@kaitranntt/ccs 3.2.0 → 3.4.0

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.
@@ -0,0 +1,467 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const http = require('http');
5
+ const https = require('https');
6
+ const GlmtTransformer = require('./glmt-transformer');
7
+ const SSEParser = require('./sse-parser');
8
+ const DeltaAccumulator = require('./delta-accumulator');
9
+
10
+ /**
11
+ * GlmtProxy - Embedded HTTP proxy for GLM thinking support
12
+ *
13
+ * Architecture:
14
+ * - Intercepts Claude CLI → Z.AI calls
15
+ * - Transforms Anthropic format → OpenAI format
16
+ * - Converts reasoning_content → thinking blocks
17
+ * - Supports both streaming and buffered modes
18
+ *
19
+ * Lifecycle:
20
+ * - Spawned by bin/ccs.js when 'glmt' profile detected
21
+ * - Binds to 127.0.0.1:random_port (security + avoid conflicts)
22
+ * - Terminates when parent process exits
23
+ *
24
+ * Debugging:
25
+ * - Verbose: Pass --verbose to see request/response logs
26
+ * - Debug: Set CCS_DEBUG_LOG=1 to write logs to ~/.ccs/logs/
27
+ *
28
+ * Usage:
29
+ * const proxy = new GlmtProxy({ verbose: true });
30
+ * await proxy.start();
31
+ */
32
+ class GlmtProxy {
33
+ constructor(config = {}) {
34
+ this.transformer = new GlmtTransformer({ verbose: config.verbose });
35
+ // Use ANTHROPIC_BASE_URL from environment (set by settings.json) or fallback to Z.AI default
36
+ this.upstreamUrl = process.env.ANTHROPIC_BASE_URL || 'https://api.z.ai/api/coding/paas/v4/chat/completions';
37
+ this.server = null;
38
+ this.port = null;
39
+ this.verbose = config.verbose || false;
40
+ this.timeout = config.timeout || 120000; // 120s default
41
+ this.streamingEnabled = process.env.CCS_GLMT_STREAMING !== 'disabled';
42
+ this.forceStreaming = process.env.CCS_GLMT_STREAMING === 'force';
43
+ }
44
+
45
+ /**
46
+ * Start HTTP server on random port
47
+ * @returns {Promise<number>} Port number
48
+ */
49
+ async start() {
50
+ return new Promise((resolve, reject) => {
51
+ this.server = http.createServer((req, res) => {
52
+ this.handleRequest(req, res);
53
+ });
54
+
55
+ // Bind to 127.0.0.1:0 (random port for security + avoid conflicts)
56
+ this.server.listen(0, '127.0.0.1', () => {
57
+ this.port = this.server.address().port;
58
+ // Signal parent process
59
+ console.log(`PROXY_READY:${this.port}`);
60
+
61
+ // Info message (only show in verbose mode)
62
+ if (this.verbose) {
63
+ const mode = this.streamingEnabled ? 'streaming mode' : 'buffered mode';
64
+ console.error(`[glmt] Proxy listening on port ${this.port} (${mode})`);
65
+ }
66
+
67
+ // Debug mode notice
68
+ if (this.transformer.debugLog) {
69
+ console.error(`[glmt] Debug logging enabled: ${this.transformer.debugLogDir}`);
70
+ console.error(`[glmt] WARNING: Debug logs contain full request/response data`);
71
+ }
72
+
73
+ this.log(`Verbose logging enabled`);
74
+ resolve(this.port);
75
+ });
76
+
77
+ this.server.on('error', (error) => {
78
+ console.error('[glmt-proxy] Server error:', error);
79
+ reject(error);
80
+ });
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Handle incoming HTTP request
86
+ * @param {http.IncomingMessage} req - Request
87
+ * @param {http.ServerResponse} res - Response
88
+ */
89
+ async handleRequest(req, res) {
90
+ const startTime = Date.now();
91
+ this.log(`Request: ${req.method} ${req.url}`);
92
+
93
+ try {
94
+ // Only accept POST requests
95
+ if (req.method !== 'POST') {
96
+ res.writeHead(405, { 'Content-Type': 'application/json' });
97
+ res.end(JSON.stringify({ error: 'Method not allowed' }));
98
+ return;
99
+ }
100
+
101
+ // Read request body
102
+ const body = await this._readBody(req);
103
+ this.log(`Request body size: ${body.length} bytes`);
104
+
105
+ // Parse JSON with error handling
106
+ let anthropicRequest;
107
+ try {
108
+ anthropicRequest = JSON.parse(body);
109
+ } catch (jsonError) {
110
+ res.writeHead(400, { 'Content-Type': 'application/json' });
111
+ res.end(JSON.stringify({
112
+ error: {
113
+ type: 'invalid_request_error',
114
+ message: 'Invalid JSON in request body: ' + jsonError.message
115
+ }
116
+ }));
117
+ return;
118
+ }
119
+
120
+ // Branch: streaming or buffered
121
+ const useStreaming = (anthropicRequest.stream && this.streamingEnabled) || this.forceStreaming;
122
+
123
+ if (useStreaming) {
124
+ await this._handleStreamingRequest(req, res, anthropicRequest, startTime);
125
+ } else {
126
+ await this._handleBufferedRequest(req, res, anthropicRequest, startTime);
127
+ }
128
+
129
+ } catch (error) {
130
+ console.error('[glmt-proxy] Request error:', error.message);
131
+ const duration = Date.now() - startTime;
132
+ this.log(`Request failed after ${duration}ms: ${error.message}`);
133
+
134
+ res.writeHead(500, { 'Content-Type': 'application/json' });
135
+ res.end(JSON.stringify({
136
+ error: {
137
+ type: 'proxy_error',
138
+ message: error.message
139
+ }
140
+ }));
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Handle buffered (non-streaming) request
146
+ * @private
147
+ */
148
+ async _handleBufferedRequest(req, res, anthropicRequest, startTime) {
149
+ // Transform to OpenAI format
150
+ const { openaiRequest, thinkingConfig } =
151
+ this.transformer.transformRequest(anthropicRequest);
152
+
153
+ this.log(`Transformed request, thinking: ${thinkingConfig.thinking}`);
154
+
155
+ // Forward to Z.AI
156
+ const openaiResponse = await this._forwardToUpstream(
157
+ openaiRequest,
158
+ req.headers
159
+ );
160
+
161
+ this.log(`Received response from upstream`);
162
+
163
+ // Transform back to Anthropic format
164
+ const anthropicResponse = this.transformer.transformResponse(
165
+ openaiResponse,
166
+ thinkingConfig
167
+ );
168
+
169
+ // Return to Claude CLI
170
+ res.writeHead(200, {
171
+ 'Content-Type': 'application/json',
172
+ 'Access-Control-Allow-Origin': '*'
173
+ });
174
+ res.end(JSON.stringify(anthropicResponse));
175
+
176
+ const duration = Date.now() - startTime;
177
+ this.log(`Request completed in ${duration}ms`);
178
+ }
179
+
180
+ /**
181
+ * Handle streaming request
182
+ * @private
183
+ */
184
+ async _handleStreamingRequest(req, res, anthropicRequest, startTime) {
185
+ this.log('Using streaming mode');
186
+
187
+ // Transform request
188
+ const { openaiRequest, thinkingConfig } =
189
+ this.transformer.transformRequest(anthropicRequest);
190
+
191
+ // Force streaming
192
+ openaiRequest.stream = true;
193
+
194
+ // Set SSE headers
195
+ res.writeHead(200, {
196
+ 'Content-Type': 'text/event-stream',
197
+ 'Cache-Control': 'no-cache',
198
+ 'Connection': 'keep-alive',
199
+ 'Access-Control-Allow-Origin': '*'
200
+ });
201
+
202
+ this.log('Starting SSE stream to Claude CLI');
203
+
204
+ // Forward and stream
205
+ await this._forwardAndStreamUpstream(
206
+ openaiRequest,
207
+ req.headers,
208
+ res,
209
+ thinkingConfig,
210
+ startTime
211
+ );
212
+ }
213
+
214
+ /**
215
+ * Read request body
216
+ * @param {http.IncomingMessage} req - Request
217
+ * @returns {Promise<string>} Body content
218
+ * @private
219
+ */
220
+ _readBody(req) {
221
+ return new Promise((resolve, reject) => {
222
+ const chunks = [];
223
+ const maxSize = 10 * 1024 * 1024; // 10MB limit
224
+ let totalSize = 0;
225
+
226
+ req.on('data', chunk => {
227
+ totalSize += chunk.length;
228
+ if (totalSize > maxSize) {
229
+ reject(new Error('Request body too large (max 10MB)'));
230
+ return;
231
+ }
232
+ chunks.push(chunk);
233
+ });
234
+
235
+ req.on('end', () => resolve(Buffer.concat(chunks).toString()));
236
+ req.on('error', reject);
237
+ });
238
+ }
239
+
240
+ /**
241
+ * Forward request to Z.AI upstream
242
+ * @param {Object} openaiRequest - OpenAI format request
243
+ * @param {Object} originalHeaders - Original request headers
244
+ * @returns {Promise<Object>} OpenAI response
245
+ * @private
246
+ */
247
+ _forwardToUpstream(openaiRequest, originalHeaders) {
248
+ return new Promise((resolve, reject) => {
249
+ const url = new URL(this.upstreamUrl);
250
+ const requestBody = JSON.stringify(openaiRequest);
251
+
252
+ const options = {
253
+ hostname: url.hostname,
254
+ port: url.port || 443,
255
+ path: url.pathname || '/api/coding/paas/v4/chat/completions',
256
+ method: 'POST',
257
+ headers: {
258
+ 'Content-Type': 'application/json',
259
+ 'Content-Length': Buffer.byteLength(requestBody),
260
+ // Preserve auth header from original request
261
+ 'Authorization': originalHeaders['authorization'] || '',
262
+ 'User-Agent': 'CCS-GLMT-Proxy/1.0'
263
+ }
264
+ };
265
+
266
+ // Debug logging
267
+ this.log(`Forwarding to: ${url.hostname}${url.pathname}`);
268
+
269
+ // Set timeout
270
+ const timeoutHandle = setTimeout(() => {
271
+ req.destroy();
272
+ reject(new Error('Upstream request timeout'));
273
+ }, this.timeout);
274
+
275
+ const req = https.request(options, (res) => {
276
+ clearTimeout(timeoutHandle);
277
+
278
+ const chunks = [];
279
+ res.on('data', chunk => chunks.push(chunk));
280
+
281
+ res.on('end', () => {
282
+ try {
283
+ const body = Buffer.concat(chunks).toString();
284
+ this.log(`Upstream response size: ${body.length} bytes`);
285
+
286
+ // Check for non-200 status
287
+ if (res.statusCode !== 200) {
288
+ reject(new Error(
289
+ `Upstream error: ${res.statusCode} ${res.statusMessage}\n${body}`
290
+ ));
291
+ return;
292
+ }
293
+
294
+ const response = JSON.parse(body);
295
+ resolve(response);
296
+ } catch (error) {
297
+ reject(new Error('Invalid JSON from upstream: ' + error.message));
298
+ }
299
+ });
300
+ });
301
+
302
+ req.on('error', (error) => {
303
+ clearTimeout(timeoutHandle);
304
+ reject(error);
305
+ });
306
+
307
+ req.write(requestBody);
308
+ req.end();
309
+ });
310
+ }
311
+
312
+ /**
313
+ * Forward request to Z.AI and stream response
314
+ * @param {Object} openaiRequest - OpenAI format request
315
+ * @param {Object} originalHeaders - Original request headers
316
+ * @param {http.ServerResponse} clientRes - Response to Claude CLI
317
+ * @param {Object} thinkingConfig - Thinking configuration
318
+ * @param {number} startTime - Request start time
319
+ * @returns {Promise<void>}
320
+ * @private
321
+ */
322
+ async _forwardAndStreamUpstream(openaiRequest, originalHeaders, clientRes, thinkingConfig, startTime) {
323
+ return new Promise((resolve, reject) => {
324
+ const url = new URL(this.upstreamUrl);
325
+ const requestBody = JSON.stringify(openaiRequest);
326
+
327
+ const options = {
328
+ hostname: url.hostname,
329
+ port: url.port || 443,
330
+ path: url.pathname || '/api/coding/paas/v4/chat/completions',
331
+ method: 'POST',
332
+ headers: {
333
+ 'Content-Type': 'application/json',
334
+ 'Content-Length': Buffer.byteLength(requestBody),
335
+ 'Authorization': originalHeaders['authorization'] || '',
336
+ 'User-Agent': 'CCS-GLMT-Proxy/1.0',
337
+ 'Accept': 'text/event-stream'
338
+ }
339
+ };
340
+
341
+ this.log(`Forwarding streaming request to: ${url.hostname}${url.pathname}`);
342
+
343
+ // C-03 Fix: Apply timeout to streaming requests
344
+ const timeoutHandle = setTimeout(() => {
345
+ req.destroy();
346
+ reject(new Error(`Streaming request timeout after ${this.timeout}ms`));
347
+ }, this.timeout);
348
+
349
+ const req = https.request(options, (upstreamRes) => {
350
+ clearTimeout(timeoutHandle);
351
+ if (upstreamRes.statusCode !== 200) {
352
+ let body = '';
353
+ upstreamRes.on('data', chunk => body += chunk);
354
+ upstreamRes.on('end', () => {
355
+ reject(new Error(`Upstream error: ${upstreamRes.statusCode}\n${body}`));
356
+ });
357
+ return;
358
+ }
359
+
360
+ const parser = new SSEParser();
361
+ const accumulator = new DeltaAccumulator(thinkingConfig);
362
+
363
+ upstreamRes.on('data', (chunk) => {
364
+ try {
365
+ const events = parser.parse(chunk);
366
+
367
+ events.forEach(event => {
368
+ // Transform OpenAI delta → Anthropic events
369
+ const anthropicEvents = this.transformer.transformDelta(event, accumulator);
370
+
371
+ // Forward to Claude CLI
372
+ anthropicEvents.forEach(evt => {
373
+ const eventLine = `event: ${evt.event}\n`;
374
+ const dataLine = `data: ${JSON.stringify(evt.data)}\n\n`;
375
+ clientRes.write(eventLine + dataLine);
376
+ });
377
+ });
378
+ } catch (error) {
379
+ this.log(`Error processing chunk: ${error.message}`);
380
+ }
381
+ });
382
+
383
+ upstreamRes.on('end', () => {
384
+ const duration = Date.now() - startTime;
385
+ this.log(`Streaming completed in ${duration}ms`);
386
+ clientRes.end();
387
+ resolve();
388
+ });
389
+
390
+ upstreamRes.on('error', (error) => {
391
+ clearTimeout(timeoutHandle);
392
+ this.log(`Upstream stream error: ${error.message}`);
393
+ clientRes.write(`event: error\n`);
394
+ clientRes.write(`data: ${JSON.stringify({ error: error.message })}\n\n`);
395
+ clientRes.end();
396
+ reject(error);
397
+ });
398
+ });
399
+
400
+ req.on('error', (error) => {
401
+ clearTimeout(timeoutHandle);
402
+ this.log(`Request error: ${error.message}`);
403
+ clientRes.write(`event: error\n`);
404
+ clientRes.write(`data: ${JSON.stringify({ error: error.message })}\n\n`);
405
+ clientRes.end();
406
+ reject(error);
407
+ });
408
+
409
+ req.write(requestBody);
410
+ req.end();
411
+ });
412
+ }
413
+
414
+ /**
415
+ * Stop proxy server
416
+ */
417
+ stop() {
418
+ if (this.server) {
419
+ this.log('Stopping proxy server');
420
+ this.server.close();
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Log message if verbose
426
+ * @param {string} message - Message to log
427
+ * @private
428
+ */
429
+ log(message) {
430
+ if (this.verbose) {
431
+ console.error(`[glmt-proxy] ${message}`);
432
+ }
433
+ }
434
+ }
435
+
436
+ // Main entry point
437
+ if (require.main === module) {
438
+ const args = process.argv.slice(2);
439
+ const verbose = args.includes('--verbose') || args.includes('-v');
440
+
441
+ const proxy = new GlmtProxy({ verbose });
442
+
443
+ proxy.start().catch(error => {
444
+ console.error('[glmt-proxy] Failed to start:', error);
445
+ process.exit(1);
446
+ });
447
+
448
+ // Cleanup on signals
449
+ process.on('SIGTERM', () => {
450
+ proxy.stop();
451
+ process.exit(0);
452
+ });
453
+
454
+ process.on('SIGINT', () => {
455
+ proxy.stop();
456
+ process.exit(0);
457
+ });
458
+
459
+ // Keep process alive
460
+ process.on('uncaughtException', (error) => {
461
+ console.error('[glmt-proxy] Uncaught exception:', error);
462
+ proxy.stop();
463
+ process.exit(1);
464
+ });
465
+ }
466
+
467
+ module.exports = GlmtProxy;