@adversity/coding-tool-x 3.1.1 → 3.1.3
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 +41 -0
- package/dist/web/assets/Analytics-BIqc8Rin.css +1 -0
- package/dist/web/assets/Analytics-D2V09DHH.js +39 -0
- package/dist/web/assets/{ConfigTemplates-ZrK_s7ma.js → ConfigTemplates-Bf_11LhH.js} +1 -1
- package/dist/web/assets/Home-BRnW4FTS.js +1 -0
- package/dist/web/assets/Home-CyCIx4BA.css +1 -0
- package/dist/web/assets/{PluginManager-BD7QUZbU.js → PluginManager-B9J32GhW.js} +1 -1
- package/dist/web/assets/{ProjectList-DRb1DuHV.js → ProjectList-5a19MWJk.js} +1 -1
- package/dist/web/assets/SessionList-CXUr6S7w.css +1 -0
- package/dist/web/assets/SessionList-Cxg5bAdT.js +1 -0
- package/dist/web/assets/{SkillManager-C1xG5B4Q.js → SkillManager-CVBr0CLi.js} +1 -1
- package/dist/web/assets/{Terminal-DksBo_lM.js → Terminal-D2Xe_Q0H.js} +1 -1
- package/dist/web/assets/{WorkspaceManager-Burx7XOo.js → WorkspaceManager-C7dwV94C.js} +1 -1
- package/dist/web/assets/icons-BxcwoY5F.js +1 -0
- package/dist/web/assets/index-BS9RA6SN.js +2 -0
- package/dist/web/assets/index-DUNAVDGb.css +1 -0
- package/dist/web/assets/naive-ui-BIXcURHZ.js +1 -0
- package/dist/web/assets/{vendors-CO3Upi1d.js → vendors-i5CBGnlm.js} +1 -1
- package/dist/web/assets/{vue-vendor-DqyWIXEb.js → vue-vendor-PKd8utv_.js} +1 -1
- package/dist/web/index.html +6 -6
- package/package.json +1 -1
- package/src/config/default.js +7 -27
- package/src/config/loader.js +6 -3
- package/src/config/model-metadata.js +167 -0
- package/src/config/model-metadata.json +125 -0
- package/src/config/model-pricing.js +23 -93
- package/src/server/api/channels.js +16 -39
- package/src/server/api/codex-channels.js +15 -43
- package/src/server/api/commands.js +0 -77
- package/src/server/api/config.js +4 -1
- package/src/server/api/gemini-channels.js +16 -40
- package/src/server/api/opencode-channels.js +108 -56
- package/src/server/api/opencode-proxy.js +42 -33
- package/src/server/api/opencode-sessions.js +4 -69
- package/src/server/api/sessions.js +11 -68
- package/src/server/api/settings.js +138 -0
- package/src/server/api/skills.js +0 -44
- package/src/server/api/statistics.js +115 -1
- package/src/server/codex-proxy-server.js +32 -59
- package/src/server/gemini-proxy-server.js +21 -18
- package/src/server/index.js +13 -7
- package/src/server/opencode-proxy-server.js +1232 -197
- package/src/server/proxy-server.js +8 -8
- package/src/server/services/codex-sessions.js +105 -6
- package/src/server/services/commands-service.js +0 -29
- package/src/server/services/config-templates-service.js +38 -28
- package/src/server/services/env-checker.js +97 -9
- package/src/server/services/env-manager.js +29 -1
- package/src/server/services/opencode-channels.js +3 -1
- package/src/server/services/opencode-sessions.js +486 -218
- package/src/server/services/opencode-settings-manager.js +172 -36
- package/src/server/services/plugins-service.js +37 -28
- package/src/server/services/pty-manager.js +22 -18
- package/src/server/services/response-decoder.js +21 -0
- package/src/server/services/skill-service.js +1 -49
- package/src/server/services/speed-test.js +40 -3
- package/src/server/services/statistics-service.js +238 -1
- package/src/server/utils/pricing.js +51 -60
- package/src/server/websocket-server.js +24 -5
- package/dist/web/assets/Home-B8YfhZ3c.js +0 -1
- package/dist/web/assets/Home-Di2qsylF.css +0 -1
- package/dist/web/assets/SessionList-BGJWyneI.css +0 -1
- package/dist/web/assets/SessionList-lZ0LKzfT.js +0 -1
- package/dist/web/assets/icons-kcfLIMBB.js +0 -1
- package/dist/web/assets/index-Ufv5rCa5.css +0 -1
- package/dist/web/assets/index-lAkrRC3h.js +0 -2
- package/dist/web/assets/naive-ui-CSrLusZZ.js +0 -1
- package/src/server/api/convert.js +0 -260
- package/src/server/services/session-converter.js +0 -577
|
@@ -13,12 +13,12 @@ const { recordSuccess, recordFailure } = require('./services/channel-health');
|
|
|
13
13
|
const { loadConfig } = require('../config/loader');
|
|
14
14
|
const DEFAULT_CONFIG = require('../config/default');
|
|
15
15
|
const { PATHS, ensureStorageDirMigrated } = require('../config/paths');
|
|
16
|
-
const {
|
|
16
|
+
const { resolveModelPricing } = require('./utils/pricing');
|
|
17
|
+
const { getDefaultSpeedTestModelByToolType } = require('../config/model-metadata');
|
|
17
18
|
const { recordRequest: recordOpenCodeRequest } = require('./services/opencode-statistics-service');
|
|
18
19
|
const { saveProxyStartTime, clearProxyStartTime, getProxyStartTime, getProxyRuntime } = require('./services/proxy-runtime');
|
|
19
20
|
const { getEnabledChannels, getEffectiveApiKey } = require('./services/opencode-channels');
|
|
20
|
-
const {
|
|
21
|
-
const { CLAUDE_MODEL_PRICING } = require('../config/model-pricing');
|
|
21
|
+
const { fetchModelsFromProvider, getCachedModelInfo } = require('./services/model-detector');
|
|
22
22
|
|
|
23
23
|
let proxyServer = null;
|
|
24
24
|
let proxyApp = null;
|
|
@@ -32,7 +32,7 @@ const requestMetadata = new Map();
|
|
|
32
32
|
const printedRedirectCache = new Map();
|
|
33
33
|
|
|
34
34
|
// OpenAI 模型定价(每百万 tokens 的价格,单位:美元)
|
|
35
|
-
//
|
|
35
|
+
// 作为 model-metadata 未覆盖时的兜底值
|
|
36
36
|
const PRICING = {
|
|
37
37
|
'gpt-4o': { input: 2.5, output: 10 },
|
|
38
38
|
'gpt-4o-2024-11-20': { input: 2.5, output: 10 },
|
|
@@ -60,6 +60,21 @@ const GEMINI_CLI_CLIENT_METADATA = 'ideType=IDE_UNSPECIFIED,platform=PLATFORM_UN
|
|
|
60
60
|
const CLAUDE_SESSION_USER_ID_TTL_MS = 60 * 60 * 1000;
|
|
61
61
|
const CLAUDE_SESSION_USER_ID_CACHE_MAX = 2000;
|
|
62
62
|
const claudeSessionUserIdCache = new Map();
|
|
63
|
+
const FILE_EXTENSION_MIME_TYPES = {
|
|
64
|
+
'.pdf': 'application/pdf',
|
|
65
|
+
'.txt': 'text/plain',
|
|
66
|
+
'.md': 'text/markdown',
|
|
67
|
+
'.csv': 'text/csv',
|
|
68
|
+
'.json': 'application/json',
|
|
69
|
+
'.xml': 'application/xml',
|
|
70
|
+
'.html': 'text/html',
|
|
71
|
+
'.png': 'image/png',
|
|
72
|
+
'.jpg': 'image/jpeg',
|
|
73
|
+
'.jpeg': 'image/jpeg',
|
|
74
|
+
'.gif': 'image/gif',
|
|
75
|
+
'.webp': 'image/webp',
|
|
76
|
+
'.svg': 'image/svg+xml'
|
|
77
|
+
};
|
|
63
78
|
|
|
64
79
|
/**
|
|
65
80
|
* 检测模型层级
|
|
@@ -159,66 +174,46 @@ function resolveOpenCodeTarget(baseUrl = '', requestPath = '') {
|
|
|
159
174
|
* 计算请求成本
|
|
160
175
|
*/
|
|
161
176
|
function calculateCost(model, tokens) {
|
|
162
|
-
let
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if (
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// 非 Claude 模型,使用 PRICING 对象(OpenAI 等)
|
|
186
|
-
pricing = PRICING[model];
|
|
187
|
-
|
|
188
|
-
// 如果没有精确匹配,尝试模糊匹配
|
|
189
|
-
if (!pricing) {
|
|
190
|
-
const modelLower = model.toLowerCase();
|
|
191
|
-
if (modelLower.includes('gpt-4o-mini')) {
|
|
192
|
-
pricing = PRICING['gpt-4o-mini'];
|
|
193
|
-
} else if (modelLower.includes('gpt-4o')) {
|
|
194
|
-
pricing = PRICING['gpt-4o'];
|
|
195
|
-
} else if (modelLower.includes('gpt-4')) {
|
|
196
|
-
pricing = PRICING['gpt-4'];
|
|
197
|
-
} else if (modelLower.includes('gpt-3.5')) {
|
|
198
|
-
pricing = PRICING['gpt-3.5-turbo'];
|
|
199
|
-
} else if (modelLower.includes('o1-mini')) {
|
|
200
|
-
pricing = PRICING['o1-mini'];
|
|
201
|
-
} else if (modelLower.includes('o1-pro')) {
|
|
202
|
-
pricing = PRICING['o1-pro'];
|
|
203
|
-
} else if (modelLower.includes('o1')) {
|
|
204
|
-
pricing = PRICING['o1'];
|
|
205
|
-
} else if (modelLower.includes('o3-mini')) {
|
|
206
|
-
pricing = PRICING['o3-mini'];
|
|
207
|
-
} else if (modelLower.includes('o3')) {
|
|
208
|
-
pricing = PRICING['o3'];
|
|
209
|
-
} else if (modelLower.includes('o4-mini')) {
|
|
210
|
-
pricing = PRICING['o4-mini'];
|
|
211
|
-
}
|
|
177
|
+
let fallbackPricing = PRICING[model];
|
|
178
|
+
if (!fallbackPricing) {
|
|
179
|
+
const modelLower = String(model || '').toLowerCase();
|
|
180
|
+
if (modelLower.includes('gpt-4o-mini')) {
|
|
181
|
+
fallbackPricing = PRICING['gpt-4o-mini'];
|
|
182
|
+
} else if (modelLower.includes('gpt-4o')) {
|
|
183
|
+
fallbackPricing = PRICING['gpt-4o'];
|
|
184
|
+
} else if (modelLower.includes('gpt-4')) {
|
|
185
|
+
fallbackPricing = PRICING['gpt-4'];
|
|
186
|
+
} else if (modelLower.includes('gpt-3.5')) {
|
|
187
|
+
fallbackPricing = PRICING['gpt-3.5-turbo'];
|
|
188
|
+
} else if (modelLower.includes('o1-mini')) {
|
|
189
|
+
fallbackPricing = PRICING['o1-mini'];
|
|
190
|
+
} else if (modelLower.includes('o1-pro')) {
|
|
191
|
+
fallbackPricing = PRICING['o1-pro'];
|
|
192
|
+
} else if (modelLower.includes('o1')) {
|
|
193
|
+
fallbackPricing = PRICING['o1'];
|
|
194
|
+
} else if (modelLower.includes('o3-mini')) {
|
|
195
|
+
fallbackPricing = PRICING['o3-mini'];
|
|
196
|
+
} else if (modelLower.includes('o3')) {
|
|
197
|
+
fallbackPricing = PRICING['o3'];
|
|
198
|
+
} else if (modelLower.includes('o4-mini')) {
|
|
199
|
+
fallbackPricing = PRICING['o4-mini'];
|
|
212
200
|
}
|
|
213
201
|
}
|
|
214
202
|
|
|
215
|
-
|
|
216
|
-
pricing = resolvePricing('opencode', pricing, OPENCODE_BASE_PRICING);
|
|
203
|
+
const pricing = resolveModelPricing('opencode', model, fallbackPricing, OPENCODE_BASE_PRICING);
|
|
217
204
|
const inputRate = typeof pricing.input === 'number' ? pricing.input : OPENCODE_BASE_PRICING.input;
|
|
218
205
|
const outputRate = typeof pricing.output === 'number' ? pricing.output : OPENCODE_BASE_PRICING.output;
|
|
206
|
+
const cacheCreationRate = typeof pricing.cacheCreation === 'number' ? pricing.cacheCreation : inputRate * 1.25;
|
|
207
|
+
const cacheReadRate = typeof pricing.cacheRead === 'number' ? pricing.cacheRead : inputRate * 0.1;
|
|
208
|
+
|
|
209
|
+
const cacheCreationTokens = tokens.cacheCreation || 0;
|
|
210
|
+
const cacheReadTokens = tokens.cacheRead || 0;
|
|
211
|
+
const regularInputTokens = Math.max(0, (tokens.input || 0) - cacheCreationTokens - cacheReadTokens);
|
|
219
212
|
|
|
220
213
|
return (
|
|
221
|
-
|
|
214
|
+
regularInputTokens * inputRate / ONE_MILLION +
|
|
215
|
+
cacheCreationTokens * cacheCreationRate / ONE_MILLION +
|
|
216
|
+
cacheReadTokens * cacheReadRate / ONE_MILLION +
|
|
222
217
|
(tokens.output || 0) * outputRate / ONE_MILLION
|
|
223
218
|
);
|
|
224
219
|
}
|
|
@@ -334,6 +329,17 @@ function normalizeGatewaySourceType(channel) {
|
|
|
334
329
|
return 'codex';
|
|
335
330
|
}
|
|
336
331
|
|
|
332
|
+
function isConverterEntryChannel(channel) {
|
|
333
|
+
const presetId = String(channel?.presetId || '').trim().toLowerCase();
|
|
334
|
+
return presetId === 'entry_claude' || presetId === 'entry_codex' || presetId === 'entry_gemini';
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function getDefaultModelsByGatewaySourceType(gatewaySourceType) {
|
|
338
|
+
if (gatewaySourceType === 'claude') return [getDefaultSpeedTestModelByToolType('claude')];
|
|
339
|
+
if (gatewaySourceType === 'gemini') return [getDefaultSpeedTestModelByToolType('gemini')];
|
|
340
|
+
return [getDefaultSpeedTestModelByToolType('codex')];
|
|
341
|
+
}
|
|
342
|
+
|
|
337
343
|
function mapStainlessOs() {
|
|
338
344
|
switch (process.platform) {
|
|
339
345
|
case 'darwin':
|
|
@@ -369,53 +375,20 @@ function getRequestPathname(urlPath = '') {
|
|
|
369
375
|
}
|
|
370
376
|
}
|
|
371
377
|
|
|
372
|
-
function
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
function isChatCompletionsPath(pathname) {
|
|
377
|
-
return pathname === '/v1/chat/completions' || pathname === '/chat/completions';
|
|
378
|
+
function normalizeGatewayPath(pathname = '') {
|
|
379
|
+
const normalized = String(pathname || '').trim();
|
|
380
|
+
if (!normalized) return '/';
|
|
381
|
+
return normalized.replace(/\/+$/, '') || '/';
|
|
378
382
|
}
|
|
379
383
|
|
|
380
|
-
function
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
candidates.push(channel.model);
|
|
385
|
-
candidates.push(channel.speedTestModel);
|
|
386
|
-
|
|
387
|
-
const modelConfig = channel.modelConfig;
|
|
388
|
-
if (modelConfig && typeof modelConfig === 'object') {
|
|
389
|
-
candidates.push(modelConfig.model);
|
|
390
|
-
candidates.push(modelConfig.opusModel);
|
|
391
|
-
candidates.push(modelConfig.sonnetModel);
|
|
392
|
-
candidates.push(modelConfig.haikuModel);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
if (Array.isArray(channel.modelRedirects)) {
|
|
396
|
-
channel.modelRedirects.forEach((rule) => {
|
|
397
|
-
candidates.push(rule?.from);
|
|
398
|
-
candidates.push(rule?.to);
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
const seen = new Set();
|
|
403
|
-
const models = [];
|
|
404
|
-
candidates.forEach((model) => {
|
|
405
|
-
if (typeof model !== 'string') return;
|
|
406
|
-
const trimmed = model.trim();
|
|
407
|
-
if (!trimmed) return;
|
|
408
|
-
const key = trimmed.toLowerCase();
|
|
409
|
-
if (seen.has(key)) return;
|
|
410
|
-
seen.add(key);
|
|
411
|
-
models.push(trimmed);
|
|
412
|
-
});
|
|
413
|
-
return models;
|
|
384
|
+
function isResponsesPath(pathname) {
|
|
385
|
+
const normalized = normalizeGatewayPath(pathname);
|
|
386
|
+
return normalized.endsWith('/v1/responses') || normalized.endsWith('/responses');
|
|
414
387
|
}
|
|
415
388
|
|
|
416
|
-
function
|
|
417
|
-
const
|
|
418
|
-
return
|
|
389
|
+
function isChatCompletionsPath(pathname) {
|
|
390
|
+
const normalized = normalizeGatewayPath(pathname);
|
|
391
|
+
return normalized.endsWith('/v1/chat/completions') || normalized.endsWith('/chat/completions');
|
|
419
392
|
}
|
|
420
393
|
|
|
421
394
|
function extractTextFragments(value, fragments) {
|
|
@@ -461,10 +434,192 @@ function extractText(value) {
|
|
|
461
434
|
return fragments.join('\n').trim();
|
|
462
435
|
}
|
|
463
436
|
|
|
437
|
+
function parseBase64DataUrl(dataUrl = '') {
|
|
438
|
+
const value = typeof dataUrl === 'string' ? dataUrl.trim() : '';
|
|
439
|
+
if (!value) return null;
|
|
440
|
+
const matched = value.match(/^data:([^;,]+)?;base64,(.+)$/i);
|
|
441
|
+
if (!matched) return null;
|
|
442
|
+
return {
|
|
443
|
+
mediaType: String(matched[1] || '').trim(),
|
|
444
|
+
data: String(matched[2] || '')
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function inferMimeTypeFromFilename(filename = '', fallback = 'application/octet-stream') {
|
|
449
|
+
const ext = path.extname(String(filename || '').trim()).toLowerCase();
|
|
450
|
+
if (!ext) return fallback;
|
|
451
|
+
return FILE_EXTENSION_MIME_TYPES[ext] || fallback;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function normalizeOpenAiImageBlock(value) {
|
|
455
|
+
let imageUrl = '';
|
|
456
|
+
if (typeof value === 'string') {
|
|
457
|
+
imageUrl = value;
|
|
458
|
+
} else if (value && typeof value === 'object') {
|
|
459
|
+
if (typeof value.url === 'string') {
|
|
460
|
+
imageUrl = value.url;
|
|
461
|
+
} else if (typeof value.image_url === 'string') {
|
|
462
|
+
imageUrl = value.image_url;
|
|
463
|
+
} else if (value.image_url && typeof value.image_url === 'object' && typeof value.image_url.url === 'string') {
|
|
464
|
+
imageUrl = value.image_url.url;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const normalizedUrl = String(imageUrl || '').trim();
|
|
469
|
+
if (!normalizedUrl) return null;
|
|
470
|
+
|
|
471
|
+
const parsedDataUrl = parseBase64DataUrl(normalizedUrl);
|
|
472
|
+
if (parsedDataUrl && parsedDataUrl.data) {
|
|
473
|
+
const mediaType = parsedDataUrl.mediaType && parsedDataUrl.mediaType.startsWith('image/')
|
|
474
|
+
? parsedDataUrl.mediaType
|
|
475
|
+
: 'image/png';
|
|
476
|
+
return {
|
|
477
|
+
type: 'image',
|
|
478
|
+
source: {
|
|
479
|
+
type: 'base64',
|
|
480
|
+
media_type: mediaType,
|
|
481
|
+
data: parsedDataUrl.data
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return {
|
|
487
|
+
type: 'image',
|
|
488
|
+
source: {
|
|
489
|
+
type: 'url',
|
|
490
|
+
url: normalizedUrl
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function normalizeOpenAiFileBlock(value) {
|
|
496
|
+
if (!value || typeof value !== 'object') return null;
|
|
497
|
+
const filePayload = (value.file && typeof value.file === 'object' && !Array.isArray(value.file))
|
|
498
|
+
? value.file
|
|
499
|
+
: value;
|
|
500
|
+
const filename = typeof filePayload.filename === 'string' ? filePayload.filename.trim() : '';
|
|
501
|
+
const rawMediaType = typeof filePayload.mime_type === 'string'
|
|
502
|
+
? filePayload.mime_type.trim()
|
|
503
|
+
: (typeof filePayload.media_type === 'string' ? filePayload.media_type.trim() : '');
|
|
504
|
+
const mediaType = rawMediaType || inferMimeTypeFromFilename(filename);
|
|
505
|
+
const fileData = typeof filePayload.file_data === 'string' ? filePayload.file_data.trim() : '';
|
|
506
|
+
const fileUrl = typeof filePayload.file_url === 'string'
|
|
507
|
+
? filePayload.file_url.trim()
|
|
508
|
+
: (typeof filePayload.url === 'string' ? filePayload.url.trim() : '');
|
|
509
|
+
const fileId = typeof filePayload.file_id === 'string' ? filePayload.file_id.trim() : '';
|
|
510
|
+
|
|
511
|
+
if (fileData) {
|
|
512
|
+
const parsedDataUrl = parseBase64DataUrl(fileData);
|
|
513
|
+
if (parsedDataUrl && parsedDataUrl.data) {
|
|
514
|
+
return {
|
|
515
|
+
type: 'document',
|
|
516
|
+
source: {
|
|
517
|
+
type: 'base64',
|
|
518
|
+
media_type: parsedDataUrl.mediaType || mediaType,
|
|
519
|
+
data: parsedDataUrl.data
|
|
520
|
+
}
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
type: 'document',
|
|
526
|
+
source: {
|
|
527
|
+
type: 'base64',
|
|
528
|
+
media_type: mediaType,
|
|
529
|
+
data: fileData
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (fileUrl) {
|
|
535
|
+
return {
|
|
536
|
+
type: 'document',
|
|
537
|
+
source: {
|
|
538
|
+
type: 'url',
|
|
539
|
+
url: fileUrl
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (fileId) {
|
|
545
|
+
return {
|
|
546
|
+
type: 'text',
|
|
547
|
+
text: `[input_file:${fileId}]`
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function normalizeOpenAiContentItemToClaudeBlocks(item) {
|
|
555
|
+
if (item === null || item === undefined) return [];
|
|
556
|
+
|
|
557
|
+
if (typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean') {
|
|
558
|
+
const text = String(item);
|
|
559
|
+
return text.trim() ? [{ type: 'text', text }] : [];
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (Array.isArray(item)) {
|
|
563
|
+
return item.flatMap(normalizeOpenAiContentItemToClaudeBlocks);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (typeof item !== 'object') return [];
|
|
567
|
+
|
|
568
|
+
const itemType = String(item.type || '').trim().toLowerCase();
|
|
569
|
+
if (itemType === 'tool_use' || itemType === 'tool_result') {
|
|
570
|
+
return [item];
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (itemType === 'image' && item.source && typeof item.source === 'object') {
|
|
574
|
+
return [item];
|
|
575
|
+
}
|
|
576
|
+
if (itemType === 'document' && item.source && typeof item.source === 'object') {
|
|
577
|
+
return [item];
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (itemType === 'text' || itemType === 'input_text' || itemType === 'output_text') {
|
|
581
|
+
const text = typeof item.text === 'string' ? item.text : '';
|
|
582
|
+
if (!text.trim()) return [];
|
|
583
|
+
const block = { type: 'text', text };
|
|
584
|
+
if (item.cache_control && typeof item.cache_control === 'object') {
|
|
585
|
+
block.cache_control = item.cache_control;
|
|
586
|
+
}
|
|
587
|
+
return [block];
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (itemType === 'image_url' || itemType === 'input_image') {
|
|
591
|
+
const imageBlock = normalizeOpenAiImageBlock(item);
|
|
592
|
+
return imageBlock ? [imageBlock] : [];
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (itemType === 'file' || itemType === 'input_file') {
|
|
596
|
+
const fileBlock = normalizeOpenAiFileBlock(item);
|
|
597
|
+
return fileBlock ? [fileBlock] : [];
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (item.image_url !== undefined || item.url !== undefined) {
|
|
601
|
+
const imageBlock = normalizeOpenAiImageBlock(item);
|
|
602
|
+
if (imageBlock) return [imageBlock];
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (item.file !== undefined || item.file_data !== undefined || item.file_url !== undefined || item.file_id !== undefined) {
|
|
606
|
+
const fileBlock = normalizeOpenAiFileBlock(item);
|
|
607
|
+
if (fileBlock) return [fileBlock];
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const fallbackText = extractText(item);
|
|
611
|
+
return fallbackText ? [{ type: 'text', text: fallbackText }] : [];
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function normalizeOpenAiContentToClaudeBlocks(content) {
|
|
615
|
+
return normalizeOpenAiContentItemToClaudeBlocks(content);
|
|
616
|
+
}
|
|
617
|
+
|
|
464
618
|
function normalizeOpenAiRole(role) {
|
|
465
619
|
const value = String(role || '').trim().toLowerCase();
|
|
466
620
|
if (value === 'assistant' || value === 'model') return 'assistant';
|
|
467
|
-
if (value === 'system') return 'system';
|
|
621
|
+
if (value === 'system' || value === 'developer') return 'system';
|
|
622
|
+
if (value === 'tool') return 'tool';
|
|
468
623
|
return 'user';
|
|
469
624
|
}
|
|
470
625
|
|
|
@@ -521,6 +676,17 @@ function normalizeToolChoiceToClaude(toolChoice) {
|
|
|
521
676
|
return undefined;
|
|
522
677
|
}
|
|
523
678
|
|
|
679
|
+
function normalizeReasoningEffortToClaude(reasoningEffort) {
|
|
680
|
+
const effort = String(reasoningEffort || '').trim().toLowerCase();
|
|
681
|
+
if (!effort) return undefined;
|
|
682
|
+
if (effort === 'none') return { type: 'disabled' };
|
|
683
|
+
if (effort === 'auto') return { type: 'enabled' };
|
|
684
|
+
if (effort === 'low') return { type: 'enabled', budget_tokens: 2048 };
|
|
685
|
+
if (effort === 'medium') return { type: 'enabled', budget_tokens: 8192 };
|
|
686
|
+
if (effort === 'high') return { type: 'enabled', budget_tokens: 24576 };
|
|
687
|
+
return undefined;
|
|
688
|
+
}
|
|
689
|
+
|
|
524
690
|
function generateToolCallId() {
|
|
525
691
|
return `toolu_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
526
692
|
}
|
|
@@ -597,24 +763,41 @@ function buildUserToolResultMessage(item) {
|
|
|
597
763
|
}
|
|
598
764
|
|
|
599
765
|
function normalizeOpenCodeMessages(pathname, payload = {}) {
|
|
600
|
-
const
|
|
766
|
+
const systemBlocks = [];
|
|
601
767
|
const messages = [];
|
|
602
768
|
|
|
603
769
|
if (isResponsesPath(pathname) && typeof payload.instructions === 'string' && payload.instructions.trim()) {
|
|
604
|
-
|
|
770
|
+
systemBlocks.push({ type: 'text', text: payload.instructions.trim() });
|
|
605
771
|
}
|
|
606
772
|
|
|
607
|
-
const appendMessage = (role, content) => {
|
|
773
|
+
const appendMessage = (role, content, topLevelCacheControl) => {
|
|
608
774
|
const normalizedRole = normalizeOpenAiRole(role);
|
|
609
|
-
const
|
|
610
|
-
if (!text) return;
|
|
775
|
+
const contentBlocks = normalizeOpenAiContentToClaudeBlocks(content);
|
|
611
776
|
if (normalizedRole === 'system') {
|
|
612
|
-
|
|
777
|
+
const blocks = contentBlocks
|
|
778
|
+
.filter(block => block && block.type === 'text' && typeof block.text === 'string' && block.text.trim());
|
|
779
|
+
blocks.forEach((block, idx) => {
|
|
780
|
+
const systemBlock = { type: 'text', text: block.text };
|
|
781
|
+
if (block.cache_control && typeof block.cache_control === 'object') {
|
|
782
|
+
systemBlock.cache_control = block.cache_control;
|
|
783
|
+
} else if (topLevelCacheControl && typeof topLevelCacheControl === 'object' && idx === blocks.length - 1) {
|
|
784
|
+
// 消息顶层的 cache_control(OpenCode/Vercel AI SDK 注入方式)打在最后一个 block 上
|
|
785
|
+
systemBlock.cache_control = topLevelCacheControl;
|
|
786
|
+
}
|
|
787
|
+
systemBlocks.push(systemBlock);
|
|
788
|
+
});
|
|
613
789
|
return;
|
|
614
790
|
}
|
|
791
|
+
|
|
792
|
+
if (!Array.isArray(contentBlocks) || contentBlocks.length === 0) return;
|
|
793
|
+
// 将消息顶层的 cache_control 传递到最后一个 content block 上
|
|
794
|
+
if (topLevelCacheControl && typeof topLevelCacheControl === 'object' && contentBlocks.length > 0) {
|
|
795
|
+
const lastBlock = contentBlocks[contentBlocks.length - 1];
|
|
796
|
+
if (!lastBlock.cache_control) lastBlock.cache_control = topLevelCacheControl;
|
|
797
|
+
}
|
|
615
798
|
messages.push({
|
|
616
799
|
role: normalizedRole === 'assistant' ? 'assistant' : 'user',
|
|
617
|
-
content:
|
|
800
|
+
content: contentBlocks
|
|
618
801
|
});
|
|
619
802
|
};
|
|
620
803
|
|
|
@@ -636,7 +819,7 @@ function normalizeOpenCodeMessages(pathname, payload = {}) {
|
|
|
636
819
|
return;
|
|
637
820
|
}
|
|
638
821
|
if (item.type === 'message' || item.role) {
|
|
639
|
-
appendMessage(item.role, item.content);
|
|
822
|
+
appendMessage(item.role, item.content, item.cache_control);
|
|
640
823
|
}
|
|
641
824
|
});
|
|
642
825
|
}
|
|
@@ -650,11 +833,7 @@ function normalizeOpenCodeMessages(pathname, payload = {}) {
|
|
|
650
833
|
return;
|
|
651
834
|
}
|
|
652
835
|
if (message.role === 'assistant' && Array.isArray(message.tool_calls) && message.tool_calls.length > 0) {
|
|
653
|
-
const assistantContent =
|
|
654
|
-
const text = extractText(message.content);
|
|
655
|
-
if (text) {
|
|
656
|
-
assistantContent.push({ type: 'text', text });
|
|
657
|
-
}
|
|
836
|
+
const assistantContent = normalizeOpenAiContentToClaudeBlocks(message.content);
|
|
658
837
|
|
|
659
838
|
message.tool_calls.forEach(toolCall => {
|
|
660
839
|
if (!toolCall || typeof toolCall !== 'object') return;
|
|
@@ -679,7 +858,7 @@ function normalizeOpenCodeMessages(pathname, payload = {}) {
|
|
|
679
858
|
}
|
|
680
859
|
return;
|
|
681
860
|
}
|
|
682
|
-
appendMessage(message.role, message.content);
|
|
861
|
+
appendMessage(message.role, message.content, message.cache_control);
|
|
683
862
|
});
|
|
684
863
|
}
|
|
685
864
|
|
|
@@ -691,7 +870,7 @@ function normalizeOpenCodeMessages(pathname, payload = {}) {
|
|
|
691
870
|
}
|
|
692
871
|
|
|
693
872
|
return {
|
|
694
|
-
|
|
873
|
+
systemBlocks,
|
|
695
874
|
messages
|
|
696
875
|
};
|
|
697
876
|
}
|
|
@@ -710,9 +889,61 @@ function normalizeClaudeMetadata(metadata, fallbackUserId = '') {
|
|
|
710
889
|
return normalized;
|
|
711
890
|
}
|
|
712
891
|
|
|
892
|
+
function applyPromptCachingToClaudePayload(converted) {
|
|
893
|
+
const EPHEMERAL = { type: 'ephemeral' };
|
|
894
|
+
|
|
895
|
+
// 统计 messages 中上游(OpenCode)已注入的缓存断点数量
|
|
896
|
+
// OpenCode 策略:对最后2条非system消息打断点,我们不重复注入
|
|
897
|
+
let messageBreakpoints = 0;
|
|
898
|
+
if (Array.isArray(converted.messages)) {
|
|
899
|
+
converted.messages.forEach(msg => {
|
|
900
|
+
if (Array.isArray(msg.content)) {
|
|
901
|
+
msg.content.forEach(block => {
|
|
902
|
+
if (block.cache_control) messageBreakpoints++;
|
|
903
|
+
if (block.type === 'tool_result' && Array.isArray(block.content)) {
|
|
904
|
+
block.content.forEach(inner => {
|
|
905
|
+
if (inner.cache_control) messageBreakpoints++;
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// 统计 system 中已有的断点
|
|
914
|
+
let systemBreakpoints = 0;
|
|
915
|
+
if (Array.isArray(converted.system)) {
|
|
916
|
+
converted.system.forEach(block => {
|
|
917
|
+
if (block.cache_control) systemBreakpoints++;
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// 若 messages 已有断点,说明上游(OpenCode)已处理,不再注入 messages 断点
|
|
922
|
+
// 只在 system blocks 没有断点时补充(OpenCode 不操作 system,由我们负责)
|
|
923
|
+
if (systemBreakpoints === 0 && Array.isArray(converted.system) && converted.system.length > 0) {
|
|
924
|
+
const last = converted.system[converted.system.length - 1];
|
|
925
|
+
if (!last.cache_control) last.cache_control = EPHEMERAL;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// 若上游完全没有注入任何断点(非 OpenCode 客户端),按原策略补充 messages 断点
|
|
929
|
+
if (messageBreakpoints === 0 && systemBreakpoints === 0) {
|
|
930
|
+
// 对最后2条消息打断点,与 OpenCode 策略对齐
|
|
931
|
+
if (Array.isArray(converted.messages) && converted.messages.length > 0) {
|
|
932
|
+
for (const msg of converted.messages.slice(-2)) {
|
|
933
|
+
if (Array.isArray(msg.content) && msg.content.length > 0) {
|
|
934
|
+
const last = msg.content[msg.content.length - 1];
|
|
935
|
+
if (!last.cache_control) last.cache_control = EPHEMERAL;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
713
942
|
function convertOpenCodePayloadToClaude(pathname, payload = {}, fallbackModel = '', options = {}) {
|
|
714
943
|
const normalized = normalizeOpenCodeMessages(pathname, payload);
|
|
715
944
|
const maxTokens = Number(payload.max_output_tokens ?? payload.max_tokens);
|
|
945
|
+
const stopSequences = normalizeStopSequences(payload.stop);
|
|
946
|
+
const thinking = normalizeReasoningEffortToClaude(payload.reasoning_effort);
|
|
716
947
|
|
|
717
948
|
const converted = {
|
|
718
949
|
model: payload.model || fallbackModel || 'claude-sonnet-4-20250514',
|
|
@@ -721,14 +952,10 @@ function convertOpenCodePayloadToClaude(pathname, payload = {}, fallbackModel =
|
|
|
721
952
|
messages: normalized.messages
|
|
722
953
|
};
|
|
723
954
|
|
|
724
|
-
if (normalized.
|
|
955
|
+
if (normalized.systemBlocks && normalized.systemBlocks.length > 0) {
|
|
725
956
|
// 部分 relay 仅接受 Claude system 的 block 数组格式,不接受纯字符串
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
type: 'text',
|
|
729
|
-
text: normalized.system
|
|
730
|
-
}
|
|
731
|
-
];
|
|
957
|
+
// 保留原始 cache_control 字段,确保 prompt cache 正常命中
|
|
958
|
+
converted.system = normalized.systemBlocks;
|
|
732
959
|
}
|
|
733
960
|
|
|
734
961
|
const tools = normalizeOpenAiToolsToClaude(payload.tools || []);
|
|
@@ -740,6 +967,12 @@ function convertOpenCodePayloadToClaude(pathname, payload = {}, fallbackModel =
|
|
|
740
967
|
if (toolChoice) {
|
|
741
968
|
converted.tool_choice = toolChoice;
|
|
742
969
|
}
|
|
970
|
+
if (stopSequences) {
|
|
971
|
+
converted.stop_sequences = stopSequences;
|
|
972
|
+
}
|
|
973
|
+
if (thinking) {
|
|
974
|
+
converted.thinking = thinking;
|
|
975
|
+
}
|
|
743
976
|
|
|
744
977
|
if (Number.isFinite(Number(payload.temperature))) {
|
|
745
978
|
converted.temperature = Number(payload.temperature);
|
|
@@ -754,6 +987,9 @@ function convertOpenCodePayloadToClaude(pathname, payload = {}, fallbackModel =
|
|
|
754
987
|
// 某些 Claude relay 会校验 metadata.user_id 以识别 Claude Code 请求
|
|
755
988
|
converted.metadata = normalizeClaudeMetadata(payload.metadata, options.sessionUserId);
|
|
756
989
|
|
|
990
|
+
// 注入 prompt cache 断点,对齐 Anthropic AI SDK 的自动缓存行为
|
|
991
|
+
applyPromptCachingToClaudePayload(converted);
|
|
992
|
+
|
|
757
993
|
return converted;
|
|
758
994
|
}
|
|
759
995
|
|
|
@@ -761,6 +997,12 @@ function normalizeOpenAiToolsToGemini(tools = []) {
|
|
|
761
997
|
if (!Array.isArray(tools)) return [];
|
|
762
998
|
|
|
763
999
|
const functionDeclarations = [];
|
|
1000
|
+
const builtInTools = [];
|
|
1001
|
+
const appendBuiltInTool = (toolNode) => {
|
|
1002
|
+
if (!toolNode || typeof toolNode !== 'object') return;
|
|
1003
|
+
builtInTools.push(toolNode);
|
|
1004
|
+
};
|
|
1005
|
+
|
|
764
1006
|
for (const tool of tools) {
|
|
765
1007
|
if (!tool || typeof tool !== 'object') continue;
|
|
766
1008
|
|
|
@@ -781,11 +1023,56 @@ function normalizeOpenAiToolsToGemini(tools = []) {
|
|
|
781
1023
|
description: tool.description || '',
|
|
782
1024
|
parameters: tool.parameters || { type: 'object', properties: {} }
|
|
783
1025
|
});
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const normalizedType = String(tool.type || '').trim().toLowerCase();
|
|
1030
|
+
|
|
1031
|
+
if (tool.google_search && typeof tool.google_search === 'object') {
|
|
1032
|
+
appendBuiltInTool({ googleSearch: tool.google_search });
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
if (tool.code_execution && typeof tool.code_execution === 'object') {
|
|
1036
|
+
appendBuiltInTool({ codeExecution: tool.code_execution });
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
if (tool.url_context && typeof tool.url_context === 'object') {
|
|
1040
|
+
appendBuiltInTool({ urlContext: tool.url_context });
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
if (normalizedType === 'google_search' || normalizedType === 'web_search' || normalizedType === 'web_search_preview') {
|
|
1045
|
+
const searchConfig = (tool.web_search && typeof tool.web_search === 'object')
|
|
1046
|
+
? tool.web_search
|
|
1047
|
+
: ((tool.googleSearch && typeof tool.googleSearch === 'object') ? tool.googleSearch : {});
|
|
1048
|
+
appendBuiltInTool({ googleSearch: searchConfig });
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
if (normalizedType === 'code_execution' || normalizedType === 'code_interpreter') {
|
|
1053
|
+
const executionConfig = (tool.codeExecution && typeof tool.codeExecution === 'object')
|
|
1054
|
+
? tool.codeExecution
|
|
1055
|
+
: {};
|
|
1056
|
+
appendBuiltInTool({ codeExecution: executionConfig });
|
|
1057
|
+
continue;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
if (normalizedType === 'url_context') {
|
|
1061
|
+
const urlContextConfig = (tool.urlContext && typeof tool.urlContext === 'object')
|
|
1062
|
+
? tool.urlContext
|
|
1063
|
+
: {};
|
|
1064
|
+
appendBuiltInTool({ urlContext: urlContextConfig });
|
|
784
1065
|
}
|
|
785
1066
|
}
|
|
786
1067
|
|
|
787
|
-
|
|
788
|
-
|
|
1068
|
+
const normalizedTools = [];
|
|
1069
|
+
if (functionDeclarations.length > 0) {
|
|
1070
|
+
normalizedTools.push({ functionDeclarations });
|
|
1071
|
+
}
|
|
1072
|
+
if (builtInTools.length > 0) {
|
|
1073
|
+
normalizedTools.push(...builtInTools);
|
|
1074
|
+
}
|
|
1075
|
+
return normalizedTools;
|
|
789
1076
|
}
|
|
790
1077
|
|
|
791
1078
|
function normalizeToolChoiceToGemini(toolChoice) {
|
|
@@ -828,6 +1115,44 @@ function normalizeToolChoiceToGemini(toolChoice) {
|
|
|
828
1115
|
return undefined;
|
|
829
1116
|
}
|
|
830
1117
|
|
|
1118
|
+
function normalizeReasoningEffortToGemini(reasoningEffort) {
|
|
1119
|
+
const effort = String(reasoningEffort || '').trim().toLowerCase();
|
|
1120
|
+
if (!effort) return undefined;
|
|
1121
|
+
if (effort === 'none') {
|
|
1122
|
+
return {
|
|
1123
|
+
includeThoughts: false,
|
|
1124
|
+
thinkingBudget: 0
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
if (effort === 'auto') {
|
|
1128
|
+
return {
|
|
1129
|
+
includeThoughts: true,
|
|
1130
|
+
thinkingBudget: -1
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
if (effort === 'low' || effort === 'medium' || effort === 'high') {
|
|
1134
|
+
return {
|
|
1135
|
+
includeThoughts: true,
|
|
1136
|
+
thinkingLevel: effort
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
return undefined;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function normalizeGeminiResponseModalities(modalities) {
|
|
1143
|
+
if (!Array.isArray(modalities)) return undefined;
|
|
1144
|
+
const mapped = modalities
|
|
1145
|
+
.map(item => String(item || '').trim().toLowerCase())
|
|
1146
|
+
.filter(Boolean)
|
|
1147
|
+
.map(item => {
|
|
1148
|
+
if (item === 'text') return 'TEXT';
|
|
1149
|
+
if (item === 'image') return 'IMAGE';
|
|
1150
|
+
return '';
|
|
1151
|
+
})
|
|
1152
|
+
.filter(Boolean);
|
|
1153
|
+
return mapped.length > 0 ? mapped : undefined;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
831
1156
|
function normalizeStopSequences(stopValue) {
|
|
832
1157
|
if (!stopValue) return undefined;
|
|
833
1158
|
if (typeof stopValue === 'string' && stopValue.trim()) {
|
|
@@ -863,6 +1188,42 @@ function normalizeGeminiFunctionResponsePayload(value) {
|
|
|
863
1188
|
return { content: normalizeToolResultContent(value) };
|
|
864
1189
|
}
|
|
865
1190
|
|
|
1191
|
+
function normalizeGeminiMediaType(value, fallback = 'application/octet-stream') {
|
|
1192
|
+
const mediaType = typeof value === 'string' ? value.trim() : '';
|
|
1193
|
+
return mediaType || fallback;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function buildGeminiPartFromClaudeMediaBlock(block) {
|
|
1197
|
+
if (!block || typeof block !== 'object') return null;
|
|
1198
|
+
const source = (block.source && typeof block.source === 'object') ? block.source : null;
|
|
1199
|
+
if (!source) return null;
|
|
1200
|
+
|
|
1201
|
+
const blockType = String(block.type || '').trim().toLowerCase();
|
|
1202
|
+
const defaultMimeType = blockType === 'image' ? 'image/png' : 'application/octet-stream';
|
|
1203
|
+
const sourceType = String(source.type || '').trim().toLowerCase();
|
|
1204
|
+
const mediaType = normalizeGeminiMediaType(source.media_type || source.mime_type, defaultMimeType);
|
|
1205
|
+
|
|
1206
|
+
if (sourceType === 'base64' && typeof source.data === 'string' && source.data.trim()) {
|
|
1207
|
+
return {
|
|
1208
|
+
inlineData: {
|
|
1209
|
+
mimeType: mediaType,
|
|
1210
|
+
data: source.data
|
|
1211
|
+
}
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
if (sourceType === 'url' && typeof source.url === 'string' && source.url.trim()) {
|
|
1216
|
+
return {
|
|
1217
|
+
fileData: {
|
|
1218
|
+
mimeType: mediaType,
|
|
1219
|
+
fileUri: source.url.trim()
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
return null;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
866
1227
|
function buildGeminiContents(messages = []) {
|
|
867
1228
|
const contents = [];
|
|
868
1229
|
const toolNameById = new Map();
|
|
@@ -917,6 +1278,14 @@ function buildGeminiContents(messages = []) {
|
|
|
917
1278
|
continue;
|
|
918
1279
|
}
|
|
919
1280
|
|
|
1281
|
+
if (block.type === 'image' || block.type === 'document') {
|
|
1282
|
+
const mediaPart = buildGeminiPartFromClaudeMediaBlock(block);
|
|
1283
|
+
if (mediaPart) {
|
|
1284
|
+
parts.push(mediaPart);
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
920
1289
|
const text = extractText(block);
|
|
921
1290
|
if (text) {
|
|
922
1291
|
parts.push({ text });
|
|
@@ -1014,14 +1383,20 @@ function convertOpenCodePayloadToGemini(pathname, payload = {}, fallbackModel =
|
|
|
1014
1383
|
const stopSequences = normalizeStopSequences(payload.stop);
|
|
1015
1384
|
const tools = normalizeOpenAiToolsToGemini(payload.tools || []);
|
|
1016
1385
|
const toolConfig = normalizeToolChoiceToGemini(payload.tool_choice);
|
|
1386
|
+
const thinkingConfig = normalizeReasoningEffortToGemini(payload.reasoning_effort);
|
|
1387
|
+
const candidateCount = Number(payload.n);
|
|
1388
|
+
const responseModalities = normalizeGeminiResponseModalities(payload.modalities);
|
|
1389
|
+
const imageConfig = (payload.image_config && typeof payload.image_config === 'object' && !Array.isArray(payload.image_config))
|
|
1390
|
+
? payload.image_config
|
|
1391
|
+
: null;
|
|
1017
1392
|
|
|
1018
1393
|
const requestBody = {
|
|
1019
1394
|
contents: buildGeminiContents(normalized.messages)
|
|
1020
1395
|
};
|
|
1021
1396
|
|
|
1022
|
-
if (normalized.
|
|
1397
|
+
if (normalized.systemBlocks && normalized.systemBlocks.length > 0) {
|
|
1023
1398
|
requestBody.systemInstruction = {
|
|
1024
|
-
parts:
|
|
1399
|
+
parts: normalized.systemBlocks.map(block => ({ text: block.text || '' })).filter(p => p.text)
|
|
1025
1400
|
};
|
|
1026
1401
|
}
|
|
1027
1402
|
|
|
@@ -1041,6 +1416,27 @@ function convertOpenCodePayloadToGemini(pathname, payload = {}, fallbackModel =
|
|
|
1041
1416
|
if (stopSequences) {
|
|
1042
1417
|
generationConfig.stopSequences = stopSequences;
|
|
1043
1418
|
}
|
|
1419
|
+
if (thinkingConfig) {
|
|
1420
|
+
generationConfig.thinkingConfig = thinkingConfig;
|
|
1421
|
+
}
|
|
1422
|
+
if (Number.isFinite(candidateCount) && candidateCount > 1) {
|
|
1423
|
+
generationConfig.candidateCount = Math.round(candidateCount);
|
|
1424
|
+
}
|
|
1425
|
+
if (responseModalities) {
|
|
1426
|
+
generationConfig.responseModalities = responseModalities;
|
|
1427
|
+
}
|
|
1428
|
+
if (imageConfig) {
|
|
1429
|
+
const mappedImageConfig = {};
|
|
1430
|
+
if (typeof imageConfig.aspect_ratio === 'string' && imageConfig.aspect_ratio.trim()) {
|
|
1431
|
+
mappedImageConfig.aspectRatio = imageConfig.aspect_ratio.trim();
|
|
1432
|
+
}
|
|
1433
|
+
if (typeof imageConfig.image_size === 'string' && imageConfig.image_size.trim()) {
|
|
1434
|
+
mappedImageConfig.imageSize = imageConfig.image_size.trim();
|
|
1435
|
+
}
|
|
1436
|
+
if (Object.keys(mappedImageConfig).length > 0) {
|
|
1437
|
+
generationConfig.imageConfig = mappedImageConfig;
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1044
1440
|
if (Object.keys(generationConfig).length > 0) {
|
|
1045
1441
|
requestBody.generationConfig = generationConfig;
|
|
1046
1442
|
}
|
|
@@ -1314,12 +1710,22 @@ function extractClaudeResponseContent(claudeResponse = {}) {
|
|
|
1314
1710
|
const textFragments = [];
|
|
1315
1711
|
const functionCalls = [];
|
|
1316
1712
|
const reasoningItems = [];
|
|
1317
|
-
|
|
1318
|
-
|
|
1713
|
+
const nestedResponse = claudeResponse?.response && typeof claudeResponse.response === 'object'
|
|
1714
|
+
? claudeResponse.response
|
|
1715
|
+
: null;
|
|
1716
|
+
const contentBlocks = Array.isArray(claudeResponse.content)
|
|
1717
|
+
? claudeResponse.content
|
|
1718
|
+
: (Array.isArray(nestedResponse?.content) ? nestedResponse.content : null);
|
|
1719
|
+
|
|
1720
|
+
if (!Array.isArray(contentBlocks)) {
|
|
1721
|
+
const messageContent = claudeResponse?.choices?.[0]?.message?.content;
|
|
1722
|
+
if (typeof messageContent === 'string' && messageContent.trim()) {
|
|
1723
|
+
return { text: messageContent.trim(), functionCalls: [], reasoningItems: [] };
|
|
1724
|
+
}
|
|
1319
1725
|
return { text: '', functionCalls: [], reasoningItems: [] };
|
|
1320
1726
|
}
|
|
1321
1727
|
|
|
1322
|
-
|
|
1728
|
+
contentBlocks.forEach(block => {
|
|
1323
1729
|
if (!block || typeof block !== 'object') return;
|
|
1324
1730
|
|
|
1325
1731
|
if (typeof block.text === 'string' && block.text.trim()) {
|
|
@@ -1357,6 +1763,109 @@ function extractClaudeResponseContent(claudeResponse = {}) {
|
|
|
1357
1763
|
};
|
|
1358
1764
|
}
|
|
1359
1765
|
|
|
1766
|
+
function toNumberOrZero(value) {
|
|
1767
|
+
const num = Number(value);
|
|
1768
|
+
return Number.isFinite(num) ? num : 0;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
function pickFirstFiniteNumber(values = []) {
|
|
1772
|
+
for (const value of values) {
|
|
1773
|
+
const num = Number(value);
|
|
1774
|
+
if (Number.isFinite(num)) return num;
|
|
1775
|
+
}
|
|
1776
|
+
return null;
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
function extractClaudeLikeUsage(claudeResponse = {}) {
|
|
1780
|
+
const nestedResponse = claudeResponse?.response && typeof claudeResponse.response === 'object'
|
|
1781
|
+
? claudeResponse.response
|
|
1782
|
+
: {};
|
|
1783
|
+
const messageObject = claudeResponse?.message && typeof claudeResponse.message === 'object'
|
|
1784
|
+
? claudeResponse.message
|
|
1785
|
+
: {};
|
|
1786
|
+
|
|
1787
|
+
const usageCandidates = [
|
|
1788
|
+
claudeResponse?.usage,
|
|
1789
|
+
nestedResponse?.usage,
|
|
1790
|
+
messageObject?.usage
|
|
1791
|
+
].filter(item => item && typeof item === 'object');
|
|
1792
|
+
|
|
1793
|
+
const metadataCandidates = [
|
|
1794
|
+
claudeResponse?.providerMetadata,
|
|
1795
|
+
nestedResponse?.providerMetadata,
|
|
1796
|
+
claudeResponse?.metadata,
|
|
1797
|
+
nestedResponse?.metadata
|
|
1798
|
+
].filter(item => item && typeof item === 'object');
|
|
1799
|
+
|
|
1800
|
+
const inputTokens = pickFirstFiniteNumber(
|
|
1801
|
+
usageCandidates.flatMap(usage => [
|
|
1802
|
+
usage.input_tokens,
|
|
1803
|
+
usage.prompt_tokens,
|
|
1804
|
+
usage.inputTokens,
|
|
1805
|
+
usage.promptTokens
|
|
1806
|
+
])
|
|
1807
|
+
);
|
|
1808
|
+
|
|
1809
|
+
const outputTokens = pickFirstFiniteNumber(
|
|
1810
|
+
usageCandidates.flatMap(usage => [
|
|
1811
|
+
usage.output_tokens,
|
|
1812
|
+
usage.completion_tokens,
|
|
1813
|
+
usage.outputTokens,
|
|
1814
|
+
usage.completionTokens
|
|
1815
|
+
])
|
|
1816
|
+
);
|
|
1817
|
+
|
|
1818
|
+
const totalTokens = pickFirstFiniteNumber(
|
|
1819
|
+
usageCandidates.flatMap(usage => [
|
|
1820
|
+
usage.total_tokens,
|
|
1821
|
+
usage.totalTokens
|
|
1822
|
+
])
|
|
1823
|
+
);
|
|
1824
|
+
|
|
1825
|
+
const cacheReadTokens = pickFirstFiniteNumber(
|
|
1826
|
+
usageCandidates.flatMap(usage => [
|
|
1827
|
+
usage.cache_read_input_tokens,
|
|
1828
|
+
usage.cacheReadInputTokens,
|
|
1829
|
+
usage.input_tokens_details?.cached_tokens,
|
|
1830
|
+
usage.prompt_tokens_details?.cached_tokens
|
|
1831
|
+
])
|
|
1832
|
+
);
|
|
1833
|
+
|
|
1834
|
+
const cacheCreationFromUsage = pickFirstFiniteNumber(
|
|
1835
|
+
usageCandidates.flatMap(usage => [
|
|
1836
|
+
usage.cache_creation_input_tokens,
|
|
1837
|
+
usage.cacheCreationInputTokens
|
|
1838
|
+
])
|
|
1839
|
+
);
|
|
1840
|
+
const cacheCreationFromMetadata = pickFirstFiniteNumber(
|
|
1841
|
+
metadataCandidates.flatMap(metadata => [
|
|
1842
|
+
metadata?.anthropic?.cacheCreationInputTokens,
|
|
1843
|
+
metadata?.venice?.usage?.cacheCreationInputTokens,
|
|
1844
|
+
metadata?.bedrock?.usage?.cacheWriteInputTokens
|
|
1845
|
+
])
|
|
1846
|
+
);
|
|
1847
|
+
|
|
1848
|
+
const reasoningTokens = pickFirstFiniteNumber(
|
|
1849
|
+
usageCandidates.flatMap(usage => [
|
|
1850
|
+
usage.output_tokens_details?.reasoning_tokens,
|
|
1851
|
+
usage.completion_tokens_details?.reasoning_tokens,
|
|
1852
|
+
usage.reasoning_tokens,
|
|
1853
|
+
usage.reasoningTokens
|
|
1854
|
+
])
|
|
1855
|
+
);
|
|
1856
|
+
|
|
1857
|
+
return {
|
|
1858
|
+
inputTokens: toNumberOrZero(inputTokens),
|
|
1859
|
+
outputTokens: toNumberOrZero(outputTokens),
|
|
1860
|
+
totalTokens: toNumberOrZero(totalTokens),
|
|
1861
|
+
cacheReadTokens: toNumberOrZero(cacheReadTokens),
|
|
1862
|
+
cacheCreationTokens: toNumberOrZero(
|
|
1863
|
+
cacheCreationFromMetadata !== null ? cacheCreationFromMetadata : cacheCreationFromUsage
|
|
1864
|
+
),
|
|
1865
|
+
reasoningTokens: toNumberOrZero(reasoningTokens)
|
|
1866
|
+
};
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1360
1869
|
function extractClaudeResponseText(claudeResponse = {}) {
|
|
1361
1870
|
return extractClaudeResponseContent(claudeResponse).text;
|
|
1362
1871
|
}
|
|
@@ -1460,13 +1969,17 @@ function mapGeminiFinishReasonToChatFinishReason(finishReason, hasToolCalls = fa
|
|
|
1460
1969
|
}
|
|
1461
1970
|
|
|
1462
1971
|
function buildOpenAiResponsesObject(claudeResponse = {}, fallbackModel = '') {
|
|
1463
|
-
const
|
|
1464
|
-
const
|
|
1465
|
-
const
|
|
1972
|
+
const usage = extractClaudeLikeUsage(claudeResponse);
|
|
1973
|
+
const inputTokens = usage.inputTokens;
|
|
1974
|
+
const outputTokens = usage.outputTokens;
|
|
1975
|
+
const totalTokens = usage.totalTokens > 0 ? usage.totalTokens : (inputTokens + outputTokens);
|
|
1976
|
+
const cacheCreationTokens = usage.cacheCreationTokens;
|
|
1977
|
+
const cacheReadTokens = usage.cacheReadTokens;
|
|
1466
1978
|
const parsedContent = extractClaudeResponseContent(claudeResponse);
|
|
1467
1979
|
const text = parsedContent.text;
|
|
1468
|
-
const
|
|
1469
|
-
const
|
|
1980
|
+
const estimatedReasoningTokens = parsedContent.reasoningItems.reduce((acc, item) => acc + Math.floor((item.text || '').length / 4), 0);
|
|
1981
|
+
const reasoningTokens = usage.reasoningTokens > 0 ? usage.reasoningTokens : estimatedReasoningTokens;
|
|
1982
|
+
const model = claudeResponse.model || claudeResponse?.response?.model || fallbackModel || '';
|
|
1470
1983
|
const responseId = `resp_${String(claudeResponse.id || Date.now()).replace(/[^a-zA-Z0-9_]/g, '')}`;
|
|
1471
1984
|
const messageId = claudeResponse.id || `msg_${Date.now()}`;
|
|
1472
1985
|
const createdAt = Math.floor(Date.now() / 1000);
|
|
@@ -1512,7 +2025,7 @@ function buildOpenAiResponsesObject(claudeResponse = {}, fallbackModel = '') {
|
|
|
1512
2025
|
});
|
|
1513
2026
|
});
|
|
1514
2027
|
|
|
1515
|
-
|
|
2028
|
+
const responseObject = {
|
|
1516
2029
|
id: responseId,
|
|
1517
2030
|
object: 'response',
|
|
1518
2031
|
created_at: createdAt,
|
|
@@ -1523,9 +2036,21 @@ function buildOpenAiResponsesObject(claudeResponse = {}, fallbackModel = '') {
|
|
|
1523
2036
|
input_tokens: inputTokens,
|
|
1524
2037
|
output_tokens: outputTokens,
|
|
1525
2038
|
total_tokens: totalTokens,
|
|
2039
|
+
...(cacheReadTokens > 0 ? { input_tokens_details: { cached_tokens: cacheReadTokens } } : {}),
|
|
1526
2040
|
...(reasoningTokens > 0 ? { output_tokens_details: { reasoning_tokens: reasoningTokens } } : {})
|
|
1527
2041
|
}
|
|
1528
2042
|
};
|
|
2043
|
+
|
|
2044
|
+
if (cacheCreationTokens > 0 || cacheReadTokens > 0) {
|
|
2045
|
+
responseObject.providerMetadata = {
|
|
2046
|
+
anthropic: {
|
|
2047
|
+
...(cacheCreationTokens > 0 ? { cacheCreationInputTokens: cacheCreationTokens } : {}),
|
|
2048
|
+
...(cacheReadTokens > 0 ? { cacheReadInputTokens: cacheReadTokens } : {})
|
|
2049
|
+
}
|
|
2050
|
+
};
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
return responseObject;
|
|
1529
2054
|
}
|
|
1530
2055
|
|
|
1531
2056
|
function buildOpenAiResponsesObjectFromGemini(geminiResponse = {}, fallbackModel = '') {
|
|
@@ -1599,12 +2124,16 @@ function buildOpenAiResponsesObjectFromGemini(geminiResponse = {}, fallbackModel
|
|
|
1599
2124
|
}
|
|
1600
2125
|
|
|
1601
2126
|
function buildOpenAiChatCompletionsObject(claudeResponse = {}, fallbackModel = '') {
|
|
1602
|
-
const
|
|
1603
|
-
const
|
|
1604
|
-
const
|
|
2127
|
+
const usage = extractClaudeLikeUsage(claudeResponse);
|
|
2128
|
+
const inputTokens = usage.inputTokens;
|
|
2129
|
+
const outputTokens = usage.outputTokens;
|
|
2130
|
+
const totalTokens = usage.totalTokens > 0 ? usage.totalTokens : (inputTokens + outputTokens);
|
|
2131
|
+
const cachedTokens = usage.cacheReadTokens;
|
|
1605
2132
|
const parsedContent = extractClaudeResponseContent(claudeResponse);
|
|
2133
|
+
const estimatedReasoningTokens = parsedContent.reasoningItems.reduce((acc, item) => acc + Math.floor((item.text || '').length / 4), 0);
|
|
2134
|
+
const reasoningTokens = usage.reasoningTokens > 0 ? usage.reasoningTokens : estimatedReasoningTokens;
|
|
1606
2135
|
const text = parsedContent.text;
|
|
1607
|
-
const model = claudeResponse.model || fallbackModel || '';
|
|
2136
|
+
const model = claudeResponse.model || claudeResponse?.response?.model || fallbackModel || '';
|
|
1608
2137
|
const chatId = `chatcmpl_${String(claudeResponse.id || Date.now()).replace(/[^a-zA-Z0-9_]/g, '')}`;
|
|
1609
2138
|
const created = Math.floor(Date.now() / 1000);
|
|
1610
2139
|
const hasToolCalls = parsedContent.functionCalls.length > 0;
|
|
@@ -1639,7 +2168,9 @@ function buildOpenAiChatCompletionsObject(claudeResponse = {}, fallbackModel = '
|
|
|
1639
2168
|
usage: {
|
|
1640
2169
|
prompt_tokens: inputTokens,
|
|
1641
2170
|
completion_tokens: outputTokens,
|
|
1642
|
-
total_tokens: totalTokens
|
|
2171
|
+
total_tokens: totalTokens,
|
|
2172
|
+
...(cachedTokens > 0 ? { prompt_tokens_details: { cached_tokens: cachedTokens } } : {}),
|
|
2173
|
+
...(reasoningTokens > 0 ? { completion_tokens_details: { reasoning_tokens: reasoningTokens } } : {})
|
|
1643
2174
|
}
|
|
1644
2175
|
};
|
|
1645
2176
|
}
|
|
@@ -1648,6 +2179,9 @@ function buildOpenAiChatCompletionsObjectFromGemini(geminiResponse = {}, fallbac
|
|
|
1648
2179
|
const usage = extractGeminiUsage(geminiResponse);
|
|
1649
2180
|
const parsedContent = extractGeminiResponseContent(geminiResponse);
|
|
1650
2181
|
const text = parsedContent.text;
|
|
2182
|
+
const reasoningTokens = usage.reasoningTokens > 0
|
|
2183
|
+
? usage.reasoningTokens
|
|
2184
|
+
: parsedContent.reasoningItems.reduce((acc, item) => acc + Math.floor((item.text || '').length / 4), 0);
|
|
1651
2185
|
const model = geminiResponse.modelVersion || fallbackModel || '';
|
|
1652
2186
|
const chatId = `chatcmpl_${Date.now()}`;
|
|
1653
2187
|
const created = Math.floor(Date.now() / 1000);
|
|
@@ -1686,7 +2220,9 @@ function buildOpenAiChatCompletionsObjectFromGemini(geminiResponse = {}, fallbac
|
|
|
1686
2220
|
usage: {
|
|
1687
2221
|
prompt_tokens: usage.inputTokens,
|
|
1688
2222
|
completion_tokens: usage.outputTokens,
|
|
1689
|
-
total_tokens: usage.totalTokens
|
|
2223
|
+
total_tokens: usage.totalTokens,
|
|
2224
|
+
...(usage.cachedTokens > 0 ? { prompt_tokens_details: { cached_tokens: usage.cachedTokens } } : {}),
|
|
2225
|
+
...(reasoningTokens > 0 ? { completion_tokens_details: { reasoning_tokens: reasoningTokens } } : {})
|
|
1690
2226
|
}
|
|
1691
2227
|
};
|
|
1692
2228
|
}
|
|
@@ -1702,11 +2238,31 @@ function sendOpenAiStyleError(res, statusCode, message, type = 'invalid_request_
|
|
|
1702
2238
|
}
|
|
1703
2239
|
|
|
1704
2240
|
function publishOpenCodeUsageLog({ requestId, channel, model, usage, startTime }) {
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
const
|
|
1709
|
-
const
|
|
2241
|
+
// 兼容多种 usage 格式:
|
|
2242
|
+
// - 标准 OpenAI/Anthropic 格式: {input_tokens, output_tokens} 或 {prompt_tokens, completion_tokens}
|
|
2243
|
+
// - 网关内部格式 (relayChatCompletionsStream 等返回): {input, output, cacheCreation, cacheRead}
|
|
2244
|
+
const inputTokens = Number(usage?.input_tokens || usage?.prompt_tokens || usage?.input || 0);
|
|
2245
|
+
const outputTokens = Number(usage?.output_tokens || usage?.completion_tokens || usage?.output || 0);
|
|
2246
|
+
const totalTokens = Number(usage?.total_tokens || usage?.total || (inputTokens + outputTokens));
|
|
2247
|
+
const cacheReadTokens = Number(
|
|
2248
|
+
usage?.input_tokens_details?.cached_tokens
|
|
2249
|
+
|| usage?.prompt_tokens_details?.cached_tokens
|
|
2250
|
+
|| usage?.providerMetadata?.anthropic?.cacheReadInputTokens
|
|
2251
|
+
|| usage?.cacheRead
|
|
2252
|
+
|| 0
|
|
2253
|
+
);
|
|
2254
|
+
const cacheCreationTokens = Number(
|
|
2255
|
+
usage?.providerMetadata?.anthropic?.cacheCreationInputTokens
|
|
2256
|
+
|| usage?.cacheCreation
|
|
2257
|
+
|| 0
|
|
2258
|
+
);
|
|
2259
|
+
const cachedTokens = cacheReadTokens + cacheCreationTokens;
|
|
2260
|
+
const reasoningTokens = Number(
|
|
2261
|
+
usage?.output_tokens_details?.reasoning_tokens
|
|
2262
|
+
|| usage?.completion_tokens_details?.reasoning_tokens
|
|
2263
|
+
|| usage?.reasoning
|
|
2264
|
+
|| 0
|
|
2265
|
+
);
|
|
1710
2266
|
const now = new Date();
|
|
1711
2267
|
const time = now.toLocaleTimeString('zh-CN', {
|
|
1712
2268
|
hour12: false,
|
|
@@ -1718,7 +2274,9 @@ function publishOpenCodeUsageLog({ requestId, channel, model, usage, startTime }
|
|
|
1718
2274
|
const tokens = {
|
|
1719
2275
|
input: inputTokens,
|
|
1720
2276
|
output: outputTokens,
|
|
1721
|
-
total: totalTokens
|
|
2277
|
+
total: totalTokens,
|
|
2278
|
+
cacheRead: cacheReadTokens,
|
|
2279
|
+
cacheCreation: cacheCreationTokens
|
|
1722
2280
|
};
|
|
1723
2281
|
const cost = calculateCost(model || '', tokens);
|
|
1724
2282
|
|
|
@@ -1824,10 +2382,57 @@ function sendResponsesSse(res, responseObject) {
|
|
|
1824
2382
|
res.end();
|
|
1825
2383
|
}
|
|
1826
2384
|
|
|
2385
|
+
function normalizeChatCompletionsDeltaToolCalls(toolCalls = []) {
|
|
2386
|
+
if (!Array.isArray(toolCalls)) return [];
|
|
2387
|
+
|
|
2388
|
+
const normalizeIndex = (value, fallbackIndex) => {
|
|
2389
|
+
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) return value;
|
|
2390
|
+
if (typeof value === 'string') {
|
|
2391
|
+
const trimmed = value.trim();
|
|
2392
|
+
if (/^\d+$/.test(trimmed)) return Number(trimmed);
|
|
2393
|
+
}
|
|
2394
|
+
return fallbackIndex;
|
|
2395
|
+
};
|
|
2396
|
+
|
|
2397
|
+
const normalizedToolCalls = [];
|
|
2398
|
+
let fallbackIndex = 0;
|
|
2399
|
+
|
|
2400
|
+
toolCalls.forEach(toolCall => {
|
|
2401
|
+
if (!toolCall || typeof toolCall !== 'object') return;
|
|
2402
|
+
|
|
2403
|
+
const rawFunction = (toolCall.function && typeof toolCall.function === 'object')
|
|
2404
|
+
? toolCall.function
|
|
2405
|
+
: {};
|
|
2406
|
+
const fallbackName = typeof toolCall.name === 'string' ? toolCall.name : '';
|
|
2407
|
+
const name = typeof rawFunction.name === 'string' ? rawFunction.name : fallbackName;
|
|
2408
|
+
const rawArguments = Object.prototype.hasOwnProperty.call(rawFunction, 'arguments')
|
|
2409
|
+
? rawFunction.arguments
|
|
2410
|
+
: toolCall.arguments;
|
|
2411
|
+
const argumentsString = normalizeFunctionArgumentsString(
|
|
2412
|
+
typeof rawArguments === 'string'
|
|
2413
|
+
? rawArguments
|
|
2414
|
+
: JSON.stringify(rawArguments && typeof rawArguments === 'object' ? rawArguments : {})
|
|
2415
|
+
);
|
|
2416
|
+
|
|
2417
|
+
normalizedToolCalls.push({
|
|
2418
|
+
index: normalizeIndex(toolCall.index, fallbackIndex),
|
|
2419
|
+
id: typeof toolCall.id === 'string' && toolCall.id.trim() ? toolCall.id.trim() : generateToolCallId(),
|
|
2420
|
+
type: 'function',
|
|
2421
|
+
function: {
|
|
2422
|
+
name,
|
|
2423
|
+
arguments: argumentsString
|
|
2424
|
+
}
|
|
2425
|
+
});
|
|
2426
|
+
fallbackIndex += 1;
|
|
2427
|
+
});
|
|
2428
|
+
|
|
2429
|
+
return normalizedToolCalls;
|
|
2430
|
+
}
|
|
2431
|
+
|
|
1827
2432
|
function sendChatCompletionsSse(res, responseObject) {
|
|
1828
2433
|
const message = responseObject?.choices?.[0]?.message || {};
|
|
1829
2434
|
const text = message?.content || '';
|
|
1830
|
-
const toolCalls =
|
|
2435
|
+
const toolCalls = normalizeChatCompletionsDeltaToolCalls(message?.tool_calls);
|
|
1831
2436
|
const finishReason = responseObject?.choices?.[0]?.finish_reason || 'stop';
|
|
1832
2437
|
|
|
1833
2438
|
setSseHeaders(res);
|
|
@@ -1865,6 +2470,21 @@ function sendChatCompletionsSse(res, responseObject) {
|
|
|
1865
2470
|
]
|
|
1866
2471
|
};
|
|
1867
2472
|
writeSseData(res, doneChunk);
|
|
2473
|
+
// Match OpenAI stream_options.include_usage behavior: emit a final usage chunk.
|
|
2474
|
+
writeSseData(res, {
|
|
2475
|
+
id: responseObject.id,
|
|
2476
|
+
object: 'chat.completion.chunk',
|
|
2477
|
+
created: responseObject.created,
|
|
2478
|
+
model: responseObject.model,
|
|
2479
|
+
choices: [],
|
|
2480
|
+
usage: responseObject?.usage && typeof responseObject.usage === 'object'
|
|
2481
|
+
? responseObject.usage
|
|
2482
|
+
: {
|
|
2483
|
+
prompt_tokens: 0,
|
|
2484
|
+
completion_tokens: 0,
|
|
2485
|
+
total_tokens: 0
|
|
2486
|
+
}
|
|
2487
|
+
});
|
|
1868
2488
|
writeSseDone(res);
|
|
1869
2489
|
res.end();
|
|
1870
2490
|
}
|
|
@@ -1882,6 +2502,9 @@ function createClaudeResponsesStreamState(fallbackModel = '') {
|
|
|
1882
2502
|
model: fallbackModel || '',
|
|
1883
2503
|
inputTokens: 0,
|
|
1884
2504
|
outputTokens: 0,
|
|
2505
|
+
cachedTokens: 0,
|
|
2506
|
+
cacheCreationTokens: 0,
|
|
2507
|
+
cacheReadTokens: 0,
|
|
1885
2508
|
usageSeen: false,
|
|
1886
2509
|
blockTypeByIndex: new Map(),
|
|
1887
2510
|
messageIdByIndex: new Map(),
|
|
@@ -2001,15 +2624,30 @@ function buildCompletedResponsesObjectFromStreamState(state) {
|
|
|
2001
2624
|
output
|
|
2002
2625
|
};
|
|
2003
2626
|
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2627
|
+
// 始终输出 usage 字段,确保 OpenCode Context 面板能正确读取 token 数据
|
|
2628
|
+
response.usage = {
|
|
2629
|
+
input_tokens: Number(state.inputTokens || 0),
|
|
2630
|
+
output_tokens: Number(state.outputTokens || 0),
|
|
2631
|
+
total_tokens: totalTokens
|
|
2632
|
+
};
|
|
2633
|
+
if (reasoningTokens > 0) {
|
|
2634
|
+
response.usage.output_tokens_details = { reasoning_tokens: reasoningTokens };
|
|
2635
|
+
}
|
|
2636
|
+
if ((state.cacheReadTokens || 0) > 0) {
|
|
2637
|
+
response.usage.input_tokens_details = { cached_tokens: Number(state.cacheReadTokens || 0) };
|
|
2638
|
+
}
|
|
2639
|
+
// 注入 providerMetadata.anthropic,供 OpenCode Session.getUsage() 读取 cache write/read tokens
|
|
2640
|
+
if ((state.cacheCreationTokens || 0) > 0 || (state.cacheReadTokens || 0) > 0) {
|
|
2641
|
+
response.providerMetadata = {
|
|
2642
|
+
anthropic: {
|
|
2643
|
+
...(Number(state.cacheCreationTokens || 0) > 0
|
|
2644
|
+
? { cacheCreationInputTokens: Number(state.cacheCreationTokens || 0) }
|
|
2645
|
+
: {}),
|
|
2646
|
+
...(Number(state.cacheReadTokens || 0) > 0
|
|
2647
|
+
? { cacheReadInputTokens: Number(state.cacheReadTokens || 0) }
|
|
2648
|
+
: {})
|
|
2649
|
+
}
|
|
2009
2650
|
};
|
|
2010
|
-
if (reasoningTokens > 0) {
|
|
2011
|
-
response.usage.output_tokens_details = { reasoning_tokens: reasoningTokens };
|
|
2012
|
-
}
|
|
2013
2651
|
}
|
|
2014
2652
|
|
|
2015
2653
|
return response;
|
|
@@ -2036,6 +2674,14 @@ function processClaudeResponsesSseEvent(parsed, state, res) {
|
|
|
2036
2674
|
state.outputTokens = Number(message.usage.output_tokens);
|
|
2037
2675
|
state.usageSeen = true;
|
|
2038
2676
|
}
|
|
2677
|
+
const cacheCreation = Number(message.usage.cache_creation_input_tokens || 0);
|
|
2678
|
+
const cacheRead = Number(message.usage.cache_read_input_tokens || 0);
|
|
2679
|
+
if (Number.isFinite(cacheCreation + cacheRead) && (cacheCreation + cacheRead) > 0) {
|
|
2680
|
+
state.cacheCreationTokens = cacheCreation;
|
|
2681
|
+
state.cacheReadTokens = cacheRead;
|
|
2682
|
+
state.cachedTokens = cacheCreation + cacheRead;
|
|
2683
|
+
state.usageSeen = true;
|
|
2684
|
+
}
|
|
2039
2685
|
}
|
|
2040
2686
|
|
|
2041
2687
|
writeSseData(res, {
|
|
@@ -2345,7 +2991,7 @@ function processClaudeResponsesSseEvent(parsed, state, res) {
|
|
|
2345
2991
|
|
|
2346
2992
|
if (type === 'message_delta') {
|
|
2347
2993
|
const usage = parsed.usage && typeof parsed.usage === 'object' ? parsed.usage : {};
|
|
2348
|
-
if (Number.isFinite(Number(usage.input_tokens))) {
|
|
2994
|
+
if (Number.isFinite(Number(usage.input_tokens)) && Number(usage.input_tokens) > 0) {
|
|
2349
2995
|
state.inputTokens = Number(usage.input_tokens);
|
|
2350
2996
|
state.usageSeen = true;
|
|
2351
2997
|
}
|
|
@@ -2353,6 +2999,14 @@ function processClaudeResponsesSseEvent(parsed, state, res) {
|
|
|
2353
2999
|
state.outputTokens = Number(usage.output_tokens);
|
|
2354
3000
|
state.usageSeen = true;
|
|
2355
3001
|
}
|
|
3002
|
+
const cacheCreation = Number(usage.cache_creation_input_tokens || 0);
|
|
3003
|
+
const cacheRead = Number(usage.cache_read_input_tokens || 0);
|
|
3004
|
+
if (Number.isFinite(cacheCreation + cacheRead) && (cacheCreation + cacheRead) > 0) {
|
|
3005
|
+
state.cacheCreationTokens = cacheCreation;
|
|
3006
|
+
state.cacheReadTokens = cacheRead;
|
|
3007
|
+
state.cachedTokens = cacheCreation + cacheRead;
|
|
3008
|
+
state.usageSeen = true;
|
|
3009
|
+
}
|
|
2356
3010
|
return;
|
|
2357
3011
|
}
|
|
2358
3012
|
|
|
@@ -2687,6 +3341,253 @@ async function collectCodexResponsesNonStream(upstreamResponse, originalPayload
|
|
|
2687
3341
|
});
|
|
2688
3342
|
}
|
|
2689
3343
|
|
|
3344
|
+
async function relayChatCompletionsStream(upstreamResponse, res, fallbackModel = '') {
|
|
3345
|
+
setSseHeaders(res);
|
|
3346
|
+
const stream = createDecodedStream(upstreamResponse);
|
|
3347
|
+
|
|
3348
|
+
const chatId = `chatcmpl_${Date.now()}`;
|
|
3349
|
+
const created = Math.floor(Date.now() / 1000);
|
|
3350
|
+
|
|
3351
|
+
// state tracked across SSE events
|
|
3352
|
+
const state = {
|
|
3353
|
+
model: fallbackModel || '',
|
|
3354
|
+
inputTokens: 0,
|
|
3355
|
+
outputTokens: 0,
|
|
3356
|
+
cacheCreationTokens: 0,
|
|
3357
|
+
cacheReadTokens: 0,
|
|
3358
|
+
stopReason: 'stop',
|
|
3359
|
+
// per-block tracking
|
|
3360
|
+
blockTypeByIndex: new Map(),
|
|
3361
|
+
functionCallIdByIndex: new Map(),
|
|
3362
|
+
functionNameByIndex: new Map(),
|
|
3363
|
+
functionArgsByIndex: new Map(),
|
|
3364
|
+
// tool_call index emitted to client (sequential, starting at 0)
|
|
3365
|
+
toolCallClientIndexByBlockIndex: new Map(),
|
|
3366
|
+
nextToolCallClientIndex: 0
|
|
3367
|
+
};
|
|
3368
|
+
|
|
3369
|
+
return new Promise((resolve, reject) => {
|
|
3370
|
+
let buffer = '';
|
|
3371
|
+
let settled = false;
|
|
3372
|
+
|
|
3373
|
+
const safeResolve = (value) => { if (!settled) { settled = true; resolve(value); } };
|
|
3374
|
+
const safeReject = (error) => { if (!settled) { settled = true; reject(error); } };
|
|
3375
|
+
|
|
3376
|
+
// Send the initial role chunk once
|
|
3377
|
+
writeSseData(res, {
|
|
3378
|
+
id: chatId,
|
|
3379
|
+
object: 'chat.completion.chunk',
|
|
3380
|
+
created,
|
|
3381
|
+
model: state.model || fallbackModel,
|
|
3382
|
+
choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }]
|
|
3383
|
+
});
|
|
3384
|
+
|
|
3385
|
+
const processSseBlock = (block) => {
|
|
3386
|
+
if (!block || !block.trim()) return;
|
|
3387
|
+
const dataLines = block
|
|
3388
|
+
.split('\n')
|
|
3389
|
+
.map(line => line.trimEnd())
|
|
3390
|
+
.filter(line => line.trim().startsWith('data:'))
|
|
3391
|
+
.map(line => line.replace(/^data:\s?/, ''));
|
|
3392
|
+
if (dataLines.length === 0) return;
|
|
3393
|
+
const payload = dataLines.join('\n').trim();
|
|
3394
|
+
if (!payload || payload === '[DONE]') return;
|
|
3395
|
+
|
|
3396
|
+
let parsed;
|
|
3397
|
+
try { parsed = JSON.parse(payload); } catch { return; }
|
|
3398
|
+
if (!parsed || typeof parsed !== 'object') return;
|
|
3399
|
+
|
|
3400
|
+
const type = parsed.type;
|
|
3401
|
+
if (!type) return;
|
|
3402
|
+
|
|
3403
|
+
if (type === 'message_start') {
|
|
3404
|
+
const msg = parsed.message && typeof parsed.message === 'object' ? parsed.message : {};
|
|
3405
|
+
if (msg.model) state.model = msg.model;
|
|
3406
|
+
if (msg.usage) {
|
|
3407
|
+
state.inputTokens = Number(msg.usage.input_tokens || 0);
|
|
3408
|
+
state.cacheCreationTokens = Number(msg.usage.cache_creation_input_tokens || 0);
|
|
3409
|
+
state.cacheReadTokens = Number(msg.usage.cache_read_input_tokens || 0);
|
|
3410
|
+
}
|
|
3411
|
+
return;
|
|
3412
|
+
}
|
|
3413
|
+
|
|
3414
|
+
if (type === 'content_block_start') {
|
|
3415
|
+
const blockIndex = Number.isFinite(Number(parsed.index)) ? Number(parsed.index) : 0;
|
|
3416
|
+
const block = parsed.content_block && typeof parsed.content_block === 'object' ? parsed.content_block : {};
|
|
3417
|
+
const blockType = block.type;
|
|
3418
|
+
state.blockTypeByIndex.set(blockIndex, blockType);
|
|
3419
|
+
|
|
3420
|
+
if (blockType === 'tool_use') {
|
|
3421
|
+
const callId = String(block.id || generateToolCallId());
|
|
3422
|
+
const name = block.name || '';
|
|
3423
|
+
state.functionCallIdByIndex.set(blockIndex, callId);
|
|
3424
|
+
state.functionNameByIndex.set(blockIndex, name);
|
|
3425
|
+
state.functionArgsByIndex.set(blockIndex, '');
|
|
3426
|
+
const clientIndex = state.nextToolCallClientIndex++;
|
|
3427
|
+
state.toolCallClientIndexByBlockIndex.set(blockIndex, clientIndex);
|
|
3428
|
+
|
|
3429
|
+
// Emit tool_call start chunk
|
|
3430
|
+
writeSseData(res, {
|
|
3431
|
+
id: chatId,
|
|
3432
|
+
object: 'chat.completion.chunk',
|
|
3433
|
+
created,
|
|
3434
|
+
model: state.model || fallbackModel,
|
|
3435
|
+
choices: [{
|
|
3436
|
+
index: 0,
|
|
3437
|
+
delta: {
|
|
3438
|
+
tool_calls: [{
|
|
3439
|
+
index: clientIndex,
|
|
3440
|
+
id: callId,
|
|
3441
|
+
type: 'function',
|
|
3442
|
+
function: { name, arguments: '' }
|
|
3443
|
+
}]
|
|
3444
|
+
},
|
|
3445
|
+
finish_reason: null
|
|
3446
|
+
}]
|
|
3447
|
+
});
|
|
3448
|
+
}
|
|
3449
|
+
return;
|
|
3450
|
+
}
|
|
3451
|
+
|
|
3452
|
+
if (type === 'content_block_delta') {
|
|
3453
|
+
const blockIndex = Number.isFinite(Number(parsed.index)) ? Number(parsed.index) : 0;
|
|
3454
|
+
const delta = parsed.delta && typeof parsed.delta === 'object' ? parsed.delta : {};
|
|
3455
|
+
const deltaType = delta.type;
|
|
3456
|
+
|
|
3457
|
+
if (deltaType === 'text_delta') {
|
|
3458
|
+
const text = typeof delta.text === 'string' ? delta.text : '';
|
|
3459
|
+
if (!text) return;
|
|
3460
|
+
writeSseData(res, {
|
|
3461
|
+
id: chatId,
|
|
3462
|
+
object: 'chat.completion.chunk',
|
|
3463
|
+
created,
|
|
3464
|
+
model: state.model || fallbackModel,
|
|
3465
|
+
choices: [{ index: 0, delta: { content: text }, finish_reason: null }]
|
|
3466
|
+
});
|
|
3467
|
+
return;
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3470
|
+
if (deltaType === 'input_json_delta') {
|
|
3471
|
+
const partialJson = typeof delta.partial_json === 'string' ? delta.partial_json : '';
|
|
3472
|
+
if (!partialJson) return;
|
|
3473
|
+
const prev = state.functionArgsByIndex.get(blockIndex) || '';
|
|
3474
|
+
state.functionArgsByIndex.set(blockIndex, prev + partialJson);
|
|
3475
|
+
const clientIndex = state.toolCallClientIndexByBlockIndex.get(blockIndex) ?? 0;
|
|
3476
|
+
writeSseData(res, {
|
|
3477
|
+
id: chatId,
|
|
3478
|
+
object: 'chat.completion.chunk',
|
|
3479
|
+
created,
|
|
3480
|
+
model: state.model || fallbackModel,
|
|
3481
|
+
choices: [{
|
|
3482
|
+
index: 0,
|
|
3483
|
+
delta: {
|
|
3484
|
+
tool_calls: [{
|
|
3485
|
+
index: clientIndex,
|
|
3486
|
+
function: { arguments: partialJson }
|
|
3487
|
+
}]
|
|
3488
|
+
},
|
|
3489
|
+
finish_reason: null
|
|
3490
|
+
}]
|
|
3491
|
+
});
|
|
3492
|
+
return;
|
|
3493
|
+
}
|
|
3494
|
+
// thinking_delta: silently skip (no equivalent in chat completions)
|
|
3495
|
+
return;
|
|
3496
|
+
}
|
|
3497
|
+
|
|
3498
|
+
if (type === 'message_delta') {
|
|
3499
|
+
const usage = parsed.usage && typeof parsed.usage === 'object' ? parsed.usage : {};
|
|
3500
|
+
if (Number.isFinite(Number(usage.output_tokens))) {
|
|
3501
|
+
state.outputTokens = Number(usage.output_tokens);
|
|
3502
|
+
}
|
|
3503
|
+
const stopReason = parsed.delta && parsed.delta.stop_reason;
|
|
3504
|
+
if (stopReason) state.stopReason = stopReason;
|
|
3505
|
+
return;
|
|
3506
|
+
}
|
|
3507
|
+
|
|
3508
|
+
if (type === 'message_stop') {
|
|
3509
|
+
const finishReason = mapClaudeStopReasonToChatFinishReason(state.stopReason);
|
|
3510
|
+
const hasToolCalls = state.nextToolCallClientIndex > 0;
|
|
3511
|
+
|
|
3512
|
+
// Final finish chunk
|
|
3513
|
+
writeSseData(res, {
|
|
3514
|
+
id: chatId,
|
|
3515
|
+
object: 'chat.completion.chunk',
|
|
3516
|
+
created,
|
|
3517
|
+
model: state.model || fallbackModel,
|
|
3518
|
+
choices: [{ index: 0, delta: {}, finish_reason: hasToolCalls ? 'tool_calls' : finishReason }]
|
|
3519
|
+
});
|
|
3520
|
+
|
|
3521
|
+
// Usage chunk (stream_options.include_usage)
|
|
3522
|
+
const inputTokens = state.inputTokens;
|
|
3523
|
+
const outputTokens = state.outputTokens;
|
|
3524
|
+
const cachedTokens = state.cacheCreationTokens + state.cacheReadTokens;
|
|
3525
|
+
writeSseData(res, {
|
|
3526
|
+
id: chatId,
|
|
3527
|
+
object: 'chat.completion.chunk',
|
|
3528
|
+
created,
|
|
3529
|
+
model: state.model || fallbackModel,
|
|
3530
|
+
choices: [],
|
|
3531
|
+
usage: {
|
|
3532
|
+
prompt_tokens: inputTokens,
|
|
3533
|
+
completion_tokens: outputTokens,
|
|
3534
|
+
total_tokens: inputTokens + outputTokens,
|
|
3535
|
+
...(cachedTokens > 0 ? { prompt_tokens_details: { cached_tokens: cachedTokens } } : {})
|
|
3536
|
+
}
|
|
3537
|
+
});
|
|
3538
|
+
|
|
3539
|
+
writeSseDone(res);
|
|
3540
|
+
res.end();
|
|
3541
|
+
safeResolve({
|
|
3542
|
+
model: state.model || fallbackModel,
|
|
3543
|
+
usage: {
|
|
3544
|
+
input: inputTokens,
|
|
3545
|
+
output: outputTokens,
|
|
3546
|
+
cacheCreation: state.cacheCreationTokens,
|
|
3547
|
+
cacheRead: state.cacheReadTokens
|
|
3548
|
+
}
|
|
3549
|
+
});
|
|
3550
|
+
}
|
|
3551
|
+
};
|
|
3552
|
+
|
|
3553
|
+
stream.on('data', (chunk) => {
|
|
3554
|
+
buffer += chunk.toString('utf8').replace(/\r\n/g, '\n');
|
|
3555
|
+
let separatorIndex = buffer.indexOf('\n\n');
|
|
3556
|
+
while (separatorIndex >= 0) {
|
|
3557
|
+
const block = buffer.slice(0, separatorIndex);
|
|
3558
|
+
buffer = buffer.slice(separatorIndex + 2);
|
|
3559
|
+
processSseBlock(block);
|
|
3560
|
+
separatorIndex = buffer.indexOf('\n\n');
|
|
3561
|
+
}
|
|
3562
|
+
});
|
|
3563
|
+
|
|
3564
|
+
stream.on('end', () => {
|
|
3565
|
+
if (buffer.trim()) processSseBlock(buffer);
|
|
3566
|
+
if (!res.writableEnded) {
|
|
3567
|
+
writeSseDone(res);
|
|
3568
|
+
res.end();
|
|
3569
|
+
}
|
|
3570
|
+
safeResolve({ model: state.model || fallbackModel, usage: { input: state.inputTokens, output: state.outputTokens, cacheCreation: state.cacheCreationTokens, cacheRead: state.cacheReadTokens } });
|
|
3571
|
+
});
|
|
3572
|
+
|
|
3573
|
+
stream.on('error', (error) => {
|
|
3574
|
+
if (!res.writableEnded) {
|
|
3575
|
+
writeSseDone(res);
|
|
3576
|
+
res.end();
|
|
3577
|
+
}
|
|
3578
|
+
safeReject(error);
|
|
3579
|
+
});
|
|
3580
|
+
|
|
3581
|
+
upstreamResponse.on('error', (error) => {
|
|
3582
|
+
if (!res.writableEnded) {
|
|
3583
|
+
writeSseDone(res);
|
|
3584
|
+
res.end();
|
|
3585
|
+
}
|
|
3586
|
+
safeReject(error);
|
|
3587
|
+
});
|
|
3588
|
+
});
|
|
3589
|
+
}
|
|
3590
|
+
|
|
2690
3591
|
async function handleClaudeGatewayRequest(req, res, channel, effectiveKey) {
|
|
2691
3592
|
const pathname = getRequestPathname(req.url);
|
|
2692
3593
|
if (!isResponsesPath(pathname) && !isChatCompletionsPath(pathname)) {
|
|
@@ -2703,6 +3604,7 @@ async function handleClaudeGatewayRequest(req, res, channel, effectiveKey) {
|
|
|
2703
3604
|
const originalPayload = (req.body && typeof req.body === 'object') ? req.body : {};
|
|
2704
3605
|
const wantsStream = !!originalPayload.stream;
|
|
2705
3606
|
const streamResponses = wantsStream && isResponsesPath(pathname);
|
|
3607
|
+
const streamChatCompletions = wantsStream && isChatCompletionsPath(pathname);
|
|
2706
3608
|
const sessionKey = extractSessionIdFromRequest(req, originalPayload);
|
|
2707
3609
|
const sessionScope = normalizeSessionKeyValue(channel?.id || channel?.name || '');
|
|
2708
3610
|
const scopedSessionKey = sessionKey && sessionScope
|
|
@@ -2713,7 +3615,7 @@ async function handleClaudeGatewayRequest(req, res, channel, effectiveKey) {
|
|
|
2713
3615
|
const claudePayload = convertOpenCodePayloadToClaude(pathname, originalPayload, channel.model, {
|
|
2714
3616
|
sessionUserId
|
|
2715
3617
|
});
|
|
2716
|
-
claudePayload.stream = streamResponses;
|
|
3618
|
+
claudePayload.stream = streamResponses || streamChatCompletions;
|
|
2717
3619
|
|
|
2718
3620
|
const headers = {
|
|
2719
3621
|
'x-api-key': effectiveKey,
|
|
@@ -2732,12 +3634,61 @@ async function handleClaudeGatewayRequest(req, res, channel, effectiveKey) {
|
|
|
2732
3634
|
'x-stainless-os': mapStainlessOs(),
|
|
2733
3635
|
'x-stainless-timeout': '600',
|
|
2734
3636
|
'content-type': 'application/json',
|
|
2735
|
-
'accept': streamResponses ? 'text/event-stream' : 'application/json',
|
|
3637
|
+
'accept': (streamResponses || streamChatCompletions) ? 'text/event-stream' : 'application/json',
|
|
2736
3638
|
'accept-encoding': 'gzip, deflate, br, zstd',
|
|
2737
3639
|
'connection': 'keep-alive',
|
|
2738
3640
|
'user-agent': CLAUDE_CODE_USER_AGENT
|
|
2739
3641
|
};
|
|
2740
3642
|
|
|
3643
|
+
if (streamChatCompletions) {
|
|
3644
|
+
let streamUpstream;
|
|
3645
|
+
try {
|
|
3646
|
+
streamUpstream = await postJsonStream(buildClaudeTargetUrl(channel.baseUrl), headers, claudePayload, 120000);
|
|
3647
|
+
} catch (error) {
|
|
3648
|
+
recordFailure(channel.id, 'opencode', error);
|
|
3649
|
+
sendOpenAiStyleError(res, 502, `Claude gateway network error: ${error.message}`, 'proxy_error');
|
|
3650
|
+
return true;
|
|
3651
|
+
}
|
|
3652
|
+
|
|
3653
|
+
const statusCode = Number(streamUpstream.statusCode) || 500;
|
|
3654
|
+
if (statusCode < 200 || statusCode >= 300) {
|
|
3655
|
+
let rawBody = '';
|
|
3656
|
+
try {
|
|
3657
|
+
rawBody = await collectHttpResponseBody(streamUpstream.response);
|
|
3658
|
+
} catch {
|
|
3659
|
+
rawBody = '';
|
|
3660
|
+
}
|
|
3661
|
+
let parsedError = null;
|
|
3662
|
+
try {
|
|
3663
|
+
parsedError = rawBody ? JSON.parse(rawBody) : null;
|
|
3664
|
+
} catch {
|
|
3665
|
+
parsedError = null;
|
|
3666
|
+
}
|
|
3667
|
+
const upstreamMessage = parsedError?.error?.message || parsedError?.message || rawBody || `HTTP ${statusCode}`;
|
|
3668
|
+
recordFailure(channel.id, 'opencode', new Error(String(upstreamMessage).slice(0, 200)));
|
|
3669
|
+
sendOpenAiStyleError(res, statusCode, String(upstreamMessage).slice(0, 1000), 'upstream_error');
|
|
3670
|
+
return true;
|
|
3671
|
+
}
|
|
3672
|
+
|
|
3673
|
+
try {
|
|
3674
|
+
const streamedResponseObject = await relayChatCompletionsStream(streamUpstream.response, res, originalPayload.model || '');
|
|
3675
|
+
publishOpenCodeUsageLog({
|
|
3676
|
+
requestId,
|
|
3677
|
+
channel,
|
|
3678
|
+
model: streamedResponseObject?.model || originalPayload.model || '',
|
|
3679
|
+
usage: streamedResponseObject?.usage || {},
|
|
3680
|
+
startTime
|
|
3681
|
+
});
|
|
3682
|
+
recordSuccess(channel.id, 'opencode');
|
|
3683
|
+
} catch (error) {
|
|
3684
|
+
recordFailure(channel.id, 'opencode', error);
|
|
3685
|
+
if (!res.headersSent) {
|
|
3686
|
+
sendOpenAiStyleError(res, 502, `Claude stream relay error: ${error.message}`, 'proxy_error');
|
|
3687
|
+
}
|
|
3688
|
+
}
|
|
3689
|
+
return true;
|
|
3690
|
+
}
|
|
3691
|
+
|
|
2741
3692
|
if (streamResponses) {
|
|
2742
3693
|
let streamUpstream;
|
|
2743
3694
|
try {
|
|
@@ -2775,7 +3726,9 @@ async function handleClaudeGatewayRequest(req, res, channel, effectiveKey) {
|
|
|
2775
3726
|
requestId,
|
|
2776
3727
|
channel,
|
|
2777
3728
|
model: streamedResponseObject?.model || originalPayload.model || '',
|
|
2778
|
-
usage: streamedResponseObject?.
|
|
3729
|
+
usage: streamedResponseObject?.providerMetadata
|
|
3730
|
+
? { ...(streamedResponseObject.usage || {}), providerMetadata: streamedResponseObject.providerMetadata }
|
|
3731
|
+
: streamedResponseObject?.usage || {},
|
|
2779
3732
|
startTime
|
|
2780
3733
|
});
|
|
2781
3734
|
recordSuccess(channel.id, 'opencode');
|
|
@@ -2882,10 +3835,11 @@ async function handleCodexGatewayRequest(req, res, channel, effectiveKey) {
|
|
|
2882
3835
|
return true;
|
|
2883
3836
|
}
|
|
2884
3837
|
|
|
2885
|
-
const codexSessionId =
|
|
3838
|
+
const codexSessionId = extractSessionIdFromRequest(req, originalPayload);
|
|
3839
|
+
const stableSessionKey = codexSessionId || `${channel.id || 'ch'}-${channel.baseUrl || ''}`;
|
|
2886
3840
|
const promptCacheKey = (typeof converted.requestBody.prompt_cache_key === 'string' && converted.requestBody.prompt_cache_key.trim())
|
|
2887
3841
|
? converted.requestBody.prompt_cache_key.trim()
|
|
2888
|
-
:
|
|
3842
|
+
: stableSessionKey;
|
|
2889
3843
|
converted.requestBody.prompt_cache_key = promptCacheKey;
|
|
2890
3844
|
|
|
2891
3845
|
const headers = {
|
|
@@ -3814,33 +4768,31 @@ async function collectProxyModelList(channels = [], options = {}) {
|
|
|
3814
4768
|
};
|
|
3815
4769
|
|
|
3816
4770
|
const forceRefresh = options.forceRefresh === true;
|
|
4771
|
+
const useCacheOnly = options.useCacheOnly === true;
|
|
3817
4772
|
// 模型列表聚合改为串行探测,避免并发触发上游会话窗口限流
|
|
3818
4773
|
for (const channel of channels) {
|
|
4774
|
+
if (isConverterEntryChannel(channel)) {
|
|
4775
|
+
const defaults = getDefaultModelsByGatewaySourceType(normalizeGatewaySourceType(channel));
|
|
4776
|
+
defaults.forEach(add);
|
|
4777
|
+
continue;
|
|
4778
|
+
}
|
|
4779
|
+
|
|
4780
|
+
if (useCacheOnly) {
|
|
4781
|
+
const cacheEntry = getCachedModelInfo(channel?.id);
|
|
4782
|
+
const cachedFetched = Array.isArray(cacheEntry?.fetchedModels) ? cacheEntry.fetchedModels : [];
|
|
4783
|
+
const cachedAvailable = Array.isArray(cacheEntry?.availableModels) ? cacheEntry.availableModels : [];
|
|
4784
|
+
cachedFetched.forEach(add);
|
|
4785
|
+
cachedAvailable.forEach(add);
|
|
4786
|
+
continue;
|
|
4787
|
+
}
|
|
4788
|
+
|
|
3819
4789
|
try {
|
|
3820
4790
|
// eslint-disable-next-line no-await-in-loop
|
|
3821
4791
|
const listResult = await fetchModelsFromProvider(channel, 'openai_compatible', { forceRefresh });
|
|
3822
4792
|
const listedModels = Array.isArray(listResult?.models) ? listResult.models : [];
|
|
3823
4793
|
if (listedModels.length > 0) {
|
|
3824
4794
|
listedModels.forEach(add);
|
|
3825
|
-
continue;
|
|
3826
|
-
}
|
|
3827
|
-
|
|
3828
|
-
const shouldProbeByDefault = !!listResult?.disabledByConfig;
|
|
3829
|
-
|
|
3830
|
-
// 默认仅入口转换器渠道执行模型探测;若已禁用 /v1/models 则对全部渠道启用默认探测
|
|
3831
|
-
if (!shouldProbeByDefault && !isConverterPresetChannel(channel)) {
|
|
3832
|
-
continue;
|
|
3833
4795
|
}
|
|
3834
|
-
|
|
3835
|
-
const channelType = normalizeGatewaySourceType(channel);
|
|
3836
|
-
// eslint-disable-next-line no-await-in-loop
|
|
3837
|
-
const probe = await probeModelAvailability(channel, channelType, {
|
|
3838
|
-
forceRefresh,
|
|
3839
|
-
stopOnFirstAvailable: false,
|
|
3840
|
-
preferredModels: collectPreferredProbeModels(channel)
|
|
3841
|
-
});
|
|
3842
|
-
const available = Array.isArray(probe?.availableModels) ? probe.availableModels : [];
|
|
3843
|
-
available.forEach(add);
|
|
3844
4796
|
} catch (err) {
|
|
3845
4797
|
console.warn(`[OpenCode Proxy] Build model list failed for ${channel?.name || channel?.id || 'unknown'}:`, err.message);
|
|
3846
4798
|
}
|
|
@@ -3906,11 +4858,23 @@ async function startOpenCodeProxyServer(options = {}) {
|
|
|
3906
4858
|
if (!proxyReq.getHeader('content-type')) {
|
|
3907
4859
|
proxyReq.setHeader('content-type', 'application/json');
|
|
3908
4860
|
}
|
|
4861
|
+
// 禁止上游返回压缩响应,避免在 proxyRes 监听器中出现双消费者竞争
|
|
4862
|
+
proxyReq.removeHeader('accept-encoding');
|
|
3909
4863
|
|
|
3910
4864
|
if (shouldParseJson(req) && (req.rawBody || req.body)) {
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
4865
|
+
let body = req.body;
|
|
4866
|
+
// 对 Chat Completions 流式请求注入 stream_options.include_usage = true
|
|
4867
|
+
// OpenCode 使用 @ai-sdk/openai-compatible,该 SDK 不一定发送此字段
|
|
4868
|
+
// 缺少此字段时,大多数 OpenAI 兼容端点不会在响应中附带 usage,
|
|
4869
|
+
// 导致 OpenCode Context 面板所有 token 显示为 0
|
|
4870
|
+
if (body && body.stream === true && !body.stream_options?.include_usage) {
|
|
4871
|
+
body = { ...body, stream_options: { ...body.stream_options, include_usage: true } };
|
|
4872
|
+
}
|
|
4873
|
+
const bodyBuffer = body !== req.body
|
|
4874
|
+
? Buffer.from(JSON.stringify(body))
|
|
4875
|
+
: req.rawBody
|
|
4876
|
+
? Buffer.isBuffer(req.rawBody) ? req.rawBody : Buffer.from(req.rawBody)
|
|
4877
|
+
: Buffer.from(JSON.stringify(req.body));
|
|
3914
4878
|
proxyReq.setHeader('Content-Length', bodyBuffer.length);
|
|
3915
4879
|
proxyReq.write(bodyBuffer);
|
|
3916
4880
|
proxyReq.end();
|
|
@@ -4087,18 +5051,23 @@ async function startOpenCodeProxyServer(options = {}) {
|
|
|
4087
5051
|
inputTokens: 0,
|
|
4088
5052
|
outputTokens: 0,
|
|
4089
5053
|
cachedTokens: 0,
|
|
5054
|
+
cacheCreationTokens: 0,
|
|
5055
|
+
cacheReadTokens: 0,
|
|
4090
5056
|
reasoningTokens: 0,
|
|
4091
5057
|
totalTokens: 0,
|
|
4092
|
-
model: ''
|
|
5058
|
+
model: '',
|
|
5059
|
+
_parseErrorLogged: false
|
|
4093
5060
|
};
|
|
4094
5061
|
|
|
4095
|
-
|
|
5062
|
+
const decodedStream = createDecodedStream(proxyRes);
|
|
5063
|
+
|
|
5064
|
+
decodedStream.on('data', (chunk) => {
|
|
4096
5065
|
// 如果响应已关闭,停止处理
|
|
4097
5066
|
if (isResponseClosed) {
|
|
4098
5067
|
return;
|
|
4099
5068
|
}
|
|
4100
5069
|
|
|
4101
|
-
buffer += chunk.toString();
|
|
5070
|
+
buffer += chunk.toString('utf8');
|
|
4102
5071
|
|
|
4103
5072
|
// 检查是否是 SSE 流
|
|
4104
5073
|
if (proxyRes.headers['content-type']?.includes('text/event-stream')) {
|
|
@@ -4106,7 +5075,7 @@ async function startOpenCodeProxyServer(options = {}) {
|
|
|
4106
5075
|
const events = buffer.split('\n\n');
|
|
4107
5076
|
buffer = events.pop() || '';
|
|
4108
5077
|
|
|
4109
|
-
events.forEach((eventText
|
|
5078
|
+
events.forEach((eventText) => {
|
|
4110
5079
|
if (!eventText.trim()) return;
|
|
4111
5080
|
|
|
4112
5081
|
try {
|
|
@@ -4127,7 +5096,6 @@ async function startOpenCodeProxyServer(options = {}) {
|
|
|
4127
5096
|
|
|
4128
5097
|
// OpenAI Responses API: 在 response.completed 事件中获取 usage
|
|
4129
5098
|
if (parsed.type === 'response.completed' && parsed.response) {
|
|
4130
|
-
// 从 response 对象中提取模型和 usage
|
|
4131
5099
|
if (parsed.response.model) {
|
|
4132
5100
|
tokenData.model = parsed.response.model;
|
|
4133
5101
|
}
|
|
@@ -4137,7 +5105,6 @@ async function startOpenCodeProxyServer(options = {}) {
|
|
|
4137
5105
|
tokenData.outputTokens = parsed.response.usage.output_tokens || 0;
|
|
4138
5106
|
tokenData.totalTokens = parsed.response.usage.total_tokens || 0;
|
|
4139
5107
|
|
|
4140
|
-
// 提取详细信息
|
|
4141
5108
|
if (parsed.response.usage.input_tokens_details) {
|
|
4142
5109
|
tokenData.cachedTokens = parsed.response.usage.input_tokens_details.cached_tokens || 0;
|
|
4143
5110
|
}
|
|
@@ -4147,24 +5114,81 @@ async function startOpenCodeProxyServer(options = {}) {
|
|
|
4147
5114
|
}
|
|
4148
5115
|
}
|
|
4149
5116
|
|
|
5117
|
+
// Anthropic SSE: message_start 含初始 usage 和模型
|
|
5118
|
+
if (parsed.type === 'message_start' && parsed.message) {
|
|
5119
|
+
if (parsed.message.model) {
|
|
5120
|
+
tokenData.model = parsed.message.model;
|
|
5121
|
+
}
|
|
5122
|
+
if (parsed.message.usage) {
|
|
5123
|
+
const u = parsed.message.usage;
|
|
5124
|
+
if (Number.isFinite(Number(u.input_tokens))) {
|
|
5125
|
+
tokenData.inputTokens = Number(u.input_tokens);
|
|
5126
|
+
}
|
|
5127
|
+
if (Number.isFinite(Number(u.output_tokens))) {
|
|
5128
|
+
tokenData.outputTokens = Number(u.output_tokens);
|
|
5129
|
+
}
|
|
5130
|
+
const cacheCreation = Number(u.cache_creation_input_tokens || 0);
|
|
5131
|
+
const cacheRead = Number(u.cache_read_input_tokens || 0);
|
|
5132
|
+
if (cacheCreation + cacheRead > 0) {
|
|
5133
|
+
tokenData.cacheCreationTokens = cacheCreation;
|
|
5134
|
+
tokenData.cacheReadTokens = cacheRead;
|
|
5135
|
+
tokenData.cachedTokens = cacheCreation + cacheRead;
|
|
5136
|
+
}
|
|
5137
|
+
}
|
|
5138
|
+
}
|
|
5139
|
+
|
|
5140
|
+
// Anthropic SSE: message_delta 含最终 output_tokens
|
|
5141
|
+
if (parsed.type === 'message_delta' && parsed.usage) {
|
|
5142
|
+
const u = parsed.usage;
|
|
5143
|
+
if (Number.isFinite(Number(u.output_tokens))) {
|
|
5144
|
+
tokenData.outputTokens = Number(u.output_tokens);
|
|
5145
|
+
}
|
|
5146
|
+
const cacheCreation = Number(u.cache_creation_input_tokens || 0);
|
|
5147
|
+
const cacheRead = Number(u.cache_read_input_tokens || 0);
|
|
5148
|
+
if (cacheCreation + cacheRead > 0) {
|
|
5149
|
+
tokenData.cacheCreationTokens = cacheCreation;
|
|
5150
|
+
tokenData.cacheReadTokens = cacheRead;
|
|
5151
|
+
tokenData.cachedTokens = cacheCreation + cacheRead;
|
|
5152
|
+
}
|
|
5153
|
+
}
|
|
5154
|
+
|
|
4150
5155
|
// 兼容其他格式:直接在顶层的 model 和 usage
|
|
4151
5156
|
if (parsed.model && !tokenData.model) {
|
|
4152
5157
|
tokenData.model = parsed.model;
|
|
4153
5158
|
}
|
|
4154
5159
|
|
|
4155
5160
|
if (parsed.usage && tokenData.inputTokens === 0) {
|
|
4156
|
-
// 兼容 Responses API 和 Chat Completions API
|
|
4157
5161
|
tokenData.inputTokens = parsed.usage.input_tokens || parsed.usage.prompt_tokens || 0;
|
|
4158
5162
|
tokenData.outputTokens = parsed.usage.output_tokens || parsed.usage.completion_tokens || 0;
|
|
5163
|
+
const cacheCreation = Number(parsed.usage.cache_creation_input_tokens || 0);
|
|
5164
|
+
const cacheRead = Number(parsed.usage.cache_read_input_tokens || 0);
|
|
5165
|
+
if (cacheCreation + cacheRead > 0) {
|
|
5166
|
+
tokenData.cacheCreationTokens = cacheCreation;
|
|
5167
|
+
tokenData.cacheReadTokens = cacheRead;
|
|
5168
|
+
tokenData.cachedTokens = cacheCreation + cacheRead;
|
|
5169
|
+
}
|
|
5170
|
+
}
|
|
5171
|
+
|
|
5172
|
+
// Gemini SSE: usageMetadata
|
|
5173
|
+
if (parsed.usageMetadata) {
|
|
5174
|
+
const u = parsed.usageMetadata;
|
|
5175
|
+
tokenData.inputTokens = Number(u.promptTokenCount || 0);
|
|
5176
|
+
tokenData.outputTokens = Number(u.candidatesTokenCount || 0);
|
|
5177
|
+
tokenData.cachedTokens = Number(u.cachedContentTokenCount || 0);
|
|
5178
|
+
tokenData.totalTokens = Number(u.totalTokenCount || 0);
|
|
4159
5179
|
}
|
|
4160
5180
|
} catch (err) {
|
|
4161
|
-
|
|
5181
|
+
if (!tokenData._parseErrorLogged) {
|
|
5182
|
+
tokenData._parseErrorLogged = true;
|
|
5183
|
+
const snippet = typeof data === 'string' ? data.slice(0, 100) : '';
|
|
5184
|
+
console.warn(`[OpenCode Passthrough] SSE parse error (channel: ${metadata?.channel}): ${err.message}, data: ${snippet}`);
|
|
5185
|
+
}
|
|
4162
5186
|
}
|
|
4163
5187
|
});
|
|
4164
5188
|
}
|
|
4165
5189
|
});
|
|
4166
5190
|
|
|
4167
|
-
|
|
5191
|
+
decodedStream.on('end', () => {
|
|
4168
5192
|
// 如果不是流式响应,尝试从完整响应中解析
|
|
4169
5193
|
if (!proxyRes.headers['content-type']?.includes('text/event-stream')) {
|
|
4170
5194
|
try {
|
|
@@ -4173,12 +5197,21 @@ async function startOpenCodeProxyServer(options = {}) {
|
|
|
4173
5197
|
tokenData.model = parsed.model;
|
|
4174
5198
|
}
|
|
4175
5199
|
if (parsed.usage) {
|
|
4176
|
-
// 兼容两种格式
|
|
4177
5200
|
tokenData.inputTokens = parsed.usage.input_tokens || parsed.usage.prompt_tokens || 0;
|
|
4178
5201
|
tokenData.outputTokens = parsed.usage.output_tokens || parsed.usage.completion_tokens || 0;
|
|
5202
|
+
const cacheCreation = Number(parsed.usage.cache_creation_input_tokens || 0);
|
|
5203
|
+
const cacheRead = Number(parsed.usage.cache_read_input_tokens || 0);
|
|
5204
|
+
if (cacheCreation + cacheRead > 0) {
|
|
5205
|
+
tokenData.cacheCreationTokens = cacheCreation;
|
|
5206
|
+
tokenData.cacheReadTokens = cacheRead;
|
|
5207
|
+
tokenData.cachedTokens = cacheCreation + cacheRead;
|
|
5208
|
+
}
|
|
4179
5209
|
}
|
|
4180
5210
|
} catch (err) {
|
|
4181
|
-
|
|
5211
|
+
if (!tokenData._parseErrorLogged) {
|
|
5212
|
+
tokenData._parseErrorLogged = true;
|
|
5213
|
+
console.warn(`[OpenCode Passthrough] Non-SSE response parse error (channel: ${metadata?.channel}): ${err.message}`);
|
|
5214
|
+
}
|
|
4182
5215
|
}
|
|
4183
5216
|
}
|
|
4184
5217
|
|
|
@@ -4196,7 +5229,9 @@ async function startOpenCodeProxyServer(options = {}) {
|
|
|
4196
5229
|
const tokens = {
|
|
4197
5230
|
input: tokenData.inputTokens,
|
|
4198
5231
|
output: tokenData.outputTokens,
|
|
4199
|
-
|
|
5232
|
+
cacheCreation: tokenData.cacheCreationTokens,
|
|
5233
|
+
cacheRead: tokenData.cacheReadTokens,
|
|
5234
|
+
total: tokenData.totalTokens || (tokenData.inputTokens + tokenData.outputTokens)
|
|
4200
5235
|
};
|
|
4201
5236
|
const cost = calculateCost(tokenData.model, tokens);
|
|
4202
5237
|
|
|
@@ -4247,7 +5282,7 @@ async function startOpenCodeProxyServer(options = {}) {
|
|
|
4247
5282
|
}
|
|
4248
5283
|
});
|
|
4249
5284
|
|
|
4250
|
-
|
|
5285
|
+
decodedStream.on('error', (err) => {
|
|
4251
5286
|
// 忽略代理响应错误(可能是网络问题)
|
|
4252
5287
|
if (err.code !== 'EPIPE' && err.code !== 'ECONNRESET') {
|
|
4253
5288
|
console.error('Proxy response error:', err);
|