@adversity/coding-tool-x 3.0.5 → 3.1.0
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 +42 -0
- package/dist/web/assets/{icons-BlzwYoRU.js → icons-CO_2OFES.js} +1 -1
- package/dist/web/assets/index-DI8QOi-E.js +14 -0
- package/dist/web/assets/index-uLHGdeZh.css +41 -0
- package/dist/web/assets/{naive-ui-B1TP-0TP.js → naive-ui-B1re3c-e.js} +1 -1
- package/dist/web/index.html +4 -4
- package/package.json +1 -1
- package/src/commands/daemon.js +11 -1
- package/src/commands/ui.js +8 -1
- package/src/index.js +3 -1
- package/src/server/api/channels.js +3 -0
- package/src/server/api/codex-channels.js +40 -0
- package/src/server/api/config-registry.js +341 -0
- package/src/server/api/gemini-channels.js +40 -0
- package/src/server/api/oauth.js +294 -0
- package/src/server/api/permissions.js +30 -15
- package/src/server/codex-proxy-server.js +30 -4
- package/src/server/config/oauth-providers.js +68 -0
- package/src/server/gemini-proxy-server.js +64 -2
- package/src/server/index.js +15 -3
- package/src/server/proxy-server.js +31 -4
- package/src/server/services/channels.js +33 -2
- package/src/server/services/codex-channels.js +35 -4
- package/src/server/services/config-registry-service.js +762 -0
- package/src/server/services/config-sync-manager.js +456 -0
- package/src/server/services/config-templates-service.js +38 -3
- package/src/server/services/gemini-channels.js +40 -1
- package/src/server/services/model-detector.js +116 -23
- package/src/server/services/oauth-callback-server.js +284 -0
- package/src/server/services/oauth-service.js +378 -0
- package/src/server/services/oauth-token-storage.js +135 -0
- package/src/server/services/permission-templates-service.js +0 -31
- package/dist/web/assets/index-19ZPjh5b.css +0 -41
- package/dist/web/assets/index-B4w1yh7H.js +0 -14
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const router = express.Router();
|
|
5
|
+
const { OAUTH_PROVIDERS, getProviderConfig } = require('../config/oauth-providers');
|
|
6
|
+
const {
|
|
7
|
+
startFlow,
|
|
8
|
+
getPendingFlow,
|
|
9
|
+
completeFlow,
|
|
10
|
+
failFlow,
|
|
11
|
+
cancelFlow,
|
|
12
|
+
exchangeCodeForToken,
|
|
13
|
+
refreshToken
|
|
14
|
+
} = require('../services/oauth-service');
|
|
15
|
+
const {
|
|
16
|
+
startCallbackServer,
|
|
17
|
+
stopCallbackServer,
|
|
18
|
+
isServerRunning
|
|
19
|
+
} = require('../services/oauth-callback-server');
|
|
20
|
+
const {
|
|
21
|
+
getAllTokens,
|
|
22
|
+
getToken,
|
|
23
|
+
saveToken,
|
|
24
|
+
updateToken,
|
|
25
|
+
deleteToken
|
|
26
|
+
} = require('../services/oauth-token-storage');
|
|
27
|
+
|
|
28
|
+
// Track callback servers by state
|
|
29
|
+
const stateToPort = new Map();
|
|
30
|
+
|
|
31
|
+
// GET /api/oauth/providers - List available OAuth providers
|
|
32
|
+
router.get('/providers', (req, res) => {
|
|
33
|
+
try {
|
|
34
|
+
const providers = Object.entries(OAUTH_PROVIDERS).map(([id, config]) => ({
|
|
35
|
+
id,
|
|
36
|
+
name: config.name,
|
|
37
|
+
scopes: config.scopes
|
|
38
|
+
}));
|
|
39
|
+
res.json({ providers });
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error('Error fetching OAuth providers:', error);
|
|
42
|
+
res.status(500).json({ error: error.message });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// POST /api/oauth/start - Start OAuth flow
|
|
47
|
+
router.post('/start', async (req, res) => {
|
|
48
|
+
try {
|
|
49
|
+
const { provider, channelId, mode = 'browser' } = req.body;
|
|
50
|
+
|
|
51
|
+
if (!provider) {
|
|
52
|
+
return res.status(400).json({ error: 'Missing required field: provider' });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Validate provider
|
|
56
|
+
if (!OAUTH_PROVIDERS[provider]) {
|
|
57
|
+
return res.status(400).json({ error: `Unknown provider: ${provider}` });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const providerConfig = getProviderConfig(provider);
|
|
61
|
+
const { state, authUrl } = startFlow(provider, channelId);
|
|
62
|
+
const callbackPort = providerConfig.callbackPort;
|
|
63
|
+
const callbackPath = providerConfig.callbackPath;
|
|
64
|
+
|
|
65
|
+
// Track state -> port mapping
|
|
66
|
+
stateToPort.set(state, callbackPort);
|
|
67
|
+
|
|
68
|
+
// Start callback server
|
|
69
|
+
await startCallbackServer(callbackPort, callbackPath, async (callbackData) => {
|
|
70
|
+
const { code, state: cbState, error, errorDescription } = callbackData;
|
|
71
|
+
|
|
72
|
+
if (error) {
|
|
73
|
+
failFlow(cbState, `${error}: ${errorDescription || 'Unknown error'}`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!code) {
|
|
78
|
+
failFlow(cbState, 'No authorization code received');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Get pending flow to retrieve code verifier
|
|
83
|
+
const flow = getPendingFlow(cbState);
|
|
84
|
+
if (!flow) {
|
|
85
|
+
console.error('OAuth callback received for unknown state:', cbState);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
// Exchange code for token
|
|
91
|
+
const tokenResult = await exchangeCodeForToken(
|
|
92
|
+
flow.provider,
|
|
93
|
+
code,
|
|
94
|
+
flow.codeVerifier
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// Calculate expiry time
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
const expiresAt = tokenResult.expiresIn
|
|
100
|
+
? now + (tokenResult.expiresIn * 1000)
|
|
101
|
+
: null;
|
|
102
|
+
|
|
103
|
+
// Save token
|
|
104
|
+
const savedToken = saveToken({
|
|
105
|
+
provider: flow.provider,
|
|
106
|
+
channelId: flow.channelId,
|
|
107
|
+
accessToken: tokenResult.accessToken,
|
|
108
|
+
refreshToken: tokenResult.refreshToken,
|
|
109
|
+
idToken: tokenResult.idToken,
|
|
110
|
+
expiresAt,
|
|
111
|
+
scope: tokenResult.scope
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Mark flow as completed
|
|
115
|
+
completeFlow(cbState, savedToken.id);
|
|
116
|
+
console.log(`OAuth flow completed for provider: ${flow.provider}, tokenId: ${savedToken.id}`);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.error('Error exchanging code for token:', err);
|
|
119
|
+
failFlow(cbState, err.message);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Open browser if mode is 'browser'
|
|
124
|
+
if (mode === 'browser') {
|
|
125
|
+
try {
|
|
126
|
+
const open = require('open');
|
|
127
|
+
await open(authUrl);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
console.error('Failed to open browser:', err);
|
|
130
|
+
// Don't fail the request, user can still open URL manually
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
res.json({
|
|
135
|
+
success: true,
|
|
136
|
+
stateId: state,
|
|
137
|
+
authUrl,
|
|
138
|
+
callbackPort
|
|
139
|
+
});
|
|
140
|
+
} catch (error) {
|
|
141
|
+
console.error('Error starting OAuth flow:', error);
|
|
142
|
+
res.status(500).json({ error: error.message });
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// GET /api/oauth/status/:stateId - Get OAuth flow status
|
|
147
|
+
router.get('/status/:stateId', (req, res) => {
|
|
148
|
+
try {
|
|
149
|
+
const { stateId } = req.params;
|
|
150
|
+
const flow = getPendingFlow(stateId);
|
|
151
|
+
|
|
152
|
+
if (!flow) {
|
|
153
|
+
return res.status(404).json({
|
|
154
|
+
status: 'not_found',
|
|
155
|
+
error: 'OAuth flow not found or expired'
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const response = {
|
|
160
|
+
status: flow.status
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
if (flow.status === 'completed' && flow.tokenId) {
|
|
164
|
+
response.tokenId = flow.tokenId;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (flow.status === 'failed' && flow.error) {
|
|
168
|
+
response.error = flow.error;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
res.json(response);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
console.error('Error fetching OAuth status:', error);
|
|
174
|
+
res.status(500).json({ error: error.message });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// POST /api/oauth/cancel/:stateId - Cancel OAuth flow
|
|
179
|
+
router.post('/cancel/:stateId', (req, res) => {
|
|
180
|
+
try {
|
|
181
|
+
const { stateId } = req.params;
|
|
182
|
+
|
|
183
|
+
// Get port for this state
|
|
184
|
+
const port = stateToPort.get(stateId);
|
|
185
|
+
|
|
186
|
+
// Stop callback server if running
|
|
187
|
+
if (port && isServerRunning(port)) {
|
|
188
|
+
stopCallbackServer(port);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Cancel the flow
|
|
192
|
+
cancelFlow(stateId);
|
|
193
|
+
|
|
194
|
+
// Clean up state -> port mapping
|
|
195
|
+
stateToPort.delete(stateId);
|
|
196
|
+
|
|
197
|
+
res.json({ success: true });
|
|
198
|
+
} catch (error) {
|
|
199
|
+
console.error('Error cancelling OAuth flow:', error);
|
|
200
|
+
res.status(500).json({ error: error.message });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// GET /api/oauth/tokens - List all tokens (masked)
|
|
205
|
+
router.get('/tokens', (req, res) => {
|
|
206
|
+
try {
|
|
207
|
+
const tokens = getAllTokens();
|
|
208
|
+
res.json({ tokens });
|
|
209
|
+
} catch (error) {
|
|
210
|
+
console.error('Error fetching OAuth tokens:', error);
|
|
211
|
+
res.status(500).json({ error: error.message });
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// GET /api/oauth/tokens/:tokenId - Get single token (masked)
|
|
216
|
+
router.get('/tokens/:tokenId', (req, res) => {
|
|
217
|
+
try {
|
|
218
|
+
const token = getToken(req.params.tokenId);
|
|
219
|
+
if (!token) {
|
|
220
|
+
return res.status(404).json({ error: 'Token not found' });
|
|
221
|
+
}
|
|
222
|
+
// Return masked token info
|
|
223
|
+
const masked = {
|
|
224
|
+
...token,
|
|
225
|
+
accessToken: token.accessToken ? token.accessToken.substring(0, 8) + '...' : null,
|
|
226
|
+
refreshToken: token.refreshToken ? token.refreshToken.substring(0, 8) + '...' : null
|
|
227
|
+
};
|
|
228
|
+
res.json(masked);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
console.error('Error fetching OAuth token:', error);
|
|
231
|
+
res.status(500).json({ error: error.message });
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// DELETE /api/oauth/tokens/:tokenId - Delete a token
|
|
236
|
+
router.delete('/tokens/:tokenId', (req, res) => {
|
|
237
|
+
try {
|
|
238
|
+
const { tokenId } = req.params;
|
|
239
|
+
const result = deleteToken(tokenId);
|
|
240
|
+
res.json(result);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (error.message === 'Token not found') {
|
|
243
|
+
return res.status(404).json({ error: 'Token not found' });
|
|
244
|
+
}
|
|
245
|
+
console.error('Error deleting OAuth token:', error);
|
|
246
|
+
res.status(500).json({ error: error.message });
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// POST /api/oauth/refresh/:tokenId - Refresh a token
|
|
251
|
+
router.post('/refresh/:tokenId', async (req, res) => {
|
|
252
|
+
try {
|
|
253
|
+
const { tokenId } = req.params;
|
|
254
|
+
const token = getToken(tokenId);
|
|
255
|
+
|
|
256
|
+
if (!token) {
|
|
257
|
+
return res.status(404).json({ error: 'Token not found' });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!token.refreshToken) {
|
|
261
|
+
return res.status(400).json({ error: 'Token does not have a refresh token' });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Refresh the token
|
|
265
|
+
const refreshResult = await refreshToken(token.provider, token.refreshToken);
|
|
266
|
+
|
|
267
|
+
// Calculate new expiry time
|
|
268
|
+
const now = Date.now();
|
|
269
|
+
const expiresAt = refreshResult.expiresIn
|
|
270
|
+
? now + (refreshResult.expiresIn * 1000)
|
|
271
|
+
: null;
|
|
272
|
+
|
|
273
|
+
// Update token in storage
|
|
274
|
+
const updatedToken = updateToken(tokenId, {
|
|
275
|
+
accessToken: refreshResult.accessToken,
|
|
276
|
+
refreshToken: refreshResult.refreshToken || token.refreshToken,
|
|
277
|
+
expiresAt,
|
|
278
|
+
scope: refreshResult.scope || token.scope
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
res.json({
|
|
282
|
+
success: true,
|
|
283
|
+
expiresAt: updatedToken.expiresAt
|
|
284
|
+
});
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (error.message === 'Token not found') {
|
|
287
|
+
return res.status(404).json({ error: 'Token not found' });
|
|
288
|
+
}
|
|
289
|
+
console.error('Error refreshing OAuth token:', error);
|
|
290
|
+
res.status(500).json({ error: error.message });
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
module.exports = router;
|
|
@@ -30,16 +30,16 @@ const permissionTemplatesService = require('../services/permission-templates-ser
|
|
|
30
30
|
const router = express.Router();
|
|
31
31
|
|
|
32
32
|
// Claude Code 设置文件路径
|
|
33
|
-
function getClaudeSettingsPath(projectPath
|
|
33
|
+
function getClaudeSettingsPath(projectPath) {
|
|
34
34
|
if (projectPath) {
|
|
35
|
-
return path.join(projectPath, '.claude',
|
|
35
|
+
return path.join(projectPath, '.claude', 'settings.json');
|
|
36
36
|
}
|
|
37
37
|
return path.join(os.homedir(), '.claude', 'settings.json');
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
// 读取 Claude Code settings.json
|
|
41
|
-
function readClaudeSettings(projectPath
|
|
42
|
-
const settingsPath = getClaudeSettingsPath(projectPath
|
|
41
|
+
function readClaudeSettings(projectPath) {
|
|
42
|
+
const settingsPath = getClaudeSettingsPath(projectPath);
|
|
43
43
|
try {
|
|
44
44
|
if (fs.existsSync(settingsPath)) {
|
|
45
45
|
const content = fs.readFileSync(settingsPath, 'utf-8');
|
|
@@ -52,15 +52,29 @@ function readClaudeSettings(projectPath, isLocal = false) {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
// 保存 Claude Code settings.json
|
|
55
|
-
function saveClaudeSettings(projectPath, settings
|
|
56
|
-
const settingsPath = getClaudeSettingsPath(projectPath
|
|
55
|
+
function saveClaudeSettings(projectPath, settings) {
|
|
56
|
+
const settingsPath = getClaudeSettingsPath(projectPath);
|
|
57
57
|
const settingsDir = path.dirname(settingsPath);
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
try {
|
|
60
|
+
// 确保目录存在
|
|
61
|
+
if (!fs.existsSync(settingsDir)) {
|
|
62
|
+
fs.mkdirSync(settingsDir, { recursive: true });
|
|
63
|
+
}
|
|
62
64
|
|
|
63
|
-
|
|
65
|
+
// 写入文件
|
|
66
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
|
67
|
+
|
|
68
|
+
// 验证文件已创建
|
|
69
|
+
if (!fs.existsSync(settingsPath)) {
|
|
70
|
+
throw new Error('文件写入后验证失败,文件未被创建');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { success: true, path: settingsPath };
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error('[Permissions API] Error saving Claude settings:', err);
|
|
76
|
+
throw new Error(`保存配置文件失败: ${err.message}`);
|
|
77
|
+
}
|
|
64
78
|
}
|
|
65
79
|
|
|
66
80
|
// 全局 all-allow 状态(内存中)
|
|
@@ -117,11 +131,11 @@ router.get('/', (req, res) => {
|
|
|
117
131
|
/**
|
|
118
132
|
* 保存项目的命令执行权限设置
|
|
119
133
|
* POST /api/permissions
|
|
120
|
-
* Body: { projectPath, settings: { allow, deny }
|
|
134
|
+
* Body: { projectPath, settings: { allow, deny } }
|
|
121
135
|
*/
|
|
122
136
|
router.post('/', (req, res) => {
|
|
123
137
|
try {
|
|
124
|
-
const { projectPath, settings: newPermissions
|
|
138
|
+
const { projectPath, settings: newPermissions } = req.body;
|
|
125
139
|
|
|
126
140
|
if (!projectPath) {
|
|
127
141
|
return res.status(400).json({
|
|
@@ -138,7 +152,7 @@ router.post('/', (req, res) => {
|
|
|
138
152
|
}
|
|
139
153
|
|
|
140
154
|
// 读取现有设置
|
|
141
|
-
const settings = readClaudeSettings(projectPath
|
|
155
|
+
const settings = readClaudeSettings(projectPath);
|
|
142
156
|
|
|
143
157
|
// 更新权限设置(使用 Claude Code 的标准格式)
|
|
144
158
|
settings.permissions = {
|
|
@@ -147,12 +161,13 @@ router.post('/', (req, res) => {
|
|
|
147
161
|
};
|
|
148
162
|
|
|
149
163
|
// 保存设置
|
|
150
|
-
saveClaudeSettings(projectPath, settings
|
|
164
|
+
const saveResult = saveClaudeSettings(projectPath, settings);
|
|
151
165
|
|
|
152
166
|
res.json({
|
|
153
167
|
success: true,
|
|
154
168
|
message: '权限设置已保存',
|
|
155
|
-
savedTo:
|
|
169
|
+
savedTo: '.claude/settings.json',
|
|
170
|
+
fullPath: saveResult.path
|
|
156
171
|
});
|
|
157
172
|
} catch (err) {
|
|
158
173
|
console.error('[Permissions API] Save permissions error:', err);
|
|
@@ -10,7 +10,7 @@ const DEFAULT_CONFIG = require('../config/default');
|
|
|
10
10
|
const { resolvePricing } = require('./utils/pricing');
|
|
11
11
|
const { recordRequest: recordCodexRequest } = require('./services/codex-statistics-service');
|
|
12
12
|
const { saveProxyStartTime, clearProxyStartTime, getProxyStartTime, getProxyRuntime } = require('./services/proxy-runtime');
|
|
13
|
-
const { getEnabledChannels, writeCodexConfigForMultiChannel } = require('./services/codex-channels');
|
|
13
|
+
const { getEnabledChannels, writeCodexConfigForMultiChannel, getEffectiveApiKey } = require('./services/codex-channels');
|
|
14
14
|
const { CLAUDE_MODEL_PRICING } = require('../config/model-pricing');
|
|
15
15
|
|
|
16
16
|
let proxyServer = null;
|
|
@@ -20,6 +20,10 @@ let currentPort = null;
|
|
|
20
20
|
// 用于存储每个请求的元数据
|
|
21
21
|
const requestMetadata = new Map();
|
|
22
22
|
|
|
23
|
+
// 用于缓存已打印过的模型重定向规则,避免重复打印
|
|
24
|
+
// 格式: { channelId: { "originalModel": "redirectedModel", ... } }
|
|
25
|
+
const printedRedirectCache = new Map();
|
|
26
|
+
|
|
23
27
|
// OpenAI 模型定价(每百万 tokens 的价格,单位:美元)
|
|
24
28
|
// Claude 模型使用 config/model-pricing.js 中的集中定价
|
|
25
29
|
const PRICING = {
|
|
@@ -253,7 +257,8 @@ async function startCodexProxyServer(options = {}) {
|
|
|
253
257
|
});
|
|
254
258
|
|
|
255
259
|
proxyReq.removeHeader('authorization');
|
|
256
|
-
|
|
260
|
+
const effectiveKey = getEffectiveApiKey(activeChannel);
|
|
261
|
+
proxyReq.setHeader('authorization', `Bearer ${effectiveKey}`);
|
|
257
262
|
proxyReq.setHeader('openai-beta', 'responses=experimental');
|
|
258
263
|
if (!proxyReq.getHeader('content-type')) {
|
|
259
264
|
proxyReq.setHeader('content-type', 'application/json');
|
|
@@ -283,7 +288,14 @@ async function startCodexProxyServer(options = {}) {
|
|
|
283
288
|
req.body.model = redirectedModel;
|
|
284
289
|
// 更新 rawBody 以匹配修改后的 body
|
|
285
290
|
req.rawBody = Buffer.from(JSON.stringify(req.body));
|
|
286
|
-
|
|
291
|
+
|
|
292
|
+
// 只在重定向规则变化时打印日志(避免每次请求都打印)
|
|
293
|
+
const cachedRedirects = printedRedirectCache.get(channel.id) || {};
|
|
294
|
+
if (cachedRedirects[originalModel] !== redirectedModel) {
|
|
295
|
+
cachedRedirects[originalModel] = redirectedModel;
|
|
296
|
+
printedRedirectCache.set(channel.id, cachedRedirects);
|
|
297
|
+
console.log(`[Codex Model Redirect] ${originalModel} → ${redirectedModel} (channel: ${channel.name})`);
|
|
298
|
+
}
|
|
287
299
|
}
|
|
288
300
|
}
|
|
289
301
|
|
|
@@ -648,8 +660,22 @@ function getCodexProxyStatus() {
|
|
|
648
660
|
};
|
|
649
661
|
}
|
|
650
662
|
|
|
663
|
+
/**
|
|
664
|
+
* 清除指定渠道的模型重定向日志缓存
|
|
665
|
+
* 用于在渠道配置更新后触发重新打印日志
|
|
666
|
+
* @param {string} channelId - 渠道 ID
|
|
667
|
+
*/
|
|
668
|
+
function clearCodexRedirectCache(channelId) {
|
|
669
|
+
if (channelId) {
|
|
670
|
+
printedRedirectCache.delete(channelId);
|
|
671
|
+
} else {
|
|
672
|
+
printedRedirectCache.clear();
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
651
676
|
module.exports = {
|
|
652
677
|
startCodexProxyServer,
|
|
653
678
|
stopCodexProxyServer,
|
|
654
|
-
getCodexProxyStatus
|
|
679
|
+
getCodexProxyStatus,
|
|
680
|
+
clearCodexRedirectCache
|
|
655
681
|
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const OAUTH_PROVIDERS = {
|
|
4
|
+
claude: {
|
|
5
|
+
name: 'Claude Pro',
|
|
6
|
+
authUrl: 'https://claude.ai/oauth/authorize',
|
|
7
|
+
tokenUrl: 'https://console.anthropic.com/v1/oauth/token',
|
|
8
|
+
clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
|
|
9
|
+
redirectUri: 'http://localhost:54545/callback',
|
|
10
|
+
scopes: ['org:create_api_key', 'user:profile', 'user:inference'],
|
|
11
|
+
authType: 'pkce',
|
|
12
|
+
callbackPort: 54545,
|
|
13
|
+
callbackPath: '/callback'
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
codex: {
|
|
17
|
+
name: 'OpenAI Codex',
|
|
18
|
+
authUrl: 'https://auth.openai.com/oauth/authorize',
|
|
19
|
+
tokenUrl: 'https://auth.openai.com/oauth/token',
|
|
20
|
+
clientId: 'app_EMoamEEZ73f0CkXaXp7hrann',
|
|
21
|
+
redirectUri: 'http://localhost:1455/auth/callback',
|
|
22
|
+
scopes: ['openid', 'profile', 'email', 'offline_access'],
|
|
23
|
+
authType: 'pkce',
|
|
24
|
+
extraParams: {
|
|
25
|
+
codex_cli_simplified_flow: 'true',
|
|
26
|
+
originator: 'ctx'
|
|
27
|
+
},
|
|
28
|
+
tokenContentType: 'application/x-www-form-urlencoded',
|
|
29
|
+
callbackPort: 1455,
|
|
30
|
+
callbackPath: '/auth/callback'
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
gemini: {
|
|
34
|
+
name: 'Google Gemini',
|
|
35
|
+
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
36
|
+
tokenUrl: 'https://oauth2.googleapis.com/token',
|
|
37
|
+
clientId: '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com',
|
|
38
|
+
clientSecret: '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',
|
|
39
|
+
redirectUri: 'http://localhost:51121/callback',
|
|
40
|
+
scopes: [
|
|
41
|
+
'https://www.googleapis.com/auth/cloud-platform',
|
|
42
|
+
'https://www.googleapis.com/auth/userinfo.email',
|
|
43
|
+
'https://www.googleapis.com/auth/userinfo.profile',
|
|
44
|
+
'https://www.googleapis.com/auth/cclog',
|
|
45
|
+
'https://www.googleapis.com/auth/experimentsandconfigs'
|
|
46
|
+
],
|
|
47
|
+
authType: 'standard',
|
|
48
|
+
extraParams: {
|
|
49
|
+
access_type: 'offline',
|
|
50
|
+
prompt: 'consent'
|
|
51
|
+
},
|
|
52
|
+
callbackPort: 51121,
|
|
53
|
+
callbackPath: '/callback'
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function getProviderConfig(provider) {
|
|
58
|
+
const config = OAUTH_PROVIDERS[provider];
|
|
59
|
+
if (!config) {
|
|
60
|
+
throw new Error(`Unknown OAuth provider: ${provider}`);
|
|
61
|
+
}
|
|
62
|
+
return { ...config };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = {
|
|
66
|
+
OAUTH_PROVIDERS,
|
|
67
|
+
getProviderConfig
|
|
68
|
+
};
|
|
@@ -10,6 +10,7 @@ const DEFAULT_CONFIG = require('../config/default');
|
|
|
10
10
|
const { resolvePricing } = require('./utils/pricing');
|
|
11
11
|
const { recordRequest: recordGeminiRequest } = require('./services/gemini-statistics-service');
|
|
12
12
|
const { saveProxyStartTime, clearProxyStartTime, getProxyStartTime, getProxyRuntime } = require('./services/proxy-runtime');
|
|
13
|
+
const { getEffectiveApiKey } = require('./services/gemini-channels');
|
|
13
14
|
|
|
14
15
|
let proxyServer = null;
|
|
15
16
|
let proxyApp = null;
|
|
@@ -18,6 +19,10 @@ let currentPort = null;
|
|
|
18
19
|
// 用于存储每个请求的元数据
|
|
19
20
|
const requestMetadata = new Map();
|
|
20
21
|
|
|
22
|
+
// 用于缓存已打印过的模型重定向规则,避免重复打印
|
|
23
|
+
// 格式: { channelId: { "originalModel": "redirectedModel", ... } }
|
|
24
|
+
const printedGeminiRedirectCache = new Map();
|
|
25
|
+
|
|
21
26
|
// Gemini 模型定价(每百万 tokens 的价格,单位:美元)
|
|
22
27
|
const PRICING = {
|
|
23
28
|
'gemini-2.5-pro': { input: 1.25, output: 5 },
|
|
@@ -47,6 +52,27 @@ function resolveGeminiTarget(baseUrl = '', requestPath = '') {
|
|
|
47
52
|
return target;
|
|
48
53
|
}
|
|
49
54
|
|
|
55
|
+
/**
|
|
56
|
+
* 应用模型重定向(精确匹配)
|
|
57
|
+
* @param {string} originalModel - 原始模型名称
|
|
58
|
+
* @param {object} channel - 渠道对象,包含 modelRedirects 数组
|
|
59
|
+
* @returns {string} 重定向后的模型名称
|
|
60
|
+
*/
|
|
61
|
+
function redirectModel(originalModel, channel) {
|
|
62
|
+
if (!originalModel) return originalModel;
|
|
63
|
+
|
|
64
|
+
const modelRedirects = channel?.modelRedirects;
|
|
65
|
+
if (Array.isArray(modelRedirects) && modelRedirects.length > 0) {
|
|
66
|
+
for (const rule of modelRedirects) {
|
|
67
|
+
if (rule.from && rule.to && rule.from === originalModel) {
|
|
68
|
+
return rule.to;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return originalModel;
|
|
74
|
+
}
|
|
75
|
+
|
|
50
76
|
/**
|
|
51
77
|
* 计算请求成本
|
|
52
78
|
*/
|
|
@@ -127,7 +153,8 @@ async function startGeminiProxyServer(options = {}) {
|
|
|
127
153
|
|
|
128
154
|
proxyReq.removeHeader('authorization');
|
|
129
155
|
proxyReq.removeHeader('x-goog-api-key');
|
|
130
|
-
|
|
156
|
+
const effectiveKey = getEffectiveApiKey(activeChannel);
|
|
157
|
+
proxyReq.setHeader('authorization', `Bearer ${effectiveKey}`);
|
|
131
158
|
if (!proxyReq.getHeader('content-type')) {
|
|
132
159
|
proxyReq.setHeader('content-type', 'application/json');
|
|
133
160
|
}
|
|
@@ -153,6 +180,27 @@ async function startGeminiProxyServer(options = {}) {
|
|
|
153
180
|
|
|
154
181
|
broadcastSchedulerState('gemini', getSchedulerState('gemini'));
|
|
155
182
|
|
|
183
|
+
// 从 URL 中提取模型名称并应用重定向
|
|
184
|
+
// URL 格式: /models/gemini-2.5-pro:generateContent 或 /v1/models/gemini-2.5-pro:generateContent
|
|
185
|
+
const urlMatch = req.url.match(/\/models\/([\w.-]+)(:[^?]*)?/);
|
|
186
|
+
if (urlMatch) {
|
|
187
|
+
const originalModel = urlMatch[1];
|
|
188
|
+
const redirectedModel = redirectModel(originalModel, channel);
|
|
189
|
+
|
|
190
|
+
if (redirectedModel !== originalModel) {
|
|
191
|
+
// 替换 URL 中的模型名称
|
|
192
|
+
req.url = req.url.replace(`/models/${originalModel}`, `/models/${redirectedModel}`);
|
|
193
|
+
|
|
194
|
+
// 只在重定向规则变化时打印日志(避免每次请求都打印)
|
|
195
|
+
const cachedRedirects = printedGeminiRedirectCache.get(channel.id) || {};
|
|
196
|
+
if (cachedRedirects[originalModel] !== redirectedModel) {
|
|
197
|
+
cachedRedirects[originalModel] = redirectedModel;
|
|
198
|
+
printedGeminiRedirectCache.set(channel.id, cachedRedirects);
|
|
199
|
+
console.log(`[Gemini Model Redirect] ${originalModel} → ${redirectedModel} (channel: ${channel.name})`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
156
204
|
const target = resolveGeminiTarget(channel.baseUrl, req.url);
|
|
157
205
|
|
|
158
206
|
proxy.web(req, res, {
|
|
@@ -511,8 +559,22 @@ function getGeminiProxyStatus() {
|
|
|
511
559
|
};
|
|
512
560
|
}
|
|
513
561
|
|
|
562
|
+
/**
|
|
563
|
+
* 清除指定渠道的模型重定向日志缓存
|
|
564
|
+
* 用于在渠道配置更新后触发重新打印日志
|
|
565
|
+
* @param {string} channelId - 渠道 ID
|
|
566
|
+
*/
|
|
567
|
+
function clearGeminiRedirectCache(channelId) {
|
|
568
|
+
if (channelId) {
|
|
569
|
+
printedGeminiRedirectCache.delete(channelId);
|
|
570
|
+
} else {
|
|
571
|
+
printedGeminiRedirectCache.clear();
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
514
575
|
module.exports = {
|
|
515
576
|
startGeminiProxyServer,
|
|
516
577
|
stopGeminiProxyServer,
|
|
517
|
-
getGeminiProxyStatus
|
|
578
|
+
getGeminiProxyStatus,
|
|
579
|
+
clearGeminiRedirectCache
|
|
518
580
|
};
|
package/src/server/index.js
CHANGED
|
@@ -14,7 +14,7 @@ const { startProxyServer } = require('./proxy-server');
|
|
|
14
14
|
const { startCodexProxyServer } = require('./codex-proxy-server');
|
|
15
15
|
const { startGeminiProxyServer } = require('./gemini-proxy-server');
|
|
16
16
|
|
|
17
|
-
async function startServer(port) {
|
|
17
|
+
async function startServer(port, host = '127.0.0.1') {
|
|
18
18
|
const config = loadConfig();
|
|
19
19
|
// 使用配置的端口,如果没有传入参数
|
|
20
20
|
if (!port) {
|
|
@@ -154,6 +154,12 @@ async function startServer(port) {
|
|
|
154
154
|
// 配置同步 API
|
|
155
155
|
app.use('/api/config-sync', require('./api/config-sync'));
|
|
156
156
|
|
|
157
|
+
// OAuth API
|
|
158
|
+
app.use('/api/oauth', require('./api/oauth'));
|
|
159
|
+
|
|
160
|
+
// 配置注册表 API (集中管理 skills/commands/agents/rules 的启用/禁用)
|
|
161
|
+
app.use('/api/config-registry', require('./api/config-registry'));
|
|
162
|
+
|
|
157
163
|
// 健康检查 API
|
|
158
164
|
app.use('/api/health-check', require('./api/health-check')(config));
|
|
159
165
|
|
|
@@ -167,9 +173,15 @@ async function startServer(port) {
|
|
|
167
173
|
}
|
|
168
174
|
|
|
169
175
|
// Start server
|
|
170
|
-
const server = app.listen(port, () => {
|
|
176
|
+
const server = app.listen(port, host, () => {
|
|
171
177
|
console.log(`\n🚀 Coding-Tool Web UI running at:`);
|
|
172
|
-
|
|
178
|
+
if (host === '0.0.0.0') {
|
|
179
|
+
console.log(chalk.yellow(` ⚠️ 警告: 服务正在监听所有网络接口 (LAN 可访问)`));
|
|
180
|
+
console.log(` http://localhost:${port}`);
|
|
181
|
+
console.log(chalk.gray(` http://<your-ip>:${port} (LAN 访问)`));
|
|
182
|
+
} else {
|
|
183
|
+
console.log(` http://localhost:${port}`);
|
|
184
|
+
}
|
|
173
185
|
|
|
174
186
|
// 附加 WebSocket 服务器到同一个端口
|
|
175
187
|
attachWebSocketServer(server);
|