aicodeswitch 5.2.13 → 6.0.1

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.
@@ -66,7 +66,14 @@ const compact_1 = require("./conversions/compact");
66
66
  const coding_plan_1 = require("./coding-plan");
67
67
  const coding_plan_headers_1 = require("./coding-plan-headers");
68
68
  const auth_1 = require("./auth");
69
- const SUPPORTED_TARGETS = ['claude-code', 'codex'];
69
+ const SUPPORTED_TARGETS = ['claude-code', 'codex', 'opencode'];
70
+ /**
71
+ * Fallback(回退原始配置)路径的虚拟供应商归属。
72
+ * 该路径转发的请求不属于任何用户配置的供应商/服务,故用一组固定 ID + 名称
73
+ * 把它纳入服务性能统计(service-performance.json),便于在测速面板单独呈现。
74
+ */
75
+ const FALLBACK_VENDOR_ID = 'fallback-vendor';
76
+ const FALLBACK_VENDOR_NAME = '原始配置 / 直连';
70
77
  /** 默认模型列表 */
71
78
  const DEFAULT_MODELS = [
72
79
  { id: 'claude-sonnet-4-20250514', owned_by: 'anthropic' },
@@ -122,6 +129,19 @@ function apiPathToClientFormat(apiPath) {
122
129
  case '/v1/models': return null;
123
130
  }
124
131
  }
132
+ /**
133
+ * 根据客户端工具类型推断其原生 API 格式(Format)。
134
+ * - claude-code → claude(Anthropic Messages)
135
+ * - codex → responses(OpenAI Responses)
136
+ * - opencode → completions(OpenAI Chat Completions,经由 @ai-sdk/openai-compatible)
137
+ */
138
+ function clientFormatForTool(tool) {
139
+ if (tool === 'codex')
140
+ return 'responses';
141
+ if (tool === 'opencode')
142
+ return 'completions';
143
+ return 'claude';
144
+ }
125
145
  class ClaudeCompactResponseSanitizer extends stream_1.Transform {
126
146
  constructor() {
127
147
  super({ objectMode: true });
@@ -275,12 +295,16 @@ class ProxyServer {
275
295
  * 流式:依据 streamTiming 精确计算 TTFT 与生成阶段吞吐;非流式:端到端估算(estimated)。
276
296
  */
277
297
  emitPerformance(params) {
298
+ var _a;
278
299
  const tracker = this.performanceTracker;
279
300
  if (!tracker)
280
301
  return;
281
302
  const { statusCode, startTime, usage, streamTiming, service, vendorId, vendorName, model } = params;
282
303
  const isError = statusCode >= 400;
304
+ const inputTokens = usage === null || usage === void 0 ? void 0 : usage.inputTokens;
283
305
  const outputTokens = usage === null || usage === void 0 ? void 0 : usage.outputTokens;
306
+ const computedTotal = (inputTokens || 0) + (outputTokens || 0);
307
+ const totalTokens = (_a = usage === null || usage === void 0 ? void 0 : usage.totalTokens) !== null && _a !== void 0 ? _a : (computedTotal > 0 ? computedTotal : undefined);
284
308
  const responseMs = Date.now() - startTime;
285
309
  let ttftMs;
286
310
  let tokensPerSecond;
@@ -296,7 +320,11 @@ class ProxyServer {
296
320
  else if (outputTokens && responseMs > 0) {
297
321
  tokensPerSecond = outputTokens / (responseMs / 1000);
298
322
  }
299
- tracker.recordPerformance(vendorId !== null && vendorId !== void 0 ? vendorId : service.vendorId, vendorName, service.id, service.name, model, { ttftMs, tokensPerSecond, outputTokens, timingAccuracy, isError });
323
+ // 解析最终归属:Fallback 临时服务没有真实 vendor,service.vendorId 兜底为虚拟供应商;
324
+ // 此时 vendorName 也为空,补上虚拟供应商名,确保不被 recordPerformance 的三元组校验丢弃。
325
+ const effectiveVendorId = vendorId !== null && vendorId !== void 0 ? vendorId : service.vendorId;
326
+ const effectiveVendorName = vendorName !== null && vendorName !== void 0 ? vendorName : (effectiveVendorId === FALLBACK_VENDOR_ID ? FALLBACK_VENDOR_NAME : undefined);
327
+ tracker.recordPerformance(effectiveVendorId, effectiveVendorName, service.id, service.name, model, { ttftMs, tokensPerSecond, outputTokens, inputTokens, totalTokens, timingAccuracy, isError });
300
328
  }
301
329
  /**
302
330
  * 从请求中提取 API Key(支持三种 Header,按优先级依次尝试)
@@ -364,6 +392,9 @@ class ProxyServer {
364
392
  if (path === '/codex' || path.startsWith('/codex/')) {
365
393
  return 'codex';
366
394
  }
395
+ if (path === '/opencode' || path.startsWith('/opencode/')) {
396
+ return 'opencode';
397
+ }
367
398
  return undefined;
368
399
  }
369
400
  inferToolFromRequest(req) {
@@ -372,6 +403,8 @@ class ProxyServer {
372
403
  return 'claude-code';
373
404
  if (path.startsWith('/codex'))
374
405
  return 'codex';
406
+ if (path.startsWith('/opencode'))
407
+ return 'opencode';
375
408
  return 'claude-code';
376
409
  }
377
410
  buildRelayTags(relayed, useOriginalConfig = false) {
@@ -732,7 +765,7 @@ class ProxyServer {
732
765
  tags: this.buildRelayTags(hasRelayAttempt),
733
766
  });
734
767
  // 确定目标类型
735
- const targetType = req.path.startsWith('/claude-code/') ? 'claude-code' : 'codex';
768
+ const targetType = this.inferToolFromRequest(req);
736
769
  // 记录错误日志 - 包含请求详情和最后失败的服务信息
737
770
  const _lastFailedVendor = lastFailedService ? this.dbManager.getVendorByServiceId(lastFailedService.id) : undefined;
738
771
  yield this.dbManager.addErrorLog({
@@ -798,7 +831,7 @@ class ProxyServer {
798
831
  }
799
832
  // Add error log - 包含请求详情
800
833
  if (!accessKeyCtx) {
801
- const targetType = req.path.startsWith('/claude-code/') ? 'claude-code' : 'codex';
834
+ const targetType = this.inferToolFromRequest(req);
802
835
  yield this.dbManager.addErrorLog({
803
836
  timestamp: Date.now(),
804
837
  method: req.method,
@@ -840,6 +873,8 @@ class ProxyServer {
840
873
  this.app.use('/claude-code', this.createFixedRouteHandler('claude-code'));
841
874
  this.app.use('/codex/', this.createFixedRouteHandler('codex'));
842
875
  this.app.use('/codex', this.createFixedRouteHandler('codex'));
876
+ this.app.use('/opencode/', this.createFixedRouteHandler('opencode'));
877
+ this.app.use('/opencode', this.createFixedRouteHandler('opencode'));
843
878
  }
844
879
  createFixedRouteHandler(targetType) {
845
880
  return (req, res) => __awaiter(this, void 0, void 0, function* () {
@@ -1169,6 +1204,9 @@ class ProxyServer {
1169
1204
  else if (req.path.startsWith('/codex/')) {
1170
1205
  tool = 'codex';
1171
1206
  }
1207
+ else if (req.path.startsWith('/opencode/')) {
1208
+ tool = 'opencode';
1209
+ }
1172
1210
  if (!tool)
1173
1211
  return undefined;
1174
1212
  // 优先检查会话级路由绑定
@@ -1209,6 +1247,9 @@ class ProxyServer {
1209
1247
  else if (req.path.startsWith('/codex/')) {
1210
1248
  targetType = 'codex';
1211
1249
  }
1250
+ else if (req.path.startsWith('/opencode/')) {
1251
+ targetType = 'opencode';
1252
+ }
1212
1253
  if (!targetType) {
1213
1254
  return false;
1214
1255
  }
@@ -1260,10 +1301,11 @@ class ProxyServer {
1260
1301
  const tempService = {
1261
1302
  id: 'fallback-service',
1262
1303
  name: 'Original Config',
1304
+ vendorId: FALLBACK_VENDOR_ID,
1263
1305
  apiUrl: originalConfig.apiUrl,
1264
1306
  apiKey: originalConfig.apiKey,
1265
1307
  authType: originalConfig.authType,
1266
- sourceType: originalConfig.sourceType || (targetType === 'claude-code' ? 'claude' : 'openai'),
1308
+ sourceType: originalConfig.sourceType || (targetType === 'claude-code' ? 'claude' : targetType === 'opencode' ? 'openai-chat' : 'openai'),
1267
1309
  createdAt: Date.now(),
1268
1310
  updatedAt: Date.now(),
1269
1311
  };
@@ -3094,6 +3136,16 @@ class ProxyServer {
3094
3136
  return sessionId[0] || null;
3095
3137
  }
3096
3138
  }
3139
+ else if (type === 'opencode') {
3140
+ // OpenCode 经 @ai-sdk/openai-compatible 发送 chat completions,尝试从 headers 提取 session 标识
3141
+ const sessionId = request.headers['session-id'] || request.headers['session_id'] || request.headers['x-session-id'];
3142
+ if (typeof sessionId === 'string') {
3143
+ return sessionId;
3144
+ }
3145
+ if (Array.isArray(sessionId)) {
3146
+ return sessionId[0] || null;
3147
+ }
3148
+ }
3097
3149
  return null;
3098
3150
  }
3099
3151
  /**
@@ -3283,6 +3335,14 @@ class ProxyServer {
3283
3335
  if (tool === 'codex' && this.isClaudeSource(source)) {
3284
3336
  return `${apiUrl}/v1/messages`;
3285
3337
  }
3338
+ // opencode 请求 claude 类型接口,使用 claude messages 接口处理
3339
+ if (tool === 'opencode' && this.isClaudeSource(source)) {
3340
+ return `${apiUrl}/v1/messages`;
3341
+ }
3342
+ // opencode 请求 openai(responses) 类型接口,使用 responses 接口处理
3343
+ if (tool === 'opencode' && this.isOpenAISource(source)) {
3344
+ return `${apiUrl}/v1/responses`;
3345
+ }
3286
3346
  // 透传路径
3287
3347
  return `${apiUrl}${originalPath}`;
3288
3348
  }
@@ -3296,7 +3356,7 @@ class ProxyServer {
3296
3356
  * @returns 转换后往服务商API接口的数据
3297
3357
  */
3298
3358
  transformRequestToUpstream(tool, source, payloadData, targetModel, providerConfig, serverToolConfig, sanitizeBody) {
3299
- const clientFormat = tool === 'codex' ? 'responses' : 'claude';
3359
+ const clientFormat = clientFormatForTool(tool);
3300
3360
  const upstreamFormat = (0, source_type_mapping_1.sourceTypeToFormat)(source);
3301
3361
  const result = (0, index_1.transformRequest)({ fromFormat: clientFormat, toFormat: upstreamFormat, body: payloadData, providerConfig, serverToolConfig, sanitizeBody });
3302
3362
  const body = result.body;
@@ -3316,7 +3376,7 @@ class ProxyServer {
3316
3376
  * @param responseData
3317
3377
  */
3318
3378
  transformResponseToTool(tool, source, responseData) {
3319
- const clientFormat = tool === 'codex' ? 'responses' : 'claude';
3379
+ const clientFormat = clientFormatForTool(tool);
3320
3380
  const upstreamFormat = (0, source_type_mapping_1.sourceTypeToFormat)(source);
3321
3381
  return (0, index_1.transformResponse)({ fromFormat: upstreamFormat, toFormat: clientFormat, response: responseData });
3322
3382
  }
@@ -3327,7 +3387,7 @@ class ProxyServer {
3327
3387
  * @returns 转换器实例和相关信息
3328
3388
  */
3329
3389
  transformSSEToTool(targetType, sourceType) {
3330
- const clientFormat = targetType === 'codex' ? 'responses' : 'claude';
3390
+ const clientFormat = clientFormatForTool(targetType);
3331
3391
  const upstreamFormat = (0, source_type_mapping_1.sourceTypeToFormat)(sourceType);
3332
3392
  if (upstreamFormat === clientFormat) {
3333
3393
  return { converter: null };
@@ -3347,8 +3407,28 @@ class ProxyServer {
3347
3407
  });
3348
3408
  return { converter: adapter, extractUsage };
3349
3409
  }
3410
+ /**
3411
+ * 将 SSEEventCollector 归一化后的 usage({input_tokens, output_tokens, total_tokens, cache_read_input_tokens})
3412
+ * 映射为 TokenUsage。collector 已遍历全部事件并合并 Anthropic message_start/message_delta + OpenAI/Gemini 字段,
3413
+ * 因此这里无需再区分上游格式。
3414
+ */
3415
+ tokenUsageFromCollected(extracted) {
3416
+ var _a;
3417
+ if (!extracted)
3418
+ return undefined;
3419
+ const inputTokens = extracted.input_tokens || 0;
3420
+ const outputTokens = extracted.output_tokens || 0;
3421
+ const computedTotal = inputTokens + outputTokens;
3422
+ const totalTokens = (_a = extracted.total_tokens) !== null && _a !== void 0 ? _a : (computedTotal > 0 ? computedTotal : undefined);
3423
+ return {
3424
+ inputTokens,
3425
+ outputTokens,
3426
+ totalTokens,
3427
+ cacheReadInputTokens: extracted.cache_read_input_tokens || 0,
3428
+ };
3429
+ }
3350
3430
  extractTokenUsageFromResponse(responseData, sourceType) {
3351
- var _a, _b, _c, _d, _e, _f;
3431
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
3352
3432
  if (!responseData)
3353
3433
  return undefined;
3354
3434
  const format = (0, source_type_mapping_1.sourceTypeToFormat)(sourceType);
@@ -3364,14 +3444,15 @@ class ProxyServer {
3364
3444
  };
3365
3445
  }
3366
3446
  if (format === 'completions' || format === 'responses') {
3367
- const usage = responseData === null || responseData === void 0 ? void 0 : responseData.usage;
3447
+ // 标准 Responses 非流式 usage 在顶层;个别上游/中转会嵌在 response.usage 下,做兜底
3448
+ const usage = (_a = responseData === null || responseData === void 0 ? void 0 : responseData.usage) !== null && _a !== void 0 ? _a : (_b = responseData === null || responseData === void 0 ? void 0 : responseData.response) === null || _b === void 0 ? void 0 : _b.usage;
3368
3449
  if (!usage)
3369
3450
  return undefined;
3370
3451
  return {
3371
3452
  inputTokens: (usage === null || usage === void 0 ? void 0 : usage.input_tokens) || (usage === null || usage === void 0 ? void 0 : usage.prompt_tokens) || 0,
3372
3453
  outputTokens: (usage === null || usage === void 0 ? void 0 : usage.output_tokens) || (usage === null || usage === void 0 ? void 0 : usage.completion_tokens) || 0,
3373
3454
  totalTokens: (usage === null || usage === void 0 ? void 0 : usage.total_tokens) || 0,
3374
- cacheReadInputTokens: (usage === null || usage === void 0 ? void 0 : usage.cached_tokens) || (usage === null || usage === void 0 ? void 0 : usage.cache_read_input_tokens) || 0,
3455
+ cacheReadInputTokens: (usage === null || usage === void 0 ? void 0 : usage.cached_tokens) || (usage === null || usage === void 0 ? void 0 : usage.cache_read_input_tokens) || ((_c = usage === null || usage === void 0 ? void 0 : usage.input_tokens_details) === null || _c === void 0 ? void 0 : _c.cached_tokens) || 0,
3375
3456
  };
3376
3457
  }
3377
3458
  if (format === 'claude') {
@@ -3379,7 +3460,7 @@ class ProxyServer {
3379
3460
  return {
3380
3461
  inputTokens: (responseData === null || responseData === void 0 ? void 0 : responseData.input_tokens) || 0,
3381
3462
  outputTokens: (responseData === null || responseData === void 0 ? void 0 : responseData.output_tokens) || 0,
3382
- totalTokens: ((_a = responseData === null || responseData === void 0 ? void 0 : responseData.input_tokens) !== null && _a !== void 0 ? _a : 0) + ((_b = responseData === null || responseData === void 0 ? void 0 : responseData.output_tokens) !== null && _b !== void 0 ? _b : 0) || undefined,
3463
+ totalTokens: ((_d = responseData === null || responseData === void 0 ? void 0 : responseData.input_tokens) !== null && _d !== void 0 ? _d : 0) + ((_e = responseData === null || responseData === void 0 ? void 0 : responseData.output_tokens) !== null && _e !== void 0 ? _e : 0) || undefined,
3383
3464
  cacheReadInputTokens: (responseData === null || responseData === void 0 ? void 0 : responseData.cache_read_input_tokens) || 0,
3384
3465
  };
3385
3466
  }
@@ -3389,7 +3470,7 @@ class ProxyServer {
3389
3470
  return {
3390
3471
  inputTokens: (usage === null || usage === void 0 ? void 0 : usage.input_tokens) || 0,
3391
3472
  outputTokens: (usage === null || usage === void 0 ? void 0 : usage.output_tokens) || 0,
3392
- totalTokens: ((_c = usage === null || usage === void 0 ? void 0 : usage.input_tokens) !== null && _c !== void 0 ? _c : 0) + ((_d = usage === null || usage === void 0 ? void 0 : usage.output_tokens) !== null && _d !== void 0 ? _d : 0) || undefined,
3473
+ totalTokens: ((_f = usage === null || usage === void 0 ? void 0 : usage.input_tokens) !== null && _f !== void 0 ? _f : 0) + ((_g = usage === null || usage === void 0 ? void 0 : usage.output_tokens) !== null && _g !== void 0 ? _g : 0) || undefined,
3393
3474
  cacheReadInputTokens: (usage === null || usage === void 0 ? void 0 : usage.cache_read_input_tokens) || 0,
3394
3475
  };
3395
3476
  }
@@ -3409,7 +3490,7 @@ class ProxyServer {
3409
3490
  return {
3410
3491
  inputTokens: (usage === null || usage === void 0 ? void 0 : usage.input_tokens) || 0,
3411
3492
  outputTokens: (usage === null || usage === void 0 ? void 0 : usage.output_tokens) || 0,
3412
- totalTokens: ((_e = usage === null || usage === void 0 ? void 0 : usage.input_tokens) !== null && _e !== void 0 ? _e : 0) + ((_f = usage === null || usage === void 0 ? void 0 : usage.output_tokens) !== null && _f !== void 0 ? _f : 0) || undefined,
3493
+ totalTokens: ((_h = usage === null || usage === void 0 ? void 0 : usage.input_tokens) !== null && _h !== void 0 ? _h : 0) + ((_j = usage === null || usage === void 0 ? void 0 : usage.output_tokens) !== null && _j !== void 0 ? _j : 0) || undefined,
3413
3494
  cacheReadInputTokens: (usage === null || usage === void 0 ? void 0 : usage.cache_read_input_tokens) || 0,
3414
3495
  };
3415
3496
  }
@@ -3427,10 +3508,37 @@ class ProxyServer {
3427
3508
  const sessionId = this.defaultExtractSessionId(req, targetType) || '-';
3428
3509
  // Agent Map:在途请求注册(active 状态判定依据)
3429
3510
  const _accessKeyCtxAtStart = req._accessKeyCtx;
3511
+ // 后台类请求(count_tokens / compact / background 规则)不重置「本轮通知标记」,避免主任务结束后的
3512
+ // 续发请求重复触发结束通知
3513
+ const _isBgRequest = rule.contentType === 'background'
3514
+ || rule.contentType === 'compact'
3515
+ || this.isCountTokensPath(req.path)
3516
+ || this.isCountTokensPath(req.originalUrl);
3517
+ // 思考类请求:复用路由同款识别(rule.contentType==='thinking' 或请求体带 thinking/reasoning 信号)。
3518
+ // 思考期间用更宽的静默上限,避免思考静默被误判为「上游停滞」而弹通知。
3519
+ const _isThinking = rule.contentType === 'thinking' || this.hasThinkingSignal(req.body);
3520
+ res.locals._agentMapThinking = _isThinking;
3430
3521
  agent_map_1.agentMapService.startRequest(sessionId, targetType, {
3431
3522
  source: _accessKeyCtxAtStart ? 'access-key' : 'global',
3432
3523
  keyId: (_a = _accessKeyCtxAtStart === null || _accessKeyCtxAtStart === void 0 ? void 0 : _accessKeyCtxAtStart.accessKey) === null || _a === void 0 ? void 0 : _a.id,
3433
3524
  keyName: (_b = _accessKeyCtxAtStart === null || _accessKeyCtxAtStart === void 0 ? void 0 : _accessKeyCtxAtStart.accessKey) === null || _b === void 0 ? void 0 : _b.name,
3525
+ background: _isBgRequest,
3526
+ thinking: _isThinking,
3527
+ });
3528
+ // 安全网:无论走哪条分支(含编程套餐拒绝、配额拦截、异常等早退,导致 finalizeLog 未被调用),
3529
+ // 都在响应关闭时确保 endRequest 配对执行一次,杜绝在途计数泄漏(「永远卡在进行中」)。
3530
+ res.on('close', () => {
3531
+ // 仅保证 endRequest 配对执行一次(防在途计数泄漏)。用独立标记 _agentMapEnded,
3532
+ // 不再与 finalizeLog 的 onFinalized 共用标记 —— 否则 res 'close' 先于 finalizeLog 触发时
3533
+ // 会把标记置位,导致 finalizeLog 跳过 onFinalized(requestCount / model / token 永不更新)。
3534
+ if (res.locals._agentMapEnded)
3535
+ return;
3536
+ res.locals._agentMapEnded = true;
3537
+ agent_map_1.agentMapService.endRequest(sessionId, {
3538
+ isStream: !!res.locals._agentMapStream,
3539
+ thinking: !!res.locals._agentMapThinking,
3540
+ });
3541
+ agent_map_1.agentMapService.reevaluate(sessionId);
3434
3542
  });
3435
3543
  const vendor = this.dbManager.getVendorByServiceId(service.id);
3436
3544
  console.log(`\x1b[32m[Request Start]\x1b[0m client=${targetType}, session=${sessionId}, rule=${rule.id}(${rule.contentType}), vendor=${(vendor === null || vendor === void 0 ? void 0 : vendor.name) || '-'}, service=${service.name}, model=${rule.targetModel || ((_c = req.body) === null || _c === void 0 ? void 0 : _c.model) || '-'}`);
@@ -3450,7 +3558,7 @@ class ProxyServer {
3450
3558
  let logged = false;
3451
3559
  const extraTagsForLog = [];
3452
3560
  // 编程套餐限制检查
3453
- const clientFormat = targetType === 'codex' ? 'responses' : 'claude';
3561
+ const clientFormat = clientFormatForTool(targetType);
3454
3562
  if (!this.checkCodingPlan(req, res, service, clientFormat))
3455
3563
  return;
3456
3564
  // Compact 请求消息清理:确保 tool_use/tool_result 配对完整
@@ -3611,10 +3719,19 @@ class ProxyServer {
3611
3719
  service, vendorId: vendor === null || vendor === void 0 ? void 0 : vendor.id, vendorName: vendor === null || vendor === void 0 ? void 0 : vendor.name,
3612
3720
  model: (requestBody === null || requestBody === void 0 ? void 0 : requestBody.model) || ((_a = req.body) === null || _a === void 0 ? void 0 : _a.model),
3613
3721
  });
3614
- // Agent Map:在途请求注销 + 活动/状态采集广播(独立于 enableLogging,仅执行一次)
3615
- if (!res.locals._agentMapRecorded) {
3616
- res.locals._agentMapRecorded = true;
3617
- agent_map_1.agentMapService.endRequest(sessionId);
3722
+ // Agent Map:在途请求注销 + 活动/状态采集广播(独立于 enableLogging
3723
+ // 注意:endRequest 与 onFinalized 用各自独立的标记,二者解耦。
3724
+ // 这样即便 res 'close' 安全网已先执行 endRequest(_agentMapEnded=true),onFinalized 仍会在此执行 ——
3725
+ // 修复「requestCount / model / token 不更新」:旧实现二者共用 _agentMapRecorded,'close' 抢先置位会让此处整块跳过。
3726
+ if (!res.locals._agentMapEnded) {
3727
+ res.locals._agentMapEnded = true;
3728
+ agent_map_1.agentMapService.endRequest(sessionId, {
3729
+ isStream: !!res.locals._agentMapStream,
3730
+ thinking: !!res.locals._agentMapThinking,
3731
+ });
3732
+ }
3733
+ if (!res.locals._agentMapFinalized) {
3734
+ res.locals._agentMapFinalized = true;
3618
3735
  const _akCtx = req._accessKeyCtx;
3619
3736
  const _tokensDelta = (usageForLog === null || usageForLog === void 0 ? void 0 : usageForLog.totalTokens) ||
3620
3737
  (((usageForLog === null || usageForLog === void 0 ? void 0 : usageForLog.inputTokens) || 0) + ((usageForLog === null || usageForLog === void 0 ? void 0 : usageForLog.outputTokens) || 0));
@@ -3631,6 +3748,9 @@ class ProxyServer {
3631
3748
  // 避免规则未配置 targetModel 时回退成编程工具提交的 req.body.model
3632
3749
  model: (requestBody === null || requestBody === void 0 ? void 0 : requestBody.model) || ((_d = req.body) === null || _d === void 0 ? void 0 : _d.model),
3633
3750
  tokensDelta: _tokensDelta,
3751
+ // 输入/输出拆分(3D 连线两段用);usageForLog 已规范化为驼峰字段
3752
+ inputTokensDelta: (usageForLog === null || usageForLog === void 0 ? void 0 : usageForLog.inputTokens) || 0,
3753
+ outputTokensDelta: (usageForLog === null || usageForLog === void 0 ? void 0 : usageForLog.outputTokens) || 0,
3634
3754
  body: req.body,
3635
3755
  downstreamResponseBody: downstreamResponseBodyForLog !== null && downstreamResponseBodyForLog !== void 0 ? downstreamResponseBodyForLog : responseBodyForLog,
3636
3756
  responseBody: responseBodyForLog,
@@ -3806,6 +3926,9 @@ class ProxyServer {
3806
3926
  serviceName: service.name,
3807
3927
  model: (requestBody === null || requestBody === void 0 ? void 0 : requestBody.model) || ((_o = req.body) === null || _o === void 0 ? void 0 : _o.model),
3808
3928
  totalTokens,
3929
+ // 输入/输出拆分持久化(与 totalTokens 同口径),供 Agent Map 重启后恢复
3930
+ inputTokens: (usageForLog === null || usageForLog === void 0 ? void 0 : usageForLog.inputTokens) || 0,
3931
+ outputTokens: (usageForLog === null || usageForLog === void 0 ? void 0 : usageForLog.outputTokens) || 0,
3809
3932
  highIqMode: rule.contentType === 'high-iq' ? true : existingSession === null || existingSession === void 0 ? void 0 : existingSession.highIqMode,
3810
3933
  highIqRuleId: rule.contentType === 'high-iq' ? rule.id : existingSession === null || existingSession === void 0 ? void 0 : existingSession.highIqRuleId,
3811
3934
  highIqEnabledAt: rule.contentType === 'high-iq'
@@ -3998,6 +4121,9 @@ class ProxyServer {
3998
4121
  else if (targetType === 'codex' && req.path.startsWith('/codex')) {
3999
4122
  pathToRequest = req.path.slice('/codex'.length);
4000
4123
  }
4124
+ else if (targetType === 'opencode' && req.path.startsWith('/opencode')) {
4125
+ pathToRequest = req.path.slice('/opencode'.length);
4126
+ }
4001
4127
  // 使用 mapRequestPathToUpstreamUrl 统一构建上游 URL
4002
4128
  const model = rule.targetModel || (requestBody === null || requestBody === void 0 ? void 0 : requestBody.model);
4003
4129
  const apiUrl = this.resolveEffectiveApiUrl(service);
@@ -4149,9 +4275,19 @@ class ProxyServer {
4149
4275
  const serializer = new streaming_1.SSESerializerTransform();
4150
4276
  const downstreamChunkCollector = new chunk_collector_1.ChunkCollectorTransform(() => {
4151
4277
  rules_status_service_1.rulesStatusBroadcaster.refreshRuleInUse(route.id, rule.id);
4278
+ // Agent Map 心跳:每个流经的下游 chunk 都视为「会话仍活跃」,刷新活动时钟(节流由 ChunkCollector 内部 5s 上限控制)
4279
+ // 同时把当前流式请求的实时累计 usage(input/output)一并推送,使前端节点在流式过程中随 token 增长实时上移
4280
+ const u = eventCollector.extractUsage();
4281
+ agent_map_1.agentMapService.heartbeat(sessionId, u ? {
4282
+ inputTokens: u.input_tokens || 0,
4283
+ outputTokens: u.output_tokens || 0,
4284
+ } : undefined);
4152
4285
  });
4153
4286
  // 服务性能打点:记录首/末 SSE 事件时间,用于 TTFT 与生成阶段吞吐
4154
4287
  streamTiming = new stream_timing_transform_1.StreamTimingTransform(startTime);
4288
+ // Agent Map:SSE 流已建立 —— 记一个在途流,状态机据此区分「同步/流式」活跃判定
4289
+ res.locals._agentMapStream = true;
4290
+ agent_map_1.agentMapService.markStreaming(sessionId);
4155
4291
  const compactResponseSanitizer = rule.contentType === 'compact' && targetType === 'claude-code'
4156
4292
  ? new ClaudeCompactResponseSanitizer()
4157
4293
  : null;
@@ -4160,7 +4296,7 @@ class ProxyServer {
4160
4296
  const modelRewriter = originalModel ? new model_rewrite_transform_1.ModelRewriteTransform(originalModel) : null;
4161
4297
  responseHeadersForLog = this.normalizeResponseHeaders(responseHeaders);
4162
4298
  // 使用 transformSSEToTool 方法选择转换器
4163
- const { converter, extractUsage } = this.transformSSEToTool(targetType, sourceType);
4299
+ const { converter } = this.transformSSEToTool(targetType, sourceType);
4164
4300
  this.copyResponseHeaders(responseHeaders, res);
4165
4301
  // 收集日志:responseBody/streamChunks 记录上游原始响应;downstreamResponseBody 记录实际下发内容
4166
4302
  const finalizeChunks = () => {
@@ -4177,13 +4313,7 @@ class ProxyServer {
4177
4313
  extractedUsage = converterUsage || extractedUsage;
4178
4314
  }
4179
4315
  }
4180
- // 如果有自定义的 extractUsage 函数,使用它
4181
- if (extractUsage && extractedUsage) {
4182
- usageForLog = extractUsage(extractedUsage);
4183
- }
4184
- else if (extractedUsage) {
4185
- usageForLog = this.extractTokenUsageFromResponse(extractedUsage, sourceType);
4186
- }
4316
+ usageForLog = this.tokenUsageFromCollected(extractedUsage);
4187
4317
  };
4188
4318
  // 监听 res 的错误事件
4189
4319
  res.on('error', (err) => {
@@ -4984,7 +5114,7 @@ class ProxyServer {
4984
5114
  // 流式 model 回写:将上游返回的 model 改写为客户端请求时的原始模型名
4985
5115
  const originalModel = (_d = req.body) === null || _d === void 0 ? void 0 : _d.model;
4986
5116
  const modelRewriter = originalModel ? new model_rewrite_transform_1.ModelRewriteTransform(originalModel) : null;
4987
- const { converter, extractUsage } = this.transformSSEByFormat(clientFormat, sourceType);
5117
+ const { converter } = this.transformSSEByFormat(clientFormat, sourceType);
4988
5118
  this.copyResponseHeaders(responseHeaders, res);
4989
5119
  res.status(response.status);
4990
5120
  const finalizeStreamChunks = () => {
@@ -4997,12 +5127,7 @@ class ProxyServer {
4997
5127
  if (converterUsage)
4998
5128
  extractedUsage = converterUsage;
4999
5129
  }
5000
- if (extractUsage && extractedUsage) {
5001
- usageForLog = extractUsage(extractedUsage);
5002
- }
5003
- else if (extractedUsage) {
5004
- usageForLog = this.extractTokenUsageFromResponse(extractedUsage, sourceType);
5005
- }
5130
+ usageForLog = this.tokenUsageFromCollected(extractedUsage);
5006
5131
  };
5007
5132
  try {
5008
5133
  yield new Promise((resolve, reject) => {
@@ -276,6 +276,7 @@ function generateMigrationPrompt(content, targetTool) {
276
276
  const toolLabels = {
277
277
  'claude-code': 'Claude Code',
278
278
  'codex': 'Codex',
279
+ 'opencode': 'OpenCode',
279
280
  };
280
281
  const sourceLabel = toolLabels[content.sourceTool];
281
282
  const targetLabel = toolLabels[targetTool];
@@ -320,41 +320,128 @@ class SSEEventCollectorTransform extends stream_1.Transform {
320
320
  this.events = [];
321
321
  }
322
322
  /**
323
- * 从events中提取usage信息
323
+ * 从events中提取usage信息。
324
+ *
325
+ * 关键:必须遍历**全部**事件并合并,而不是命中第一个就返回——
326
+ * Anthropic 流式把用量拆在两个事件里:`message_start.message.usage` 带 input_tokens,
327
+ * `message_delta.usage` 带(累计的)output_tokens。旧实现命中 message_delta 即返回,
328
+ * 导致 input_tokens 永远丢失。同时统一 OpenAI(prompt_tokens/completion_tokens) 与
329
+ * Gemini(usageMetadata) 的字段命名,返回归一化的 {input_tokens, output_tokens, total_tokens, cache_read_input_tokens}。
324
330
  */
325
331
  extractUsage() {
332
+ var _a, _b, _c;
333
+ let input_tokens;
334
+ let output_tokens;
335
+ let total_tokens;
336
+ let cache_read_input_tokens;
326
337
  for (const event of this.events) {
327
338
  if (!event.data)
328
339
  continue;
340
+ let data;
329
341
  try {
330
- const data = JSON.parse(event.data);
331
- // 尝试从不同的位置提取usage
332
- // 1. message_delta事件中的usage
333
- if (event.event === 'message_delta' && data.usage) {
334
- return data.usage;
335
- }
336
- // 2. 直接在data中的usage
337
- if (data.usage) {
338
- return data.usage;
339
- }
340
- // 3. OpenAI格式: choices数组中最后一个元素的usage
341
- if (Array.isArray(data.choices) && data.choices.length > 0) {
342
- const lastChoice = data.choices[data.choices.length - 1];
343
- if (lastChoice === null || lastChoice === void 0 ? void 0 : lastChoice.usage) {
344
- return lastChoice.usage;
345
- }
346
- }
347
- // 4. 直接在顶级的usage字段
348
- if ((data === null || data === void 0 ? void 0 : data.input_tokens) !== undefined || (data === null || data === void 0 ? void 0 : data.output_tokens) !== undefined ||
349
- (data === null || data === void 0 ? void 0 : data.prompt_tokens) !== undefined || (data === null || data === void 0 ? void 0 : data.completion_tokens) !== undefined) {
350
- return data;
342
+ data = JSON.parse(event.data);
343
+ }
344
+ catch (_d) {
345
+ continue;
346
+ }
347
+ // Anthropic: message_start 携带 input(在 message.usage 下)
348
+ const msgUsage = (_a = data === null || data === void 0 ? void 0 : data.message) === null || _a === void 0 ? void 0 : _a.usage;
349
+ if (msgUsage) {
350
+ if (typeof msgUsage.input_tokens === 'number')
351
+ input_tokens = msgUsage.input_tokens;
352
+ if (typeof msgUsage.cache_read_input_tokens === 'number')
353
+ cache_read_input_tokens = msgUsage.cache_read_input_tokens;
354
+ }
355
+ // 通用 usage 对象(Anthropic message_delta.usage / OpenAI usage
356
+ const usage = data === null || data === void 0 ? void 0 : data.usage;
357
+ if (usage) {
358
+ if (typeof usage.input_tokens === 'number')
359
+ input_tokens = usage.input_tokens;
360
+ if (typeof usage.output_tokens === 'number')
361
+ output_tokens = usage.output_tokens; // message_delta 累计值,取最后一次
362
+ if (typeof usage.cache_read_input_tokens === 'number')
363
+ cache_read_input_tokens = usage.cache_read_input_tokens;
364
+ if (typeof usage.prompt_tokens === 'number')
365
+ input_tokens = usage.prompt_tokens;
366
+ if (typeof usage.completion_tokens === 'number')
367
+ output_tokens = usage.completion_tokens;
368
+ if (typeof usage.total_tokens === 'number')
369
+ total_tokens = usage.total_tokens;
370
+ if (typeof usage.cached_tokens === 'number')
371
+ cache_read_input_tokens = usage.cached_tokens;
372
+ }
373
+ // OpenAI Responses 流式:usage 在最终 response.completed / response.done 事件的 data.response.usage 下
374
+ // (Codex 默认走 Responses 流式,此前漏读此处导致 Codex token 恒为 0)
375
+ const respUsage = (_b = data === null || data === void 0 ? void 0 : data.response) === null || _b === void 0 ? void 0 : _b.usage;
376
+ if (respUsage) {
377
+ if (typeof respUsage.input_tokens === 'number')
378
+ input_tokens = respUsage.input_tokens;
379
+ if (typeof respUsage.output_tokens === 'number')
380
+ output_tokens = respUsage.output_tokens;
381
+ if (typeof respUsage.total_tokens === 'number')
382
+ total_tokens = respUsage.total_tokens;
383
+ if (typeof respUsage.prompt_tokens === 'number')
384
+ input_tokens = respUsage.prompt_tokens;
385
+ if (typeof respUsage.completion_tokens === 'number')
386
+ output_tokens = respUsage.completion_tokens;
387
+ const cached = (_c = respUsage.input_tokens_details) === null || _c === void 0 ? void 0 : _c.cached_tokens;
388
+ if (typeof cached === 'number')
389
+ cache_read_input_tokens = cached;
390
+ }
391
+ // OpenAI: choices[].usage(部分上游把 usage 放在最后一个 choice 上)
392
+ if (Array.isArray(data === null || data === void 0 ? void 0 : data.choices) && data.choices.length > 0) {
393
+ const lastChoice = data.choices[data.choices.length - 1];
394
+ const cu = lastChoice === null || lastChoice === void 0 ? void 0 : lastChoice.usage;
395
+ if (cu) {
396
+ if (typeof cu.prompt_tokens === 'number')
397
+ input_tokens = cu.prompt_tokens;
398
+ if (typeof cu.completion_tokens === 'number')
399
+ output_tokens = cu.completion_tokens;
400
+ if (typeof cu.total_tokens === 'number')
401
+ total_tokens = cu.total_tokens;
351
402
  }
352
403
  }
353
- catch (_a) {
354
- // JSON解析失败,跳过
404
+ // Gemini: usageMetadata
405
+ const um = data === null || data === void 0 ? void 0 : data.usageMetadata;
406
+ if (um) {
407
+ if (typeof um.promptTokenCount === 'number')
408
+ input_tokens = um.promptTokenCount;
409
+ if (typeof um.candidatesTokenCount === 'number')
410
+ output_tokens = um.candidatesTokenCount;
411
+ if (typeof um.totalTokenCount === 'number')
412
+ total_tokens = um.totalTokenCount;
413
+ if (typeof um.cachedContentTokenCount === 'number')
414
+ cache_read_input_tokens = um.cachedContentTokenCount;
355
415
  }
416
+ // 顶级裸字段兜底
417
+ if (typeof (data === null || data === void 0 ? void 0 : data.input_tokens) === 'number')
418
+ input_tokens = data.input_tokens;
419
+ if (typeof (data === null || data === void 0 ? void 0 : data.output_tokens) === 'number')
420
+ output_tokens = data.output_tokens;
421
+ if (typeof (data === null || data === void 0 ? void 0 : data.prompt_tokens) === 'number')
422
+ input_tokens = data.prompt_tokens;
423
+ if (typeof (data === null || data === void 0 ? void 0 : data.completion_tokens) === 'number')
424
+ output_tokens = data.completion_tokens;
425
+ if (typeof (data === null || data === void 0 ? void 0 : data.total_tokens) === 'number')
426
+ total_tokens = data.total_tokens;
427
+ }
428
+ if (input_tokens === undefined && output_tokens === undefined && total_tokens === undefined) {
429
+ return null;
430
+ }
431
+ const result = {};
432
+ if (input_tokens !== undefined)
433
+ result.input_tokens = input_tokens;
434
+ if (output_tokens !== undefined)
435
+ result.output_tokens = output_tokens;
436
+ if (total_tokens !== undefined) {
437
+ result.total_tokens = total_tokens;
438
+ }
439
+ else if (input_tokens !== undefined && output_tokens !== undefined) {
440
+ result.total_tokens = input_tokens + output_tokens;
356
441
  }
357
- return null;
442
+ if (cache_read_input_tokens !== undefined)
443
+ result.cache_read_input_tokens = cache_read_input_tokens;
444
+ return result;
358
445
  }
359
446
  }
360
447
  exports.SSEEventCollectorTransform = SSEEventCollectorTransform;