coding-tool-x 3.3.8 → 3.3.9

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 (75) hide show
  1. package/CHANGELOG.md +17 -2
  2. package/README.md +253 -326
  3. package/dist/web/assets/{Analytics-DLpoDZ2M.js → Analytics-D6LzK9hk.js} +1 -1
  4. package/dist/web/assets/{ConfigTemplates-D_hRb55W.js → ConfigTemplates-BUDYuxRi.js} +1 -1
  5. package/dist/web/assets/Home-BQxQ1LhR.css +1 -0
  6. package/dist/web/assets/Home-D7KX7iF8.js +1 -0
  7. package/dist/web/assets/{PluginManager-JXsyym1s.js → PluginManager-DTgQ--vB.js} +1 -1
  8. package/dist/web/assets/{ProjectList-DZWSeb-q.js → ProjectList-DMCiGmCT.js} +1 -1
  9. package/dist/web/assets/{SessionList-Cs624DR3.js → SessionList-CRBsdVRe.js} +1 -1
  10. package/dist/web/assets/{SkillManager-bEliz7qz.js → SkillManager-DMwx2Q4k.js} +1 -1
  11. package/dist/web/assets/{WorkspaceManager-J3RecFGn.js → WorkspaceManager-DapB4ljL.js} +1 -1
  12. package/dist/web/assets/{icons-Cuc23WS7.js → icons-B5Pl4lrD.js} +1 -1
  13. package/dist/web/assets/index-CL-qpoJ_.js +2 -0
  14. package/dist/web/assets/index-D_5dRFOL.css +1 -0
  15. package/dist/web/assets/{markdown-C9MYpaSi.js → markdown-DyTJGI4N.js} +1 -1
  16. package/dist/web/assets/{naive-ui-CxpuzdjU.js → naive-ui-Bdxp09n2.js} +1 -1
  17. package/dist/web/assets/{vendors-DMjSfzlv.js → vendors-CKPV1OAU.js} +2 -2
  18. package/dist/web/assets/{vue-vendor-DET08QYg.js → vue-vendor-3bf-fPGP.js} +1 -1
  19. package/dist/web/index.html +7 -7
  20. package/docs/home.png +0 -0
  21. package/package.json +13 -5
  22. package/src/commands/daemon.js +3 -2
  23. package/src/commands/security.js +1 -2
  24. package/src/config/paths.js +638 -93
  25. package/src/server/api/agents.js +1 -1
  26. package/src/server/api/claude-hooks.js +13 -8
  27. package/src/server/api/codex-proxy.js +5 -4
  28. package/src/server/api/hooks.js +45 -0
  29. package/src/server/api/plugins.js +0 -1
  30. package/src/server/api/ui-config.js +5 -0
  31. package/src/server/codex-proxy-server.js +89 -59
  32. package/src/server/gemini-proxy-server.js +107 -88
  33. package/src/server/index.js +1 -0
  34. package/src/server/opencode-proxy-server.js +381 -225
  35. package/src/server/proxy-server.js +86 -60
  36. package/src/server/services/alias.js +3 -3
  37. package/src/server/services/channels.js +3 -2
  38. package/src/server/services/codex-channels.js +41 -87
  39. package/src/server/services/codex-env-manager.js +423 -0
  40. package/src/server/services/codex-settings-manager.js +15 -15
  41. package/src/server/services/codex-statistics-service.js +3 -27
  42. package/src/server/services/config-export-service.js +20 -7
  43. package/src/server/services/config-registry-service.js +3 -2
  44. package/src/server/services/config-sync-manager.js +1 -1
  45. package/src/server/services/favorites.js +4 -3
  46. package/src/server/services/gemini-channels.js +3 -3
  47. package/src/server/services/gemini-statistics-service.js +3 -25
  48. package/src/server/services/mcp-service.js +2 -3
  49. package/src/server/services/model-detector.js +4 -3
  50. package/src/server/services/native-oauth-adapters.js +2 -1
  51. package/src/server/services/network-access.js +39 -1
  52. package/src/server/services/notification-hooks.js +951 -0
  53. package/src/server/services/opencode-channels.js +6 -6
  54. package/src/server/services/opencode-sessions.js +2 -2
  55. package/src/server/services/opencode-statistics-service.js +3 -27
  56. package/src/server/services/plugins-service.js +110 -31
  57. package/src/server/services/prompts-service.js +2 -3
  58. package/src/server/services/proxy-log-helper.js +242 -0
  59. package/src/server/services/proxy-runtime.js +6 -4
  60. package/src/server/services/repo-scanner-base.js +12 -4
  61. package/src/server/services/request-logger.js +7 -7
  62. package/src/server/services/security-config.js +4 -4
  63. package/src/server/services/session-cache.js +2 -2
  64. package/src/server/services/sessions.js +2 -2
  65. package/src/server/services/skill-service.js +174 -55
  66. package/src/server/services/statistics-service.js +5 -5
  67. package/src/server/services/ui-config.js +4 -3
  68. package/src/server/services/workspace-service.js +1 -1
  69. package/src/server/websocket-server.js +5 -4
  70. package/dist/web/assets/Home-BMoFdAwy.css +0 -1
  71. package/dist/web/assets/Home-DNwp-0J-.js +0 -1
  72. package/dist/web/assets/index-BXeSvAwU.js +0 -2
  73. package/dist/web/assets/index-DWAC3Tdv.css +0 -1
  74. package/docs/bannel.png +0 -0
  75. package/docs/model-redirection.md +0 -251
@@ -14,13 +14,13 @@ const { recordSuccess, recordFailure } = require('./services/channel-health');
14
14
  const { loadConfig } = require('../config/loader');
15
15
  const DEFAULT_CONFIG = require('../config/default');
16
16
  const { PATHS, ensureStorageDirMigrated } = require('../config/paths');
17
- const { resolvePricing } = require('./utils/pricing');
17
+ const { resolveModelPricing } = require('./utils/pricing');
18
18
  const { recordRequest: recordOpenCodeRequest } = require('./services/opencode-statistics-service');
19
19
  const { saveProxyStartTime, clearProxyStartTime, getProxyStartTime, getProxyRuntime } = require('./services/proxy-runtime');
20
20
  const { getEnabledChannels, getEffectiveApiKey } = require('./services/opencode-channels');
21
21
  const { persistProxyRequestSnapshot, loadClaudeRequestTemplate } = require('./services/request-logger');
22
22
  const { probeModelAvailability, fetchModelsFromProvider } = require('./services/model-detector');
23
- const { CLAUDE_MODEL_PRICING } = require('../config/model-pricing');
23
+ const { publishUsageLog, publishFailureLog } = require('./services/proxy-log-helper');
24
24
 
25
25
  let proxyServer = null;
26
26
  let proxyApp = null;
@@ -190,61 +190,33 @@ function resolveOpenCodeTarget(baseUrl = '', requestPath = '') {
190
190
  * 计算请求成本
191
191
  */
192
192
  function calculateCost(model, tokens) {
193
- let pricing;
194
-
195
- // 首先检查是否是 Claude 模型,使用集中定价
196
- if (model.startsWith('claude-') || model.toLowerCase().includes('claude')) {
197
- pricing = CLAUDE_MODEL_PRICING[model];
198
-
199
- // 如果没有精确匹配,尝试模糊匹配 Claude 模型
200
- if (!pricing) {
201
- const modelLower = model.toLowerCase();
202
- // 查找最接近的 Claude 模型
203
- for (const [key, value] of Object.entries(CLAUDE_MODEL_PRICING)) {
204
- if (key.toLowerCase().includes(modelLower) || modelLower.includes(key.toLowerCase())) {
205
- pricing = value;
206
- break;
207
- }
208
- }
209
- }
210
-
211
- // 如果仍然没有找到,使用默认 Sonnet 定价
212
- if (!pricing) {
213
- pricing = CLAUDE_MODEL_PRICING['claude-sonnet-4-5-20250929'];
214
- }
215
- } else {
216
- // 非 Claude 模型,使用 PRICING 对象(OpenAI 等)
217
- pricing = PRICING[model];
218
-
219
- // 如果没有精确匹配,尝试模糊匹配
220
- if (!pricing) {
221
- const modelLower = model.toLowerCase();
222
- if (modelLower.includes('gpt-4o-mini')) {
223
- pricing = PRICING['gpt-4o-mini'];
224
- } else if (modelLower.includes('gpt-4o')) {
225
- pricing = PRICING['gpt-4o'];
226
- } else if (modelLower.includes('gpt-4')) {
227
- pricing = PRICING['gpt-4'];
228
- } else if (modelLower.includes('gpt-3.5')) {
229
- pricing = PRICING['gpt-3.5-turbo'];
230
- } else if (modelLower.includes('o1-mini')) {
231
- pricing = PRICING['o1-mini'];
232
- } else if (modelLower.includes('o1-pro')) {
233
- pricing = PRICING['o1-pro'];
234
- } else if (modelLower.includes('o1')) {
235
- pricing = PRICING['o1'];
236
- } else if (modelLower.includes('o3-mini')) {
237
- pricing = PRICING['o3-mini'];
238
- } else if (modelLower.includes('o3')) {
239
- pricing = PRICING['o3'];
240
- } else if (modelLower.includes('o4-mini')) {
241
- pricing = PRICING['o4-mini'];
242
- }
193
+ let fallbackPricing = PRICING[model];
194
+ if (!fallbackPricing) {
195
+ const modelLower = String(model || '').toLowerCase();
196
+ if (modelLower.includes('gpt-4o-mini')) {
197
+ fallbackPricing = PRICING['gpt-4o-mini'];
198
+ } else if (modelLower.includes('gpt-4o')) {
199
+ fallbackPricing = PRICING['gpt-4o'];
200
+ } else if (modelLower.includes('gpt-4')) {
201
+ fallbackPricing = PRICING['gpt-4'];
202
+ } else if (modelLower.includes('gpt-3.5')) {
203
+ fallbackPricing = PRICING['gpt-3.5-turbo'];
204
+ } else if (modelLower.includes('o1-mini')) {
205
+ fallbackPricing = PRICING['o1-mini'];
206
+ } else if (modelLower.includes('o1-pro')) {
207
+ fallbackPricing = PRICING['o1-pro'];
208
+ } else if (modelLower.includes('o1')) {
209
+ fallbackPricing = PRICING['o1'];
210
+ } else if (modelLower.includes('o3-mini')) {
211
+ fallbackPricing = PRICING['o3-mini'];
212
+ } else if (modelLower.includes('o3')) {
213
+ fallbackPricing = PRICING['o3'];
214
+ } else if (modelLower.includes('o4-mini')) {
215
+ fallbackPricing = PRICING['o4-mini'];
243
216
  }
244
217
  }
245
218
 
246
- // 默认使用基础定价
247
- pricing = resolvePricing('opencode', pricing, OPENCODE_BASE_PRICING);
219
+ const pricing = resolveModelPricing('opencode', model, fallbackPricing, OPENCODE_BASE_PRICING);
248
220
  const inputRate = typeof pricing.input === 'number' ? pricing.input : OPENCODE_BASE_PRICING.input;
249
221
  const outputRate = typeof pricing.output === 'number' ? pricing.output : OPENCODE_BASE_PRICING.output;
250
222
 
@@ -1998,59 +1970,63 @@ function sendOpenAiStyleError(res, statusCode, message, type = 'invalid_request_
1998
1970
  });
1999
1971
  }
2000
1972
 
2001
- function publishOpenCodeUsageLog({ requestId, channel, model, usage, startTime }) {
2002
- const inputTokens = Number(usage?.input_tokens || usage?.prompt_tokens || 0);
2003
- const outputTokens = Number(usage?.output_tokens || usage?.completion_tokens || 0);
2004
- const totalTokens = Number(usage?.total_tokens || (inputTokens + outputTokens));
2005
- const cachedTokens = Number(usage?.input_tokens_details?.cached_tokens || 0);
2006
- const reasoningTokens = Number(usage?.output_tokens_details?.reasoning_tokens || 0);
2007
- const now = new Date();
2008
- const time = now.toLocaleTimeString('zh-CN', {
2009
- hour12: false,
2010
- hour: '2-digit',
2011
- minute: '2-digit',
2012
- second: '2-digit'
2013
- });
2014
-
2015
- const tokens = {
2016
- input: inputTokens,
2017
- output: outputTokens,
2018
- total: totalTokens
2019
- };
2020
- const cost = calculateCost(model || '', tokens);
1973
+ function reportOpenCodeGatewayFailure({
1974
+ req,
1975
+ res,
1976
+ channel,
1977
+ statusCode,
1978
+ message,
1979
+ type = 'invalid_request_error',
1980
+ error = null,
1981
+ stage = 'gateway',
1982
+ model = ''
1983
+ }) {
1984
+ if (channel?.id && error) {
1985
+ recordFailure(channel.id, 'opencode', error);
1986
+ }
2021
1987
 
2022
- broadcastLog({
2023
- type: 'log',
2024
- id: requestId,
2025
- time,
2026
- channel: channel.name,
2027
- model: model || '',
2028
- inputTokens,
2029
- outputTokens,
2030
- cachedTokens,
2031
- reasoningTokens,
2032
- totalTokens,
2033
- cost,
2034
- source: 'opencode'
1988
+ publishFailureLog({
1989
+ source: 'opencode',
1990
+ metadata: (req && requestMetadata.get(req)) || {
1991
+ channel: channel?.name,
1992
+ channelId: channel?.id,
1993
+ model: model || req?.body?.model
1994
+ },
1995
+ channel: channel?.name,
1996
+ model: model || req?.body?.model || '',
1997
+ message,
1998
+ error,
1999
+ statusCode,
2000
+ stage,
2001
+ broadcastLog
2035
2002
  });
2036
2003
 
2037
- recordOpenCodeRequest({
2038
- id: requestId,
2039
- timestamp: new Date(startTime).toISOString(),
2040
- toolType: 'opencode',
2041
- channel: channel.name,
2042
- channelId: channel.id,
2004
+ if (!res.headersSent) {
2005
+ sendOpenAiStyleError(res, statusCode, message, type);
2006
+ }
2007
+ return true;
2008
+ }
2009
+
2010
+ function publishOpenCodeUsageLog({ requestId, channel, model, usage, startTime }) {
2011
+ return publishUsageLog({
2012
+ source: 'opencode',
2013
+ metadata: {
2014
+ id: requestId,
2015
+ channel: channel?.name,
2016
+ channelId: channel?.id,
2017
+ startTime
2018
+ },
2043
2019
  model: model || '',
2044
2020
  tokens: {
2045
- input: inputTokens,
2046
- output: outputTokens,
2047
- reasoning: reasoningTokens,
2048
- cached: cachedTokens,
2049
- total: totalTokens
2021
+ input: Number(usage?.input_tokens || usage?.prompt_tokens || 0),
2022
+ output: Number(usage?.output_tokens || usage?.completion_tokens || 0),
2023
+ cached: Number(usage?.input_tokens_details?.cached_tokens || 0),
2024
+ reasoning: Number(usage?.output_tokens_details?.reasoning_tokens || 0),
2025
+ total: Number(usage?.total_tokens || 0)
2050
2026
  },
2051
- duration: Date.now() - startTime,
2052
- success: true,
2053
- cost
2027
+ calculateCost,
2028
+ broadcastLog,
2029
+ recordRequest: recordOpenCodeRequest
2054
2030
  });
2055
2031
  }
2056
2032
 
@@ -3001,8 +2977,14 @@ async function handleClaudeGatewayRequest(req, res, channel, effectiveKey) {
3001
2977
  }
3002
2978
 
3003
2979
  if (!shouldParseJson(req)) {
3004
- sendOpenAiStyleError(res, 400, 'Claude gateway only supports JSON POST payload');
3005
- return true;
2980
+ return reportOpenCodeGatewayFailure({
2981
+ req,
2982
+ res,
2983
+ channel,
2984
+ statusCode: 400,
2985
+ message: 'Claude gateway only supports JSON POST payload',
2986
+ stage: 'validate_request'
2987
+ });
3006
2988
  }
3007
2989
 
3008
2990
  const requestId = `opencode-${Date.now()}-${Math.random()}`;
@@ -3035,9 +3017,16 @@ async function handleClaudeGatewayRequest(req, res, channel, effectiveKey) {
3035
3017
  try {
3036
3018
  streamUpstream = await postJsonStream(buildClaudeTargetUrl(channel.baseUrl), headers, claudePayload, 120000);
3037
3019
  } catch (error) {
3038
- recordFailure(channel.id, 'opencode', error);
3039
- sendOpenAiStyleError(res, 502, `Claude gateway network error: ${error.message}`, 'proxy_error');
3040
- return true;
3020
+ return reportOpenCodeGatewayFailure({
3021
+ req,
3022
+ res,
3023
+ channel,
3024
+ statusCode: 502,
3025
+ message: `Claude gateway network error: ${error.message}`,
3026
+ type: 'proxy_error',
3027
+ error,
3028
+ stage: 'claude_gateway_network'
3029
+ });
3041
3030
  }
3042
3031
 
3043
3032
  const statusCode = Number(streamUpstream.statusCode) || 500;
@@ -3056,9 +3045,16 @@ async function handleClaudeGatewayRequest(req, res, channel, effectiveKey) {
3056
3045
  parsedError = null;
3057
3046
  }
3058
3047
  const upstreamMessage = parsedError?.error?.message || parsedError?.message || rawBody || `HTTP ${statusCode}`;
3059
- recordFailure(channel.id, 'opencode', new Error(String(upstreamMessage).slice(0, 200)));
3060
- sendOpenAiStyleError(res, statusCode, String(upstreamMessage).slice(0, 1000), 'upstream_error');
3061
- return true;
3048
+ return reportOpenCodeGatewayFailure({
3049
+ req,
3050
+ res,
3051
+ channel,
3052
+ statusCode,
3053
+ message: String(upstreamMessage).slice(0, 1000),
3054
+ type: 'upstream_error',
3055
+ error: new Error(String(upstreamMessage).slice(0, 200)),
3056
+ stage: 'claude_gateway_upstream'
3057
+ });
3062
3058
  }
3063
3059
 
3064
3060
  try {
@@ -3072,10 +3068,16 @@ async function handleClaudeGatewayRequest(req, res, channel, effectiveKey) {
3072
3068
  });
3073
3069
  recordSuccess(channel.id, 'opencode');
3074
3070
  } catch (error) {
3075
- recordFailure(channel.id, 'opencode', error);
3076
- if (!res.headersSent) {
3077
- sendOpenAiStyleError(res, 502, `Claude stream relay error: ${error.message}`, 'proxy_error');
3078
- }
3071
+ reportOpenCodeGatewayFailure({
3072
+ req,
3073
+ res,
3074
+ channel,
3075
+ statusCode: 502,
3076
+ message: `Claude stream relay error: ${error.message}`,
3077
+ type: 'proxy_error',
3078
+ error,
3079
+ stage: 'claude_stream_relay'
3080
+ });
3079
3081
  }
3080
3082
  return true;
3081
3083
  }
@@ -3084,9 +3086,16 @@ async function handleClaudeGatewayRequest(req, res, channel, effectiveKey) {
3084
3086
  try {
3085
3087
  upstream = await postJson(buildClaudeTargetUrl(channel.baseUrl), headers, claudePayload, 120000);
3086
3088
  } catch (error) {
3087
- recordFailure(channel.id, 'opencode', error);
3088
- sendOpenAiStyleError(res, 502, `Claude gateway network error: ${error.message}`, 'proxy_error');
3089
- return true;
3089
+ return reportOpenCodeGatewayFailure({
3090
+ req,
3091
+ res,
3092
+ channel,
3093
+ statusCode: 502,
3094
+ message: `Claude gateway network error: ${error.message}`,
3095
+ type: 'proxy_error',
3096
+ error,
3097
+ stage: 'claude_gateway_network'
3098
+ });
3090
3099
  }
3091
3100
 
3092
3101
  const statusCode = Number(upstream.statusCode) || 500;
@@ -3099,15 +3108,29 @@ async function handleClaudeGatewayRequest(req, res, channel, effectiveKey) {
3099
3108
 
3100
3109
  if (statusCode < 200 || statusCode >= 300) {
3101
3110
  const upstreamMessage = parsedBody?.error?.message || parsedBody?.message || upstream.rawBody || `HTTP ${statusCode}`;
3102
- recordFailure(channel.id, 'opencode', new Error(String(upstreamMessage).slice(0, 200)));
3103
- sendOpenAiStyleError(res, statusCode, String(upstreamMessage).slice(0, 1000), 'upstream_error');
3104
- return true;
3111
+ return reportOpenCodeGatewayFailure({
3112
+ req,
3113
+ res,
3114
+ channel,
3115
+ statusCode,
3116
+ message: String(upstreamMessage).slice(0, 1000),
3117
+ type: 'upstream_error',
3118
+ error: new Error(String(upstreamMessage).slice(0, 200)),
3119
+ stage: 'claude_gateway_upstream'
3120
+ });
3105
3121
  }
3106
3122
 
3107
3123
  if (!parsedBody || typeof parsedBody !== 'object') {
3108
- recordFailure(channel.id, 'opencode', new Error('Invalid Claude gateway response'));
3109
- sendOpenAiStyleError(res, 502, 'Invalid Claude gateway response', 'proxy_error');
3110
- return true;
3124
+ return reportOpenCodeGatewayFailure({
3125
+ req,
3126
+ res,
3127
+ channel,
3128
+ statusCode: 502,
3129
+ message: 'Invalid Claude gateway response',
3130
+ type: 'proxy_error',
3131
+ error: new Error('Invalid Claude gateway response'),
3132
+ stage: 'claude_gateway_parse'
3133
+ });
3111
3134
  }
3112
3135
 
3113
3136
  if (isResponsesPath(pathname)) {
@@ -3152,8 +3175,14 @@ async function handleCodexGatewayRequest(req, res, channel, effectiveKey) {
3152
3175
  }
3153
3176
 
3154
3177
  if (!shouldParseJson(req)) {
3155
- sendOpenAiStyleError(res, 400, 'Codex gateway only supports JSON POST payload');
3156
- return true;
3178
+ return reportOpenCodeGatewayFailure({
3179
+ req,
3180
+ res,
3181
+ channel,
3182
+ statusCode: 400,
3183
+ message: 'Codex gateway only supports JSON POST payload',
3184
+ stage: 'validate_request'
3185
+ });
3157
3186
  }
3158
3187
 
3159
3188
  const requestId = `opencode-${Date.now()}-${Math.random()}`;
@@ -3164,14 +3193,26 @@ async function handleCodexGatewayRequest(req, res, channel, effectiveKey) {
3164
3193
  const targetModel = converted.model;
3165
3194
 
3166
3195
  if (!targetModel) {
3167
- sendOpenAiStyleError(res, 400, 'Missing model in request and channel configuration');
3168
- return true;
3196
+ return reportOpenCodeGatewayFailure({
3197
+ req,
3198
+ res,
3199
+ channel,
3200
+ statusCode: 400,
3201
+ message: 'Missing model in request and channel configuration',
3202
+ stage: 'resolve_model'
3203
+ });
3169
3204
  }
3170
3205
 
3171
3206
  const targetUrl = buildCodexTargetUrl(channel.baseUrl);
3172
3207
  if (!targetUrl) {
3173
- sendOpenAiStyleError(res, 400, 'Failed to build Codex target URL');
3174
- return true;
3208
+ return reportOpenCodeGatewayFailure({
3209
+ req,
3210
+ res,
3211
+ channel,
3212
+ statusCode: 400,
3213
+ message: 'Failed to build Codex target URL',
3214
+ stage: 'build_target_url'
3215
+ });
3175
3216
  }
3176
3217
 
3177
3218
  const codexSessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 15)}`;
@@ -3198,9 +3239,16 @@ async function handleCodexGatewayRequest(req, res, channel, effectiveKey) {
3198
3239
  try {
3199
3240
  streamUpstream = await postJsonStream(targetUrl, headers, converted.requestBody, 120000);
3200
3241
  } catch (error) {
3201
- recordFailure(channel.id, 'opencode', error);
3202
- sendOpenAiStyleError(res, 502, `Codex gateway network error: ${error.message}`, 'proxy_error');
3203
- return true;
3242
+ return reportOpenCodeGatewayFailure({
3243
+ req,
3244
+ res,
3245
+ channel,
3246
+ statusCode: 502,
3247
+ message: `Codex gateway network error: ${error.message}`,
3248
+ type: 'proxy_error',
3249
+ error,
3250
+ stage: 'codex_gateway_network'
3251
+ });
3204
3252
  }
3205
3253
 
3206
3254
  const statusCode = Number(streamUpstream.statusCode) || 500;
@@ -3220,9 +3268,16 @@ async function handleCodexGatewayRequest(req, res, channel, effectiveKey) {
3220
3268
  }
3221
3269
 
3222
3270
  const upstreamMessage = parsedError?.error?.message || parsedError?.message || rawBody || `HTTP ${statusCode}`;
3223
- recordFailure(channel.id, 'opencode', new Error(String(upstreamMessage).slice(0, 200)));
3224
- sendOpenAiStyleError(res, statusCode, String(upstreamMessage).slice(0, 1000), 'upstream_error');
3225
- return true;
3271
+ return reportOpenCodeGatewayFailure({
3272
+ req,
3273
+ res,
3274
+ channel,
3275
+ statusCode,
3276
+ message: String(upstreamMessage).slice(0, 1000),
3277
+ type: 'upstream_error',
3278
+ error: new Error(String(upstreamMessage).slice(0, 200)),
3279
+ stage: 'codex_gateway_upstream'
3280
+ });
3226
3281
  }
3227
3282
 
3228
3283
  try {
@@ -3241,9 +3296,16 @@ async function handleCodexGatewayRequest(req, res, channel, effectiveKey) {
3241
3296
 
3242
3297
  const responseObject = await collectCodexResponsesNonStream(streamUpstream.response, originalPayload);
3243
3298
  if (!responseObject || typeof responseObject !== 'object') {
3244
- recordFailure(channel.id, 'opencode', new Error('Invalid Codex gateway response'));
3245
- sendOpenAiStyleError(res, 502, 'Invalid Codex gateway response', 'proxy_error');
3246
- return true;
3299
+ return reportOpenCodeGatewayFailure({
3300
+ req,
3301
+ res,
3302
+ channel,
3303
+ statusCode: 502,
3304
+ message: 'Invalid Codex gateway response',
3305
+ type: 'proxy_error',
3306
+ error: new Error('Invalid Codex gateway response'),
3307
+ stage: 'codex_gateway_parse'
3308
+ });
3247
3309
  }
3248
3310
  res.json(responseObject);
3249
3311
  publishOpenCodeUsageLog({
@@ -3256,10 +3318,16 @@ async function handleCodexGatewayRequest(req, res, channel, effectiveKey) {
3256
3318
  recordSuccess(channel.id, 'opencode');
3257
3319
  return true;
3258
3320
  } catch (error) {
3259
- recordFailure(channel.id, 'opencode', error);
3260
- if (!res.headersSent) {
3261
- sendOpenAiStyleError(res, 502, `Codex stream relay error: ${error.message}`, 'proxy_error');
3262
- }
3321
+ reportOpenCodeGatewayFailure({
3322
+ req,
3323
+ res,
3324
+ channel,
3325
+ statusCode: 502,
3326
+ message: `Codex stream relay error: ${error.message}`,
3327
+ type: 'proxy_error',
3328
+ error,
3329
+ stage: 'codex_stream_relay'
3330
+ });
3263
3331
  return true;
3264
3332
  }
3265
3333
  }
@@ -3925,8 +3993,14 @@ async function handleGeminiGatewayRequest(req, res, channel, effectiveKey) {
3925
3993
  }
3926
3994
 
3927
3995
  if (!shouldParseJson(req)) {
3928
- sendOpenAiStyleError(res, 400, 'Gemini gateway only supports JSON POST payload');
3929
- return true;
3996
+ return reportOpenCodeGatewayFailure({
3997
+ req,
3998
+ res,
3999
+ channel,
4000
+ statusCode: 400,
4001
+ message: 'Gemini gateway only supports JSON POST payload',
4002
+ stage: 'validate_request'
4003
+ });
3930
4004
  }
3931
4005
 
3932
4006
  const requestId = `opencode-${Date.now()}-${Math.random()}`;
@@ -3939,8 +4013,14 @@ async function handleGeminiGatewayRequest(req, res, channel, effectiveKey) {
3939
4013
  const useGeminiCli = shouldUseGeminiCliFormat(channel.baseUrl);
3940
4014
 
3941
4015
  if (!targetModel) {
3942
- sendOpenAiStyleError(res, 400, 'Missing model in request and channel configuration');
3943
- return true;
4016
+ return reportOpenCodeGatewayFailure({
4017
+ req,
4018
+ res,
4019
+ channel,
4020
+ statusCode: 400,
4021
+ message: 'Missing model in request and channel configuration',
4022
+ stage: 'resolve_model'
4023
+ });
3944
4024
  }
3945
4025
 
3946
4026
  const targetUrl = buildGeminiTargetUrl(channel.baseUrl, targetModel, effectiveKey, {
@@ -3948,8 +4028,14 @@ async function handleGeminiGatewayRequest(req, res, channel, effectiveKey) {
3948
4028
  useCli: useGeminiCli
3949
4029
  });
3950
4030
  if (!targetUrl) {
3951
- sendOpenAiStyleError(res, 400, 'Failed to build Gemini target URL');
3952
- return true;
4031
+ return reportOpenCodeGatewayFailure({
4032
+ req,
4033
+ res,
4034
+ channel,
4035
+ statusCode: 400,
4036
+ message: 'Failed to build Gemini target URL',
4037
+ stage: 'build_target_url'
4038
+ });
3953
4039
  }
3954
4040
 
3955
4041
  const geminiPayload = useGeminiCli
@@ -3985,9 +4071,16 @@ async function handleGeminiGatewayRequest(req, res, channel, effectiveKey) {
3985
4071
  try {
3986
4072
  streamUpstream = await postJsonStream(targetUrl, headers, geminiPayload, 120000);
3987
4073
  } catch (error) {
3988
- recordFailure(channel.id, 'opencode', error);
3989
- sendOpenAiStyleError(res, 502, `Gemini gateway network error: ${error.message}`, 'proxy_error');
3990
- return true;
4074
+ return reportOpenCodeGatewayFailure({
4075
+ req,
4076
+ res,
4077
+ channel,
4078
+ statusCode: 502,
4079
+ message: `Gemini gateway network error: ${error.message}`,
4080
+ type: 'proxy_error',
4081
+ error,
4082
+ stage: 'gemini_gateway_network'
4083
+ });
3991
4084
  }
3992
4085
 
3993
4086
  const statusCode = Number(streamUpstream.statusCode) || 500;
@@ -4006,9 +4099,16 @@ async function handleGeminiGatewayRequest(req, res, channel, effectiveKey) {
4006
4099
  parsedError = null;
4007
4100
  }
4008
4101
  const upstreamMessage = parsedError?.error?.message || parsedError?.message || rawBody || `HTTP ${statusCode}`;
4009
- recordFailure(channel.id, 'opencode', new Error(String(upstreamMessage).slice(0, 200)));
4010
- sendOpenAiStyleError(res, statusCode, String(upstreamMessage).slice(0, 1000), 'upstream_error');
4011
- return true;
4102
+ return reportOpenCodeGatewayFailure({
4103
+ req,
4104
+ res,
4105
+ channel,
4106
+ statusCode,
4107
+ message: String(upstreamMessage).slice(0, 1000),
4108
+ type: 'upstream_error',
4109
+ error: new Error(String(upstreamMessage).slice(0, 200)),
4110
+ stage: 'gemini_gateway_upstream'
4111
+ });
4012
4112
  }
4013
4113
 
4014
4114
  try {
@@ -4022,10 +4122,16 @@ async function handleGeminiGatewayRequest(req, res, channel, effectiveKey) {
4022
4122
  });
4023
4123
  recordSuccess(channel.id, 'opencode');
4024
4124
  } catch (error) {
4025
- recordFailure(channel.id, 'opencode', error);
4026
- if (!res.headersSent) {
4027
- sendOpenAiStyleError(res, 502, `Gemini stream relay error: ${error.message}`, 'proxy_error');
4028
- }
4125
+ reportOpenCodeGatewayFailure({
4126
+ req,
4127
+ res,
4128
+ channel,
4129
+ statusCode: 502,
4130
+ message: `Gemini stream relay error: ${error.message}`,
4131
+ type: 'proxy_error',
4132
+ error,
4133
+ stage: 'gemini_stream_relay'
4134
+ });
4029
4135
  }
4030
4136
  return true;
4031
4137
  }
@@ -4034,9 +4140,16 @@ async function handleGeminiGatewayRequest(req, res, channel, effectiveKey) {
4034
4140
  try {
4035
4141
  upstream = await postJson(targetUrl, headers, geminiPayload, 120000);
4036
4142
  } catch (error) {
4037
- recordFailure(channel.id, 'opencode', error);
4038
- sendOpenAiStyleError(res, 502, `Gemini gateway network error: ${error.message}`, 'proxy_error');
4039
- return true;
4143
+ return reportOpenCodeGatewayFailure({
4144
+ req,
4145
+ res,
4146
+ channel,
4147
+ statusCode: 502,
4148
+ message: `Gemini gateway network error: ${error.message}`,
4149
+ type: 'proxy_error',
4150
+ error,
4151
+ stage: 'gemini_gateway_network'
4152
+ });
4040
4153
  }
4041
4154
 
4042
4155
  const statusCode = Number(upstream.statusCode) || 500;
@@ -4049,15 +4162,29 @@ async function handleGeminiGatewayRequest(req, res, channel, effectiveKey) {
4049
4162
 
4050
4163
  if (statusCode < 200 || statusCode >= 300) {
4051
4164
  const upstreamMessage = parsedBody?.error?.message || parsedBody?.message || upstream.rawBody || `HTTP ${statusCode}`;
4052
- recordFailure(channel.id, 'opencode', new Error(String(upstreamMessage).slice(0, 200)));
4053
- sendOpenAiStyleError(res, statusCode, String(upstreamMessage).slice(0, 1000), 'upstream_error');
4054
- return true;
4165
+ return reportOpenCodeGatewayFailure({
4166
+ req,
4167
+ res,
4168
+ channel,
4169
+ statusCode,
4170
+ message: String(upstreamMessage).slice(0, 1000),
4171
+ type: 'upstream_error',
4172
+ error: new Error(String(upstreamMessage).slice(0, 200)),
4173
+ stage: 'gemini_gateway_upstream'
4174
+ });
4055
4175
  }
4056
4176
 
4057
4177
  if (!parsedBody || typeof parsedBody !== 'object') {
4058
- recordFailure(channel.id, 'opencode', new Error('Invalid Gemini gateway response'));
4059
- sendOpenAiStyleError(res, 502, 'Invalid Gemini gateway response', 'proxy_error');
4060
- return true;
4178
+ return reportOpenCodeGatewayFailure({
4179
+ req,
4180
+ res,
4181
+ channel,
4182
+ statusCode: 502,
4183
+ message: 'Invalid Gemini gateway response',
4184
+ type: 'proxy_error',
4185
+ error: new Error('Invalid Gemini gateway response'),
4186
+ stage: 'gemini_gateway_parse'
4187
+ });
4061
4188
  }
4062
4189
 
4063
4190
  if (isResponsesPath(pathname)) {
@@ -4241,6 +4368,14 @@ async function startOpenCodeProxyServer(options = {}) {
4241
4368
  if (!effectiveKey) {
4242
4369
  releaseChannel(channel.id, 'opencode');
4243
4370
  broadcastSchedulerState('opencode', getSchedulerState('opencode'));
4371
+ publishFailureLog({
4372
+ source: 'opencode',
4373
+ channel: channel.name,
4374
+ message: 'API key not configured or expired. Please update your channel key.',
4375
+ statusCode: 401,
4376
+ stage: 'preflight',
4377
+ broadcastLog
4378
+ });
4244
4379
  return res.status(401).json({
4245
4380
  error: {
4246
4381
  message: 'API key not configured or expired. Please update your channel key.',
@@ -4332,6 +4467,20 @@ async function startOpenCodeProxyServer(options = {}) {
4332
4467
  release();
4333
4468
  if (err) {
4334
4469
  recordFailure(channel.id, 'opencode', err);
4470
+ const metadata = requestMetadata.get(req) || {
4471
+ channel: channel.name,
4472
+ channelId: channel.id,
4473
+ startTime: Date.now()
4474
+ };
4475
+ publishFailureLog({
4476
+ source: 'opencode',
4477
+ metadata,
4478
+ message: err.message,
4479
+ error: err,
4480
+ statusCode: 502,
4481
+ stage: 'proxy_web',
4482
+ broadcastLog
4483
+ });
4335
4484
  console.error('OpenCode proxy error:', err);
4336
4485
  if (res && !res.headersSent) {
4337
4486
  res.status(502).json({
@@ -4345,6 +4494,13 @@ async function startOpenCodeProxyServer(options = {}) {
4345
4494
  });
4346
4495
  } catch (error) {
4347
4496
  console.error('OpenCode channel allocation error:', error);
4497
+ publishFailureLog({
4498
+ source: 'opencode',
4499
+ message: error.message || 'No OpenCode channel available',
4500
+ statusCode: 503,
4501
+ stage: 'allocate_channel',
4502
+ broadcastLog
4503
+ });
4348
4504
  if (!res.headersSent) {
4349
4505
  res.status(503).json({
4350
4506
  error: {
@@ -4397,6 +4553,38 @@ async function startOpenCodeProxyServer(options = {}) {
4397
4553
  totalTokens: 0,
4398
4554
  model: ''
4399
4555
  };
4556
+ let usageRecorded = false;
4557
+
4558
+ function recordUsageIfReady() {
4559
+ if (usageRecorded) {
4560
+ return false;
4561
+ }
4562
+
4563
+ const result = publishUsageLog({
4564
+ source: 'opencode',
4565
+ metadata,
4566
+ model: tokenData.model,
4567
+ tokens: {
4568
+ input: tokenData.inputTokens,
4569
+ output: tokenData.outputTokens,
4570
+ cached: tokenData.cachedTokens,
4571
+ reasoning: tokenData.reasoningTokens,
4572
+ total: tokenData.totalTokens
4573
+ },
4574
+ calculateCost,
4575
+ broadcastLog,
4576
+ recordRequest: recordOpenCodeRequest,
4577
+ recordSuccess,
4578
+ allowBroadcast: !isResponseClosed
4579
+ });
4580
+
4581
+ if (!result) {
4582
+ return false;
4583
+ }
4584
+
4585
+ usageRecorded = true;
4586
+ return true;
4587
+ }
4400
4588
 
4401
4589
  proxyRes.on('data', (chunk) => {
4402
4590
  // 如果响应已关闭,停止处理
@@ -4462,7 +4650,10 @@ async function startOpenCodeProxyServer(options = {}) {
4462
4650
  // 兼容 Responses API 和 Chat Completions API
4463
4651
  tokenData.inputTokens = parsed.usage.input_tokens || parsed.usage.prompt_tokens || 0;
4464
4652
  tokenData.outputTokens = parsed.usage.output_tokens || parsed.usage.completion_tokens || 0;
4653
+ tokenData.totalTokens = parsed.usage.total_tokens || (tokenData.inputTokens + tokenData.outputTokens);
4465
4654
  }
4655
+
4656
+ recordUsageIfReady();
4466
4657
  } catch (err) {
4467
4658
  // 忽略解析错误
4468
4659
  }
@@ -4482,71 +4673,14 @@ async function startOpenCodeProxyServer(options = {}) {
4482
4673
  // 兼容两种格式
4483
4674
  tokenData.inputTokens = parsed.usage.input_tokens || parsed.usage.prompt_tokens || 0;
4484
4675
  tokenData.outputTokens = parsed.usage.output_tokens || parsed.usage.completion_tokens || 0;
4676
+ tokenData.totalTokens = parsed.usage.total_tokens || (tokenData.inputTokens + tokenData.outputTokens);
4485
4677
  }
4486
4678
  } catch (err) {
4487
4679
  // 忽略解析错误
4488
4680
  }
4489
4681
  }
4490
4682
 
4491
- // 只有当有 token 数据时才记录
4492
- if (tokenData.inputTokens > 0 || tokenData.outputTokens > 0) {
4493
- const now = new Date();
4494
- const time = now.toLocaleTimeString('zh-CN', {
4495
- hour12: false,
4496
- hour: '2-digit',
4497
- minute: '2-digit',
4498
- second: '2-digit'
4499
- });
4500
-
4501
- // 记录统计数据(先计算)
4502
- const tokens = {
4503
- input: tokenData.inputTokens,
4504
- output: tokenData.outputTokens,
4505
- total: tokenData.inputTokens + tokenData.outputTokens
4506
- };
4507
- const cost = calculateCost(tokenData.model, tokens);
4508
-
4509
- // 广播日志(仅当响应仍然开放时)
4510
- if (!isResponseClosed) {
4511
- broadcastLog({
4512
- type: 'log',
4513
- id: metadata.id,
4514
- time: time,
4515
- channel: metadata.channel,
4516
- model: tokenData.model,
4517
- inputTokens: tokenData.inputTokens,
4518
- outputTokens: tokenData.outputTokens,
4519
- cachedTokens: tokenData.cachedTokens,
4520
- reasoningTokens: tokenData.reasoningTokens,
4521
- totalTokens: tokenData.totalTokens,
4522
- cost: cost,
4523
- source: 'opencode'
4524
- });
4525
- }
4526
-
4527
- const duration = Date.now() - metadata.startTime;
4528
-
4529
- recordOpenCodeRequest({
4530
- id: metadata.id,
4531
- timestamp: new Date(metadata.startTime).toISOString(),
4532
- toolType: 'opencode',
4533
- channel: metadata.channel,
4534
- channelId: metadata.channelId,
4535
- model: tokenData.model,
4536
- tokens: {
4537
- input: tokenData.inputTokens,
4538
- output: tokenData.outputTokens,
4539
- reasoning: tokenData.reasoningTokens,
4540
- cached: tokenData.cachedTokens,
4541
- total: tokens.total
4542
- },
4543
- duration: duration,
4544
- success: true,
4545
- cost: cost
4546
- });
4547
-
4548
- recordSuccess(metadata.channelId, 'opencode');
4549
- }
4683
+ recordUsageIfReady();
4550
4684
 
4551
4685
  if (!isResponseClosed) {
4552
4686
  requestMetadata.delete(req);
@@ -4560,6 +4694,15 @@ async function startOpenCodeProxyServer(options = {}) {
4560
4694
  }
4561
4695
  isResponseClosed = true;
4562
4696
  recordFailure(metadata.channelId, 'opencode', err);
4697
+ publishFailureLog({
4698
+ source: 'opencode',
4699
+ metadata,
4700
+ message: err.message,
4701
+ error: err,
4702
+ statusCode: proxyRes.statusCode,
4703
+ stage: 'response_stream',
4704
+ broadcastLog
4705
+ });
4563
4706
  requestMetadata.delete(req);
4564
4707
  });
4565
4708
  });
@@ -4572,6 +4715,19 @@ async function startOpenCodeProxyServer(options = {}) {
4572
4715
  releaseChannel(req.selectedChannel.id, 'opencode');
4573
4716
  broadcastSchedulerState('opencode', getSchedulerState('opencode'));
4574
4717
  }
4718
+ publishFailureLog({
4719
+ source: 'opencode',
4720
+ metadata: (req && requestMetadata.get(req)) || {
4721
+ channel: req?.selectedChannel?.name,
4722
+ channelId: req?.selectedChannel?.id,
4723
+ model: req?.body?.model
4724
+ },
4725
+ message: err.message,
4726
+ error: err,
4727
+ statusCode: 502,
4728
+ stage: 'proxy',
4729
+ broadcastLog
4730
+ });
4575
4731
  if (res && !res.headersSent) {
4576
4732
  res.status(502).json({
4577
4733
  error: {