coding-tool-x 3.5.11 → 3.5.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +11 -1
- package/dist/web/assets/{Analytics-Bw-0r4Ys.js → Analytics-B9Shk-uq.js} +1 -1
- package/dist/web/assets/{ConfigTemplates-C6QRIeDj.js → ConfigTemplates-Dlkn-_zW.js} +1 -1
- package/dist/web/assets/Home-AGf32LvJ.js +1 -0
- package/dist/web/assets/Home-BeGkC4bb.css +1 -0
- package/dist/web/assets/{PluginManager-Ce-mws6Q.js → PluginManager-BnAjHN4u.js} +1 -1
- package/dist/web/assets/{ProjectList-9Z7FFCIT.js → ProjectList-7YV_zk9L.js} +1 -1
- package/dist/web/assets/{SessionList-CyETQcs8.js → SessionList-D0un-UKS.js} +1 -1
- package/dist/web/assets/{SkillManager-DSUcP1se.js → SkillManager-DhYBmbtd.js} +1 -1
- package/dist/web/assets/{WorkspaceManager-Cvbc11bJ.js → WorkspaceManager-JOZIPAGo.js} +1 -1
- package/dist/web/assets/icons-QFXzEgqM.js +1 -0
- package/dist/web/assets/index-DM3yF1Oo.js +2 -0
- package/dist/web/assets/{index-BS7aplYp.css → index-dzKgnRhx.css} +1 -1
- package/dist/web/index.html +4 -4
- package/package.json +2 -1
- package/src/commands/daemon.js +6 -2
- package/src/commands/ui.js +8 -3
- package/src/index.js +2 -0
- package/src/server/api/hooks.js +18 -1
- package/src/server/codex-proxy-server.js +22 -6
- package/src/server/gemini-proxy-server.js +3 -1
- package/src/server/index.js +37 -5
- package/src/server/opencode-proxy-server.js +39 -14
- package/src/server/proxy-server.js +3 -1
- package/src/server/services/base/proxy-utils.js +41 -0
- package/src/server/services/base/response-usage-parser.js +137 -59
- package/src/server/services/codex-statistics-service.js +1 -0
- package/src/server/services/gemini-statistics-service.js +1 -0
- package/src/server/services/https-cert.js +202 -0
- package/src/server/services/notification-hooks.js +138 -6
- package/src/server/services/opencode-gateway-converter.js +3 -0
- package/src/server/services/opencode-statistics-service.js +1 -0
- package/src/server/services/proxy-log-helper.js +44 -12
- package/src/server/services/statistics-service.js +73 -28
- package/src/server/services/web-build.js +172 -0
- package/src/server/websocket-server.js +18 -1
- package/dist/web/assets/Home-BQxQ1LhR.css +0 -1
- package/dist/web/assets/Home-B_Z9ythw.js +0 -1
- package/dist/web/assets/icons-lKCTySl7.js +0 -1
- package/dist/web/assets/index-CTeg1xG-.js +0 -2
|
@@ -8,6 +8,128 @@
|
|
|
8
8
|
* 并确保模型重定向后仍能正确解析不同格式的响应。
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
function readNumericField(source, keys = []) {
|
|
12
|
+
for (const key of keys) {
|
|
13
|
+
if (!source || source[key] === undefined || source[key] === null) {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
const value = Number(source[key]);
|
|
17
|
+
if (Number.isFinite(value)) {
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readNestedNumericField(source, pathVariants = []) {
|
|
25
|
+
for (const path of pathVariants) {
|
|
26
|
+
let current = source;
|
|
27
|
+
let missing = false;
|
|
28
|
+
for (const segment of path) {
|
|
29
|
+
if (!current || typeof current !== 'object' || !(segment in current)) {
|
|
30
|
+
missing = true;
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
current = current[segment];
|
|
34
|
+
}
|
|
35
|
+
if (missing || current === undefined || current === null) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const value = Number(current);
|
|
39
|
+
if (Number.isFinite(value)) {
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseUsageObject(rawUsage) {
|
|
47
|
+
if (!rawUsage || typeof rawUsage !== 'object') {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const tokens = {};
|
|
52
|
+
const hasAnthropicCacheFields =
|
|
53
|
+
rawUsage.cache_creation_input_tokens !== undefined ||
|
|
54
|
+
rawUsage.cache_read_input_tokens !== undefined ||
|
|
55
|
+
rawUsage.cacheCreationInputTokens !== undefined ||
|
|
56
|
+
rawUsage.cacheReadInputTokens !== undefined;
|
|
57
|
+
const hasOpenAiPromptFields =
|
|
58
|
+
rawUsage.prompt_tokens !== undefined ||
|
|
59
|
+
rawUsage.promptTokens !== undefined ||
|
|
60
|
+
rawUsage.prompt_tokens_details !== undefined ||
|
|
61
|
+
rawUsage.promptTokensDetails !== undefined;
|
|
62
|
+
const hasOpenAiResponseFields =
|
|
63
|
+
rawUsage.input_tokens_details !== undefined ||
|
|
64
|
+
rawUsage.inputTokensDetails !== undefined ||
|
|
65
|
+
rawUsage.output_tokens_details !== undefined ||
|
|
66
|
+
rawUsage.outputTokensDetails !== undefined;
|
|
67
|
+
const hasOpenAiCompatibilityFields =
|
|
68
|
+
hasOpenAiPromptFields ||
|
|
69
|
+
hasOpenAiResponseFields ||
|
|
70
|
+
rawUsage.cached_tokens !== undefined ||
|
|
71
|
+
rawUsage.cachedTokens !== undefined ||
|
|
72
|
+
rawUsage.reasoning_tokens !== undefined ||
|
|
73
|
+
rawUsage.reasoningTokens !== undefined ||
|
|
74
|
+
rawUsage.completion_tokens_details !== undefined ||
|
|
75
|
+
rawUsage.completionTokensDetails !== undefined;
|
|
76
|
+
|
|
77
|
+
const input = readNumericField(rawUsage, ['input_tokens', 'prompt_tokens', 'inputTokens', 'promptTokens']);
|
|
78
|
+
if (input !== undefined) tokens.input = input;
|
|
79
|
+
|
|
80
|
+
const output = readNumericField(rawUsage, ['output_tokens', 'completion_tokens', 'outputTokens', 'completionTokens']);
|
|
81
|
+
if (output !== undefined) tokens.output = output;
|
|
82
|
+
|
|
83
|
+
const total = readNumericField(rawUsage, ['total_tokens', 'totalTokens']);
|
|
84
|
+
if (total !== undefined) tokens.total = total;
|
|
85
|
+
|
|
86
|
+
const cacheCreation = readNumericField(rawUsage, ['cache_creation_input_tokens', 'cacheCreationInputTokens'])
|
|
87
|
+
?? readNestedNumericField(rawUsage, [
|
|
88
|
+
['prompt_tokens_details', 'cache_creation_input_tokens'],
|
|
89
|
+
['promptTokensDetails', 'cacheCreationInputTokens']
|
|
90
|
+
]);
|
|
91
|
+
if (cacheCreation !== undefined) tokens.cacheCreation = cacheCreation;
|
|
92
|
+
|
|
93
|
+
const cacheRead = readNumericField(rawUsage, ['cache_read_input_tokens', 'cacheReadInputTokens']);
|
|
94
|
+
if (cacheRead !== undefined) tokens.cacheRead = cacheRead;
|
|
95
|
+
|
|
96
|
+
const cached = readNumericField(rawUsage, ['cached_tokens', 'cachedTokens'])
|
|
97
|
+
?? readNestedNumericField(rawUsage, [
|
|
98
|
+
['input_tokens_details', 'cached_tokens'],
|
|
99
|
+
['prompt_tokens_details', 'cached_tokens'],
|
|
100
|
+
['inputTokensDetails', 'cachedTokens'],
|
|
101
|
+
['promptTokensDetails', 'cachedTokens']
|
|
102
|
+
]);
|
|
103
|
+
if (cached !== undefined) tokens.cached = cached;
|
|
104
|
+
|
|
105
|
+
const reasoning = readNumericField(rawUsage, ['reasoning_tokens', 'reasoningTokens'])
|
|
106
|
+
?? readNestedNumericField(rawUsage, [
|
|
107
|
+
['completion_tokens_details', 'reasoning_tokens'],
|
|
108
|
+
['output_tokens_details', 'reasoning_tokens'],
|
|
109
|
+
['completionTokensDetails', 'reasoningTokens'],
|
|
110
|
+
['outputTokensDetails', 'reasoningTokens']
|
|
111
|
+
]);
|
|
112
|
+
if (reasoning !== undefined) tokens.reasoning = reasoning;
|
|
113
|
+
|
|
114
|
+
if (tokens.total === undefined && tokens.input !== undefined && tokens.output !== undefined && !hasAnthropicCacheFields) {
|
|
115
|
+
tokens.total = tokens.input + tokens.output;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// OpenAI/OpenAI-compatible usage commonly reports prompt/input totals that
|
|
119
|
+
// already include cache hits. Align with OpenCode's provider contract by
|
|
120
|
+
// moving cached prompt tokens into a dedicated field and keeping input as the
|
|
121
|
+
// net uncached prompt tokens.
|
|
122
|
+
if (!hasAnthropicCacheFields && hasOpenAiCompatibilityFields && tokens.cached !== undefined && tokens.input !== undefined) {
|
|
123
|
+
tokens.input = Math.max(tokens.input - tokens.cached, 0);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (Object.keys(tokens).length === 0) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return tokens;
|
|
131
|
+
}
|
|
132
|
+
|
|
11
133
|
/**
|
|
12
134
|
* 从单个 SSE 事件的 parsed JSON 中提取 model 和 token 信息。
|
|
13
135
|
* 自动检测 Claude / OpenAI / Gemini 格式。
|
|
@@ -31,82 +153,35 @@ function parseSSEUsage(parsed, eventType) {
|
|
|
31
153
|
if (eventType === 'message_start' && parsed.message && parsed.message.model) {
|
|
32
154
|
model = parsed.message.model;
|
|
33
155
|
}
|
|
156
|
+
if (!tokens && parsed.message && parsed.message.usage) {
|
|
157
|
+
tokens = parseUsageObject(parsed.message.usage);
|
|
158
|
+
}
|
|
34
159
|
if (eventType === 'message_stop') {
|
|
35
160
|
isDone = true;
|
|
36
161
|
}
|
|
37
162
|
|
|
38
163
|
// === OpenAI Responses API 格式 ===
|
|
39
164
|
// data: {"type": "response.completed", "response": {"model", "usage": {...}}}
|
|
40
|
-
if (parsed.type === 'response.completed' && parsed.response) {
|
|
165
|
+
if ((parsed.type === 'response.completed' || parsed.type === 'response.incomplete') && parsed.response) {
|
|
41
166
|
if (parsed.response.model) {
|
|
42
167
|
model = parsed.response.model;
|
|
43
168
|
}
|
|
44
169
|
if (parsed.response.usage) {
|
|
45
|
-
tokens =
|
|
46
|
-
input: parsed.response.usage.input_tokens || 0,
|
|
47
|
-
output: parsed.response.usage.output_tokens || 0,
|
|
48
|
-
total: parsed.response.usage.total_tokens || 0,
|
|
49
|
-
};
|
|
50
|
-
if (parsed.response.usage.input_tokens_details &&
|
|
51
|
-
parsed.response.usage.input_tokens_details.cached_tokens !== undefined) {
|
|
52
|
-
tokens.cached = parsed.response.usage.input_tokens_details.cached_tokens;
|
|
53
|
-
}
|
|
54
|
-
if (parsed.response.usage.output_tokens_details &&
|
|
55
|
-
parsed.response.usage.output_tokens_details.reasoning_tokens !== undefined) {
|
|
56
|
-
tokens.reasoning = parsed.response.usage.output_tokens_details.reasoning_tokens;
|
|
57
|
-
}
|
|
170
|
+
tokens = parseUsageObject(parsed.response.usage);
|
|
58
171
|
}
|
|
59
172
|
isDone = true;
|
|
60
173
|
}
|
|
61
174
|
|
|
62
175
|
// === parsed.usage(Claude 原生 + OpenAI Chat Completions 共用) ===
|
|
63
176
|
if (!tokens && parsed.usage) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
// Claude 格式字段
|
|
67
|
-
if (parsed.usage.input_tokens !== undefined) {
|
|
68
|
-
t.input = parsed.usage.input_tokens;
|
|
69
|
-
}
|
|
70
|
-
if (parsed.usage.output_tokens !== undefined) {
|
|
71
|
-
t.output = parsed.usage.output_tokens;
|
|
72
|
-
}
|
|
73
|
-
if (parsed.usage.cache_creation_input_tokens !== undefined) {
|
|
74
|
-
t.cacheCreation = parsed.usage.cache_creation_input_tokens;
|
|
75
|
-
}
|
|
76
|
-
if (parsed.usage.cache_read_input_tokens !== undefined) {
|
|
77
|
-
t.cacheRead = parsed.usage.cache_read_input_tokens;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// OpenAI Chat Completions 格式字段(fallback)
|
|
81
|
-
if (t.input === undefined && parsed.usage.prompt_tokens !== undefined) {
|
|
82
|
-
t.input = parsed.usage.prompt_tokens;
|
|
83
|
-
}
|
|
84
|
-
if (t.output === undefined && parsed.usage.completion_tokens !== undefined) {
|
|
85
|
-
t.output = parsed.usage.completion_tokens;
|
|
86
|
-
}
|
|
87
|
-
if (parsed.usage.total_tokens !== undefined) {
|
|
88
|
-
t.total = parsed.usage.total_tokens;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// OpenAI detailed breakdowns
|
|
92
|
-
if (parsed.usage.input_tokens_details &&
|
|
93
|
-
parsed.usage.input_tokens_details.cached_tokens !== undefined) {
|
|
94
|
-
t.cached = parsed.usage.input_tokens_details.cached_tokens;
|
|
95
|
-
}
|
|
96
|
-
if (parsed.usage.output_tokens_details &&
|
|
97
|
-
parsed.usage.output_tokens_details.reasoning_tokens !== undefined) {
|
|
98
|
-
t.reasoning = parsed.usage.output_tokens_details.reasoning_tokens;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Gemini cache in OpenAI compat mode
|
|
102
|
-
if (parsed.usage.prompt_tokens_details &&
|
|
103
|
-
parsed.usage.prompt_tokens_details.cached_tokens !== undefined) {
|
|
104
|
-
t.cached = parsed.usage.prompt_tokens_details.cached_tokens;
|
|
105
|
-
}
|
|
177
|
+
tokens = parseUsageObject(parsed.usage);
|
|
178
|
+
}
|
|
106
179
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
180
|
+
if (!tokens && parsed.tokenUsage) {
|
|
181
|
+
tokens = parseUsageObject(parsed.tokenUsage);
|
|
182
|
+
}
|
|
183
|
+
if (!tokens && parsed.token_usage) {
|
|
184
|
+
tokens = parseUsageObject(parsed.token_usage);
|
|
110
185
|
}
|
|
111
186
|
|
|
112
187
|
// === Gemini Native 格式 ===
|
|
@@ -120,6 +195,9 @@ function parseSSEUsage(parsed, eventType) {
|
|
|
120
195
|
if (parsed.usageMetadata.cachedContentTokenCount) {
|
|
121
196
|
tokens.cached = parsed.usageMetadata.cachedContentTokenCount;
|
|
122
197
|
}
|
|
198
|
+
if (parsed.usageMetadata.thoughtsTokenCount) {
|
|
199
|
+
tokens.reasoning = parsed.usageMetadata.thoughtsTokenCount;
|
|
200
|
+
}
|
|
123
201
|
}
|
|
124
202
|
|
|
125
203
|
// === 通用 model fallback ===
|
|
@@ -110,6 +110,7 @@ function recordRequest(requestData = {}) {
|
|
|
110
110
|
input: normalizedTokens.input,
|
|
111
111
|
output: normalizedTokens.output,
|
|
112
112
|
reasoning: normalizedTokens.reasoning,
|
|
113
|
+
cached: normalizedTokens.cached,
|
|
113
114
|
cacheCreation: normalizedTokens.cacheCreation,
|
|
114
115
|
cacheRead: normalizedTokens.cacheRead,
|
|
115
116
|
total: normalizedTokens.total
|
|
@@ -108,6 +108,7 @@ function recordRequest(requestData = {}) {
|
|
|
108
108
|
tokens: {
|
|
109
109
|
input: normalizedTokens.input,
|
|
110
110
|
output: normalizedTokens.output,
|
|
111
|
+
cached: normalizedTokens.cached,
|
|
111
112
|
cacheCreation: normalizedTokens.cacheCreation,
|
|
112
113
|
cacheRead: normalizedTokens.cacheRead,
|
|
113
114
|
total: normalizedTokens.total
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execFileSync } = require('child_process');
|
|
6
|
+
const { randomBytes } = require('crypto');
|
|
7
|
+
const { PATHS } = require('../../config/paths');
|
|
8
|
+
|
|
9
|
+
const HTTPS_DIR = path.join(PATHS.config, 'https');
|
|
10
|
+
const HTTPS_KEY_FILE = path.join(HTTPS_DIR, 'localhost-key.pem');
|
|
11
|
+
const HTTPS_CERT_FILE = path.join(HTTPS_DIR, 'localhost-cert.pem');
|
|
12
|
+
const HTTPS_PFX_FILE = path.join(HTTPS_DIR, 'localhost.pfx');
|
|
13
|
+
const HTTPS_META_FILE = path.join(HTTPS_DIR, 'meta.json');
|
|
14
|
+
|
|
15
|
+
function ensureHttpsDir() {
|
|
16
|
+
if (!fs.existsSync(HTTPS_DIR)) {
|
|
17
|
+
fs.mkdirSync(HTTPS_DIR, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readMeta() {
|
|
22
|
+
if (!fs.existsSync(HTTPS_META_FILE)) {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(fs.readFileSync(HTTPS_META_FILE, 'utf8'));
|
|
28
|
+
} catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function writeMeta(meta) {
|
|
34
|
+
ensureHttpsDir();
|
|
35
|
+
fs.writeFileSync(HTTPS_META_FILE, JSON.stringify(meta, null, 2), 'utf8');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function resolveOpenSslCommand() {
|
|
39
|
+
const commands = process.platform === 'win32'
|
|
40
|
+
? ['openssl.exe', 'openssl']
|
|
41
|
+
: ['openssl'];
|
|
42
|
+
|
|
43
|
+
for (const command of commands) {
|
|
44
|
+
try {
|
|
45
|
+
execFileSync(command, ['version'], { stdio: 'ignore' });
|
|
46
|
+
return command;
|
|
47
|
+
} catch {
|
|
48
|
+
// Try next candidate.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildOpenSslConfig() {
|
|
56
|
+
return [
|
|
57
|
+
'[req]',
|
|
58
|
+
'default_bits = 2048',
|
|
59
|
+
'distinguished_name = req_distinguished_name',
|
|
60
|
+
'prompt = no',
|
|
61
|
+
'x509_extensions = v3_req',
|
|
62
|
+
'',
|
|
63
|
+
'[req_distinguished_name]',
|
|
64
|
+
'CN = localhost',
|
|
65
|
+
'',
|
|
66
|
+
'[v3_req]',
|
|
67
|
+
'subjectAltName = @alt_names',
|
|
68
|
+
'keyUsage = digitalSignature, keyEncipherment',
|
|
69
|
+
'extendedKeyUsage = serverAuth',
|
|
70
|
+
'',
|
|
71
|
+
'[alt_names]',
|
|
72
|
+
'DNS.1 = localhost',
|
|
73
|
+
'IP.1 = 127.0.0.1',
|
|
74
|
+
'IP.2 = ::1'
|
|
75
|
+
].join('\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function generatePemCertificateWithOpenSsl(command) {
|
|
79
|
+
ensureHttpsDir();
|
|
80
|
+
const configPath = path.join(HTTPS_DIR, 'openssl.cnf');
|
|
81
|
+
fs.writeFileSync(configPath, buildOpenSslConfig(), 'utf8');
|
|
82
|
+
|
|
83
|
+
execFileSync(command, [
|
|
84
|
+
'req',
|
|
85
|
+
'-x509',
|
|
86
|
+
'-nodes',
|
|
87
|
+
'-newkey',
|
|
88
|
+
'rsa:2048',
|
|
89
|
+
'-sha256',
|
|
90
|
+
'-days',
|
|
91
|
+
'825',
|
|
92
|
+
'-keyout',
|
|
93
|
+
HTTPS_KEY_FILE,
|
|
94
|
+
'-out',
|
|
95
|
+
HTTPS_CERT_FILE,
|
|
96
|
+
'-config',
|
|
97
|
+
configPath
|
|
98
|
+
], {
|
|
99
|
+
stdio: 'ignore'
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
writeMeta({
|
|
103
|
+
generator: 'openssl',
|
|
104
|
+
createdAt: new Date().toISOString()
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildWindowsPfxGenerationScript(outputPath, passphrase) {
|
|
109
|
+
return [
|
|
110
|
+
'$ErrorActionPreference = "Stop"',
|
|
111
|
+
'$rsa = [System.Security.Cryptography.RSA]::Create(2048)',
|
|
112
|
+
'$req = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new(',
|
|
113
|
+
' "CN=localhost",',
|
|
114
|
+
' $rsa,',
|
|
115
|
+
' [System.Security.Cryptography.HashAlgorithmName]::SHA256,',
|
|
116
|
+
' [System.Security.Cryptography.RSASignaturePadding]::Pkcs1',
|
|
117
|
+
')',
|
|
118
|
+
'$san = [System.Security.Cryptography.X509Certificates.SubjectAlternativeNameBuilder]::new()',
|
|
119
|
+
'$san.AddDnsName("localhost")',
|
|
120
|
+
'$san.AddIpAddress([System.Net.IPAddress]::Parse("127.0.0.1"))',
|
|
121
|
+
'$req.CertificateExtensions.Add($san.Build($false))',
|
|
122
|
+
'$req.CertificateExtensions.Add([System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($false, $false, 0, $true))',
|
|
123
|
+
'$req.CertificateExtensions.Add([System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new([System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature -bor [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyEncipherment, $true))',
|
|
124
|
+
'$req.CertificateExtensions.Add([System.Security.Cryptography.X509Certificates.X509SubjectKeyIdentifierExtension]::new($req.PublicKey, $false))',
|
|
125
|
+
'$notBefore = [DateTimeOffset]::UtcNow.AddDays(-1)',
|
|
126
|
+
'$notAfter = $notBefore.AddYears(2)',
|
|
127
|
+
'$cert = $req.CreateSelfSigned($notBefore, $notAfter)',
|
|
128
|
+
`$bytes = $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, ${JSON.stringify(passphrase)})`,
|
|
129
|
+
`[System.IO.Directory]::CreateDirectory(${JSON.stringify(HTTPS_DIR)}) | Out-Null`,
|
|
130
|
+
`[System.IO.File]::WriteAllBytes(${JSON.stringify(outputPath)}, $bytes)`,
|
|
131
|
+
'$cert.Dispose()',
|
|
132
|
+
'$rsa.Dispose()'
|
|
133
|
+
].join('; ');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function generatePfxCertificateOnWindows() {
|
|
137
|
+
ensureHttpsDir();
|
|
138
|
+
const passphrase = randomBytes(18).toString('base64url');
|
|
139
|
+
|
|
140
|
+
execFileSync('powershell', [
|
|
141
|
+
'-NoProfile',
|
|
142
|
+
'-Command',
|
|
143
|
+
buildWindowsPfxGenerationScript(HTTPS_PFX_FILE, passphrase)
|
|
144
|
+
], {
|
|
145
|
+
stdio: 'ignore',
|
|
146
|
+
windowsHide: true
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
writeMeta({
|
|
150
|
+
generator: 'powershell-pfx',
|
|
151
|
+
passphrase,
|
|
152
|
+
createdAt: new Date().toISOString()
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function ensureHttpsCredentials() {
|
|
157
|
+
ensureHttpsDir();
|
|
158
|
+
|
|
159
|
+
if (fs.existsSync(HTTPS_KEY_FILE) && fs.existsSync(HTTPS_CERT_FILE)) {
|
|
160
|
+
return {
|
|
161
|
+
key: fs.readFileSync(HTTPS_KEY_FILE),
|
|
162
|
+
cert: fs.readFileSync(HTTPS_CERT_FILE)
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const meta = readMeta();
|
|
167
|
+
if (fs.existsSync(HTTPS_PFX_FILE) && meta.passphrase) {
|
|
168
|
+
return {
|
|
169
|
+
pfx: fs.readFileSync(HTTPS_PFX_FILE),
|
|
170
|
+
passphrase: meta.passphrase
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const openSslCommand = resolveOpenSslCommand();
|
|
175
|
+
if (openSslCommand) {
|
|
176
|
+
generatePemCertificateWithOpenSsl(openSslCommand);
|
|
177
|
+
return {
|
|
178
|
+
key: fs.readFileSync(HTTPS_KEY_FILE),
|
|
179
|
+
cert: fs.readFileSync(HTTPS_CERT_FILE)
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (process.platform === 'win32') {
|
|
184
|
+
generatePfxCertificateOnWindows();
|
|
185
|
+
const nextMeta = readMeta();
|
|
186
|
+
return {
|
|
187
|
+
pfx: fs.readFileSync(HTTPS_PFX_FILE),
|
|
188
|
+
passphrase: nextMeta.passphrase
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
throw new Error('无法生成 HTTPS 本地证书,请先安装 openssl 后重试');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
module.exports = {
|
|
196
|
+
ensureHttpsCredentials,
|
|
197
|
+
_test: {
|
|
198
|
+
buildOpenSslConfig,
|
|
199
|
+
buildWindowsPfxGenerationScript,
|
|
200
|
+
resolveOpenSslCommand
|
|
201
|
+
}
|
|
202
|
+
};
|
|
@@ -13,7 +13,7 @@ const MANAGED_HOOK_NAME = 'coding-tool-notify';
|
|
|
13
13
|
const MANAGED_OPENCODE_PLUGIN_FILE = 'coding-tool-notify.js';
|
|
14
14
|
|
|
15
15
|
function normalizeType(type) {
|
|
16
|
-
return type === 'dialog'
|
|
16
|
+
return type === 'dialog' || type === 'browser' ? type : 'notification';
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
function ensureParentDir(filePath) {
|
|
@@ -118,9 +118,9 @@ function removeNotifyScript() {
|
|
|
118
118
|
function parseManagedType(input) {
|
|
119
119
|
const value = String(input || '');
|
|
120
120
|
const matches = [
|
|
121
|
-
value.match(/--cc-notify-type=(dialog|notification)/i),
|
|
122
|
-
value.match(/--mode=(dialog|notification)/i),
|
|
123
|
-
value.match(/MODE\s*=\s*["'](dialog|notification)["']/i)
|
|
121
|
+
value.match(/--cc-notify-type=(dialog|notification|browser)/i),
|
|
122
|
+
value.match(/--mode=(dialog|notification|browser)/i),
|
|
123
|
+
value.match(/MODE\s*=\s*["'](dialog|notification|browser)["']/i)
|
|
124
124
|
];
|
|
125
125
|
|
|
126
126
|
for (const match of matches) {
|
|
@@ -446,6 +446,7 @@ const { execSync, execFileSync } = require('child_process')
|
|
|
446
446
|
|
|
447
447
|
const FEISHU_ENABLED = ${feishuEnabled ? 'true' : 'false'}
|
|
448
448
|
const FEISHU_WEBHOOK_URL = ${JSON.stringify(feishuEnabled ? feishu.webhookUrl : '')}
|
|
449
|
+
const CONFIG_FILE = ${JSON.stringify(PATHS.configFile)}
|
|
449
450
|
|
|
450
451
|
function readArg(name) {
|
|
451
452
|
const prefix = \`\${name}=\`
|
|
@@ -507,10 +508,77 @@ function resolveMessage(source, eventType, payload) {
|
|
|
507
508
|
return 'Claude Code 任务已完成 | 等待交互'
|
|
508
509
|
}
|
|
509
510
|
|
|
511
|
+
function readWebUiPort() {
|
|
512
|
+
try {
|
|
513
|
+
const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'))
|
|
514
|
+
const port = parseInt(config?.ports?.webUI, 10)
|
|
515
|
+
return Number.isFinite(port) ? port : 19999
|
|
516
|
+
} catch (error) {
|
|
517
|
+
return 19999
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function postBrowserNotification(source, eventType, message) {
|
|
522
|
+
const payload = JSON.stringify({
|
|
523
|
+
source,
|
|
524
|
+
eventType,
|
|
525
|
+
message
|
|
526
|
+
})
|
|
527
|
+
const port = readWebUiPort()
|
|
528
|
+
const attempts = [
|
|
529
|
+
{ module: https, options: { rejectUnauthorized: false } },
|
|
530
|
+
{ module: http, options: {} }
|
|
531
|
+
]
|
|
532
|
+
|
|
533
|
+
return new Promise((resolve) => {
|
|
534
|
+
const runAttempt = (index) => {
|
|
535
|
+
const current = attempts[index]
|
|
536
|
+
if (!current) {
|
|
537
|
+
resolve()
|
|
538
|
+
return
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const request = current.module.request({
|
|
542
|
+
hostname: '127.0.0.1',
|
|
543
|
+
port,
|
|
544
|
+
path: '/api/hooks/browser-event',
|
|
545
|
+
method: 'POST',
|
|
546
|
+
timeout: 1500,
|
|
547
|
+
headers: {
|
|
548
|
+
'Content-Type': 'application/json',
|
|
549
|
+
'Content-Length': Buffer.byteLength(payload)
|
|
550
|
+
},
|
|
551
|
+
...current.options
|
|
552
|
+
}, (response) => {
|
|
553
|
+
response.resume()
|
|
554
|
+
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
555
|
+
resolve()
|
|
556
|
+
return
|
|
557
|
+
}
|
|
558
|
+
runAttempt(index + 1)
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
request.on('error', () => runAttempt(index + 1))
|
|
562
|
+
request.on('timeout', () => {
|
|
563
|
+
request.destroy()
|
|
564
|
+
runAttempt(index + 1)
|
|
565
|
+
})
|
|
566
|
+
request.write(payload)
|
|
567
|
+
request.end()
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
runAttempt(0)
|
|
571
|
+
})
|
|
572
|
+
}
|
|
573
|
+
|
|
510
574
|
function notify(mode, message) {
|
|
511
575
|
const title = 'Coding Tool'
|
|
512
576
|
const platform = os.platform()
|
|
513
577
|
|
|
578
|
+
if (mode === 'browser') {
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
|
|
514
582
|
try {
|
|
515
583
|
if (platform === 'darwin') {
|
|
516
584
|
if (mode === 'dialog') {
|
|
@@ -635,7 +703,11 @@ function sendFeishu(message, source) {
|
|
|
635
703
|
const payload = readOptionalPayload()
|
|
636
704
|
const message = resolveMessage(source, eventType, payload)
|
|
637
705
|
|
|
638
|
-
|
|
706
|
+
if (mode === 'browser') {
|
|
707
|
+
await postBrowserNotification(source, eventType, message)
|
|
708
|
+
} else {
|
|
709
|
+
notify(mode, message)
|
|
710
|
+
}
|
|
639
711
|
await sendFeishu(message, source)
|
|
640
712
|
})().catch(() => {
|
|
641
713
|
process.exit(0)
|
|
@@ -1183,6 +1255,55 @@ function normalizePlatformInput(platform = {}) {
|
|
|
1183
1255
|
};
|
|
1184
1256
|
}
|
|
1185
1257
|
|
|
1258
|
+
function resolveBrowserNotificationTitle(source = 'claude') {
|
|
1259
|
+
switch (String(source || '').toLowerCase()) {
|
|
1260
|
+
case 'codex':
|
|
1261
|
+
return 'Codex CLI';
|
|
1262
|
+
case 'gemini':
|
|
1263
|
+
return 'Gemini CLI';
|
|
1264
|
+
case 'opencode':
|
|
1265
|
+
return 'OpenCode';
|
|
1266
|
+
default:
|
|
1267
|
+
return 'Claude Code';
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
function resolveBrowserNotificationUrl(source = 'claude') {
|
|
1272
|
+
switch (String(source || '').toLowerCase()) {
|
|
1273
|
+
case 'codex':
|
|
1274
|
+
return '/codex';
|
|
1275
|
+
case 'gemini':
|
|
1276
|
+
return '/gemini';
|
|
1277
|
+
case 'opencode':
|
|
1278
|
+
return '/opencode';
|
|
1279
|
+
default:
|
|
1280
|
+
return '/claude';
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function emitBrowserNotification(input = {}) {
|
|
1285
|
+
const source = String(input.source || 'claude').toLowerCase();
|
|
1286
|
+
const message = String(input.message || '').trim();
|
|
1287
|
+
if (!message) {
|
|
1288
|
+
throw createValidationError('缺少浏览器通知内容');
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const payload = {
|
|
1292
|
+
type: 'browser-notification',
|
|
1293
|
+
id: input.id || `${source}-${Date.now()}`,
|
|
1294
|
+
source,
|
|
1295
|
+
eventType: String(input.eventType || '').trim(),
|
|
1296
|
+
title: String(input.title || resolveBrowserNotificationTitle(source)).trim(),
|
|
1297
|
+
message,
|
|
1298
|
+
url: String(input.url || resolveBrowserNotificationUrl(source)).trim(),
|
|
1299
|
+
timestamp: Date.now()
|
|
1300
|
+
};
|
|
1301
|
+
|
|
1302
|
+
const { broadcastBrowserNotification } = require('../websocket-server');
|
|
1303
|
+
broadcastBrowserNotification(payload);
|
|
1304
|
+
return payload;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1186
1307
|
function saveNotificationSettings(input = {}) {
|
|
1187
1308
|
const existingFeishu = getFeishuConfig();
|
|
1188
1309
|
const requestedWebhookUrl = String(input?.feishu?.webhookUrl || '').trim();
|
|
@@ -1220,7 +1341,7 @@ function saveNotificationSettings(input = {}) {
|
|
|
1220
1341
|
}
|
|
1221
1342
|
|
|
1222
1343
|
function parseNotifyTypeMarker(command) {
|
|
1223
|
-
const marker = String(command || '').match(/--cc-notify-type=(['"])?(dialog|notification)\1/i);
|
|
1344
|
+
const marker = String(command || '').match(/--cc-notify-type=(['"])?(dialog|notification|browser)\1/i);
|
|
1224
1345
|
return marker?.[2] ? normalizeType(marker[2].toLowerCase()) : null;
|
|
1225
1346
|
}
|
|
1226
1347
|
|
|
@@ -1566,6 +1687,15 @@ function testNotification({ type, testFeishu, webhookUrl } = {}) {
|
|
|
1566
1687
|
return sendFeishuTest(webhookUrl);
|
|
1567
1688
|
}
|
|
1568
1689
|
|
|
1690
|
+
if (normalizeType(type) === 'browser') {
|
|
1691
|
+
emitBrowserNotification({
|
|
1692
|
+
source: 'claude',
|
|
1693
|
+
title: 'coding-tool-x',
|
|
1694
|
+
message: '这是一条浏览器测试通知'
|
|
1695
|
+
});
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1569
1699
|
runSystemNotification(type || 'notification', '这是一条测试通知');
|
|
1570
1700
|
}
|
|
1571
1701
|
|
|
@@ -1576,6 +1706,7 @@ module.exports = {
|
|
|
1576
1706
|
saveNotificationSettings,
|
|
1577
1707
|
saveLegacyClaudeHookSettings,
|
|
1578
1708
|
testNotification,
|
|
1709
|
+
emitBrowserNotification,
|
|
1579
1710
|
initDefaultHooks,
|
|
1580
1711
|
syncManagedNotificationAssets,
|
|
1581
1712
|
getOpenCodeManagedPluginPath,
|
|
@@ -1604,6 +1735,7 @@ module.exports = {
|
|
|
1604
1735
|
getOpenCodeManagedPluginPath,
|
|
1605
1736
|
generateNotifyScript,
|
|
1606
1737
|
generateSystemNotificationCommand,
|
|
1738
|
+
emitBrowserNotification,
|
|
1607
1739
|
parseStopHookStatus,
|
|
1608
1740
|
shouldRepairStopHook
|
|
1609
1741
|
}
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* - OpenAI Chat Completions API (/v1/chat/completions)
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
const { ensureOpenAiStreamUsage } = require('./base/proxy-utils');
|
|
15
|
+
|
|
14
16
|
const SUPPORTED_SOURCE_TYPES = ['claude', 'codex', 'gemini'];
|
|
15
17
|
const SUPPORTED_TARGET_APIS = ['responses', 'chat.completions'];
|
|
16
18
|
|
|
@@ -554,6 +556,7 @@ function buildChatCompletionsRequest(normalized, options, warnings) {
|
|
|
554
556
|
if (chatTools.length > 0) request.tools = chatTools;
|
|
555
557
|
}
|
|
556
558
|
|
|
559
|
+
ensureOpenAiStreamUsage(request);
|
|
557
560
|
return request;
|
|
558
561
|
}
|
|
559
562
|
|
|
@@ -110,6 +110,7 @@ function recordRequest(requestData = {}) {
|
|
|
110
110
|
input: normalizedTokens.input,
|
|
111
111
|
output: normalizedTokens.output,
|
|
112
112
|
reasoning: normalizedTokens.reasoning,
|
|
113
|
+
cached: normalizedTokens.cached,
|
|
113
114
|
cacheCreation: normalizedTokens.cacheCreation,
|
|
114
115
|
cacheRead: normalizedTokens.cacheRead,
|
|
115
116
|
total: normalizedTokens.total
|