@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/web/assets/Analytics-BIqc8Rin.css +1 -0
  3. package/dist/web/assets/Analytics-D2V09DHH.js +39 -0
  4. package/dist/web/assets/{ConfigTemplates-ZrK_s7ma.js → ConfigTemplates-Bf_11LhH.js} +1 -1
  5. package/dist/web/assets/Home-BRnW4FTS.js +1 -0
  6. package/dist/web/assets/Home-CyCIx4BA.css +1 -0
  7. package/dist/web/assets/{PluginManager-BD7QUZbU.js → PluginManager-B9J32GhW.js} +1 -1
  8. package/dist/web/assets/{ProjectList-DRb1DuHV.js → ProjectList-5a19MWJk.js} +1 -1
  9. package/dist/web/assets/SessionList-CXUr6S7w.css +1 -0
  10. package/dist/web/assets/SessionList-Cxg5bAdT.js +1 -0
  11. package/dist/web/assets/{SkillManager-C1xG5B4Q.js → SkillManager-CVBr0CLi.js} +1 -1
  12. package/dist/web/assets/{Terminal-DksBo_lM.js → Terminal-D2Xe_Q0H.js} +1 -1
  13. package/dist/web/assets/{WorkspaceManager-Burx7XOo.js → WorkspaceManager-C7dwV94C.js} +1 -1
  14. package/dist/web/assets/icons-BxcwoY5F.js +1 -0
  15. package/dist/web/assets/index-BS9RA6SN.js +2 -0
  16. package/dist/web/assets/index-DUNAVDGb.css +1 -0
  17. package/dist/web/assets/naive-ui-BIXcURHZ.js +1 -0
  18. package/dist/web/assets/{vendors-CO3Upi1d.js → vendors-i5CBGnlm.js} +1 -1
  19. package/dist/web/assets/{vue-vendor-DqyWIXEb.js → vue-vendor-PKd8utv_.js} +1 -1
  20. package/dist/web/index.html +6 -6
  21. package/package.json +1 -1
  22. package/src/config/default.js +7 -27
  23. package/src/config/loader.js +6 -3
  24. package/src/config/model-metadata.js +167 -0
  25. package/src/config/model-metadata.json +125 -0
  26. package/src/config/model-pricing.js +23 -93
  27. package/src/server/api/channels.js +16 -39
  28. package/src/server/api/codex-channels.js +15 -43
  29. package/src/server/api/commands.js +0 -77
  30. package/src/server/api/config.js +4 -1
  31. package/src/server/api/gemini-channels.js +16 -40
  32. package/src/server/api/opencode-channels.js +108 -56
  33. package/src/server/api/opencode-proxy.js +42 -33
  34. package/src/server/api/opencode-sessions.js +4 -69
  35. package/src/server/api/sessions.js +11 -68
  36. package/src/server/api/settings.js +138 -0
  37. package/src/server/api/skills.js +0 -44
  38. package/src/server/api/statistics.js +115 -1
  39. package/src/server/codex-proxy-server.js +32 -59
  40. package/src/server/gemini-proxy-server.js +21 -18
  41. package/src/server/index.js +13 -7
  42. package/src/server/opencode-proxy-server.js +1232 -197
  43. package/src/server/proxy-server.js +8 -8
  44. package/src/server/services/codex-sessions.js +105 -6
  45. package/src/server/services/commands-service.js +0 -29
  46. package/src/server/services/config-templates-service.js +38 -28
  47. package/src/server/services/env-checker.js +97 -9
  48. package/src/server/services/env-manager.js +29 -1
  49. package/src/server/services/opencode-channels.js +3 -1
  50. package/src/server/services/opencode-sessions.js +486 -218
  51. package/src/server/services/opencode-settings-manager.js +172 -36
  52. package/src/server/services/plugins-service.js +37 -28
  53. package/src/server/services/pty-manager.js +22 -18
  54. package/src/server/services/response-decoder.js +21 -0
  55. package/src/server/services/skill-service.js +1 -49
  56. package/src/server/services/speed-test.js +40 -3
  57. package/src/server/services/statistics-service.js +238 -1
  58. package/src/server/utils/pricing.js +51 -60
  59. package/src/server/websocket-server.js +24 -5
  60. package/dist/web/assets/Home-B8YfhZ3c.js +0 -1
  61. package/dist/web/assets/Home-Di2qsylF.css +0 -1
  62. package/dist/web/assets/SessionList-BGJWyneI.css +0 -1
  63. package/dist/web/assets/SessionList-lZ0LKzfT.js +0 -1
  64. package/dist/web/assets/icons-kcfLIMBB.js +0 -1
  65. package/dist/web/assets/index-Ufv5rCa5.css +0 -1
  66. package/dist/web/assets/index-lAkrRC3h.js +0 -2
  67. package/dist/web/assets/naive-ui-CSrLusZZ.js +0 -1
  68. package/src/server/api/convert.js +0 -260
  69. 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 { resolvePricing } = require('./utils/pricing');
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 { probeModelAvailability, fetchModelsFromProvider } = require('./services/model-detector');
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
- // Claude 模型使用 config/model-pricing.js 中的集中定价
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 pricing;
163
-
164
- // 首先检查是否是 Claude 模型,使用集中定价
165
- if (model.startsWith('claude-') || model.toLowerCase().includes('claude')) {
166
- pricing = CLAUDE_MODEL_PRICING[model];
167
-
168
- // 如果没有精确匹配,尝试模糊匹配 Claude 模型
169
- if (!pricing) {
170
- const modelLower = model.toLowerCase();
171
- // 查找最接近的 Claude 模型
172
- for (const [key, value] of Object.entries(CLAUDE_MODEL_PRICING)) {
173
- if (key.toLowerCase().includes(modelLower) || modelLower.includes(key.toLowerCase())) {
174
- pricing = value;
175
- break;
176
- }
177
- }
178
- }
179
-
180
- // 如果仍然没有找到,使用默认 Sonnet 定价
181
- if (!pricing) {
182
- pricing = CLAUDE_MODEL_PRICING['claude-sonnet-4-5-20250929'];
183
- }
184
- } else {
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
- (tokens.input || 0) * inputRate / ONE_MILLION +
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 isResponsesPath(pathname) {
373
- return pathname === '/v1/responses' || pathname === '/responses';
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 collectPreferredProbeModels(channel) {
381
- const candidates = [];
382
- if (!channel || typeof channel !== 'object') return candidates;
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 isConverterPresetChannel(channel) {
417
- const presetId = String(channel?.presetId || '').trim().toLowerCase();
418
- return presetId === 'entry_claude' || presetId === 'entry_codex' || presetId === 'entry_gemini';
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 systemParts = [];
766
+ const systemBlocks = [];
601
767
  const messages = [];
602
768
 
603
769
  if (isResponsesPath(pathname) && typeof payload.instructions === 'string' && payload.instructions.trim()) {
604
- systemParts.push(payload.instructions.trim());
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 text = extractText(content);
610
- if (!text) return;
775
+ const contentBlocks = normalizeOpenAiContentToClaudeBlocks(content);
611
776
  if (normalizedRole === 'system') {
612
- systemParts.push(text);
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: [{ type: 'text', text }]
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
- system: systemParts.join('\n\n').trim(),
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.system) {
955
+ if (normalized.systemBlocks && normalized.systemBlocks.length > 0) {
725
956
  // 部分 relay 仅接受 Claude system 的 block 数组格式,不接受纯字符串
726
- converted.system = [
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
- if (functionDeclarations.length === 0) return [];
788
- return [{ functionDeclarations }];
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.system) {
1397
+ if (normalized.systemBlocks && normalized.systemBlocks.length > 0) {
1023
1398
  requestBody.systemInstruction = {
1024
- parts: [{ text: normalized.system }]
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
- if (!Array.isArray(claudeResponse.content)) {
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
- claudeResponse.content.forEach(block => {
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 inputTokens = Number(claudeResponse?.usage?.input_tokens || 0);
1464
- const outputTokens = Number(claudeResponse?.usage?.output_tokens || 0);
1465
- const totalTokens = Number(claudeResponse?.usage?.total_tokens || (inputTokens + outputTokens));
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 reasoningTokens = parsedContent.reasoningItems.reduce((acc, item) => acc + Math.floor((item.text || '').length / 4), 0);
1469
- const model = claudeResponse.model || fallbackModel || '';
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
- return {
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 inputTokens = Number(claudeResponse?.usage?.input_tokens || 0);
1603
- const outputTokens = Number(claudeResponse?.usage?.output_tokens || 0);
1604
- const totalTokens = Number(claudeResponse?.usage?.total_tokens || (inputTokens + outputTokens));
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
- const inputTokens = Number(usage?.input_tokens || usage?.prompt_tokens || 0);
1706
- const outputTokens = Number(usage?.output_tokens || usage?.completion_tokens || 0);
1707
- const totalTokens = Number(usage?.total_tokens || (inputTokens + outputTokens));
1708
- const cachedTokens = Number(usage?.input_tokens_details?.cached_tokens || 0);
1709
- const reasoningTokens = Number(usage?.output_tokens_details?.reasoning_tokens || 0);
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 = Array.isArray(message?.tool_calls) ? message.tool_calls : [];
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
- if (state.usageSeen || totalTokens > 0 || reasoningTokens > 0) {
2005
- response.usage = {
2006
- input_tokens: Number(state.inputTokens || 0),
2007
- output_tokens: Number(state.outputTokens || 0),
2008
- total_tokens: totalTokens
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?.usage || {},
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 = `${Date.now()}-${Math.random().toString(36).slice(2, 15)}`;
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
- : codexSessionId;
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
- const bodyBuffer = req.rawBody
3912
- ? Buffer.isBuffer(req.rawBody) ? req.rawBody : Buffer.from(req.rawBody)
3913
- : Buffer.from(JSON.stringify(req.body));
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
- proxyRes.on('data', (chunk) => {
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, index) => {
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
- proxyRes.on('end', () => {
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
- total: tokenData.inputTokens + tokenData.outputTokens
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
- proxyRes.on('error', (err) => {
5285
+ decodedStream.on('error', (err) => {
4251
5286
  // 忽略代理响应错误(可能是网络问题)
4252
5287
  if (err.code !== 'EPIPE' && err.code !== 'ECONNRESET') {
4253
5288
  console.error('Proxy response error:', err);