@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,684 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const crypto = require('crypto');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+ const SSEParser = require('./sse-parser');
9
+ const DeltaAccumulator = require('./delta-accumulator');
10
+
11
+ /**
12
+ * GlmtTransformer - Convert between Anthropic and OpenAI formats with thinking support
13
+ *
14
+ * Features:
15
+ * - Request: Anthropic → OpenAI (inject reasoning params)
16
+ * - Response: OpenAI reasoning_content → Anthropic thinking blocks
17
+ * - Debug mode: Log raw data to ~/.ccs/logs/ (CCS_DEBUG_LOG=1)
18
+ * - Verbose mode: Console logging with timestamps
19
+ * - Validation: Self-test transformation results
20
+ *
21
+ * Usage:
22
+ * const transformer = new GlmtTransformer({ verbose: true, debugLog: true });
23
+ * const { openaiRequest, thinkingConfig } = transformer.transformRequest(req);
24
+ * const anthropicResponse = transformer.transformResponse(resp, thinkingConfig);
25
+ *
26
+ * Control Tags (in user prompt):
27
+ * <Thinking:On|Off> - Enable/disable reasoning
28
+ * <Effort:Low|Medium|High> - Control reasoning depth
29
+ */
30
+ class GlmtTransformer {
31
+ constructor(config = {}) {
32
+ this.defaultThinking = config.defaultThinking ?? true;
33
+ this.verbose = config.verbose || false;
34
+ this.debugLog = config.debugLog ?? process.env.CCS_DEBUG_LOG === '1';
35
+ this.debugLogDir = config.debugLogDir || path.join(os.homedir(), '.ccs', 'logs');
36
+ this.modelMaxTokens = {
37
+ 'GLM-4.6': 128000,
38
+ 'GLM-4.5': 96000,
39
+ 'GLM-4.5-air': 16000
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Transform Anthropic request to OpenAI format
45
+ * @param {Object} anthropicRequest - Anthropic Messages API request
46
+ * @returns {Object} { openaiRequest, thinkingConfig }
47
+ */
48
+ transformRequest(anthropicRequest) {
49
+ // Log original request
50
+ this._writeDebugLog('request-anthropic', anthropicRequest);
51
+
52
+ try {
53
+ // 1. Extract thinking control from messages
54
+ const thinkingConfig = this._extractThinkingControl(
55
+ anthropicRequest.messages || []
56
+ );
57
+ this.log(`Extracted thinking control: ${JSON.stringify(thinkingConfig)}`);
58
+
59
+ // 2. Map model
60
+ const glmModel = this._mapModel(anthropicRequest.model);
61
+
62
+ // 3. Convert to OpenAI format
63
+ const openaiRequest = {
64
+ model: glmModel,
65
+ messages: this._sanitizeMessages(anthropicRequest.messages || []),
66
+ max_tokens: this._getMaxTokens(glmModel),
67
+ stream: anthropicRequest.stream ?? false
68
+ };
69
+
70
+ // 4. Preserve optional parameters
71
+ if (anthropicRequest.temperature !== undefined) {
72
+ openaiRequest.temperature = anthropicRequest.temperature;
73
+ }
74
+ if (anthropicRequest.top_p !== undefined) {
75
+ openaiRequest.top_p = anthropicRequest.top_p;
76
+ }
77
+
78
+ // 5. Handle streaming
79
+ // Keep stream parameter from request
80
+ if (anthropicRequest.stream !== undefined) {
81
+ openaiRequest.stream = anthropicRequest.stream;
82
+ }
83
+
84
+ // 6. Inject reasoning parameters
85
+ this._injectReasoningParams(openaiRequest, thinkingConfig);
86
+
87
+ // Log transformed request
88
+ this._writeDebugLog('request-openai', openaiRequest);
89
+
90
+ return { openaiRequest, thinkingConfig };
91
+ } catch (error) {
92
+ console.error('[glmt-transformer] Request transformation error:', error);
93
+ // Return original request with warning
94
+ return {
95
+ openaiRequest: anthropicRequest,
96
+ thinkingConfig: { thinking: false },
97
+ error: error.message
98
+ };
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Transform OpenAI response to Anthropic format
104
+ * @param {Object} openaiResponse - OpenAI Chat Completions response
105
+ * @param {Object} thinkingConfig - Config from request transformation
106
+ * @returns {Object} Anthropic Messages API response
107
+ */
108
+ transformResponse(openaiResponse, thinkingConfig = {}) {
109
+ // Log original response
110
+ this._writeDebugLog('response-openai', openaiResponse);
111
+
112
+ try {
113
+ const choice = openaiResponse.choices?.[0];
114
+ if (!choice) {
115
+ throw new Error('No choices in OpenAI response');
116
+ }
117
+
118
+ const message = choice.message;
119
+ const content = [];
120
+
121
+ // Add thinking block if reasoning_content exists
122
+ if (message.reasoning_content) {
123
+ const length = message.reasoning_content.length;
124
+ const lineCount = message.reasoning_content.split('\n').length;
125
+ const preview = message.reasoning_content
126
+ .substring(0, 100)
127
+ .replace(/\n/g, ' ')
128
+ .trim();
129
+
130
+ this.log(`Detected reasoning_content:`);
131
+ this.log(` Length: ${length} characters`);
132
+ this.log(` Lines: ${lineCount}`);
133
+ this.log(` Preview: ${preview}...`);
134
+
135
+ content.push({
136
+ type: 'thinking',
137
+ thinking: message.reasoning_content,
138
+ signature: this._generateThinkingSignature(message.reasoning_content)
139
+ });
140
+ } else {
141
+ this.log('No reasoning_content in OpenAI response');
142
+ this.log('Note: This is expected if thinking not requested or model cannot reason');
143
+ }
144
+
145
+ // Add text content
146
+ if (message.content) {
147
+ content.push({
148
+ type: 'text',
149
+ text: message.content
150
+ });
151
+ }
152
+
153
+ // Handle tool_calls if present
154
+ if (message.tool_calls && message.tool_calls.length > 0) {
155
+ message.tool_calls.forEach(toolCall => {
156
+ content.push({
157
+ type: 'tool_use',
158
+ id: toolCall.id,
159
+ name: toolCall.function.name,
160
+ input: JSON.parse(toolCall.function.arguments || '{}')
161
+ });
162
+ });
163
+ }
164
+
165
+ const anthropicResponse = {
166
+ id: openaiResponse.id || 'msg_' + Date.now(),
167
+ type: 'message',
168
+ role: 'assistant',
169
+ content: content,
170
+ model: openaiResponse.model || 'glm-4.6',
171
+ stop_reason: this._mapStopReason(choice.finish_reason),
172
+ usage: openaiResponse.usage || {
173
+ input_tokens: 0,
174
+ output_tokens: 0
175
+ }
176
+ };
177
+
178
+ // Validate transformation in verbose mode
179
+ if (this.verbose) {
180
+ const validation = this._validateTransformation(anthropicResponse);
181
+ this.log(`Transformation validation: ${validation.passed}/${validation.total} checks passed`);
182
+ if (!validation.valid) {
183
+ this.log(`Failed checks: ${JSON.stringify(validation.checks, null, 2)}`);
184
+ }
185
+ }
186
+
187
+ // Log transformed response
188
+ this._writeDebugLog('response-anthropic', anthropicResponse);
189
+
190
+ return anthropicResponse;
191
+ } catch (error) {
192
+ console.error('[glmt-transformer] Response transformation error:', error);
193
+ // Return minimal valid response
194
+ return {
195
+ id: 'msg_error_' + Date.now(),
196
+ type: 'message',
197
+ role: 'assistant',
198
+ content: [{
199
+ type: 'text',
200
+ text: '[Transformation Error] ' + error.message
201
+ }],
202
+ stop_reason: 'end_turn',
203
+ usage: { input_tokens: 0, output_tokens: 0 }
204
+ };
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Sanitize messages for OpenAI API compatibility
210
+ * Remove thinking blocks and unsupported content types
211
+ * @param {Array} messages - Messages array
212
+ * @returns {Array} Sanitized messages
213
+ * @private
214
+ */
215
+ _sanitizeMessages(messages) {
216
+ return messages.map(msg => {
217
+ // If content is a string, return as-is
218
+ if (typeof msg.content === 'string') {
219
+ return msg;
220
+ }
221
+
222
+ // If content is an array, filter out unsupported types
223
+ if (Array.isArray(msg.content)) {
224
+ const sanitizedContent = msg.content
225
+ .filter(block => {
226
+ // Keep only text content for OpenAI
227
+ // Filter out: thinking, tool_use, tool_result, etc.
228
+ return block.type === 'text';
229
+ })
230
+ .map(block => {
231
+ // Return just the text content
232
+ return block;
233
+ });
234
+
235
+ // If we filtered everything out, return empty string
236
+ if (sanitizedContent.length === 0) {
237
+ return {
238
+ role: msg.role,
239
+ content: ''
240
+ };
241
+ }
242
+
243
+ // If only one text block, convert to string
244
+ if (sanitizedContent.length === 1 && sanitizedContent[0].type === 'text') {
245
+ return {
246
+ role: msg.role,
247
+ content: sanitizedContent[0].text
248
+ };
249
+ }
250
+
251
+ // Return array of text blocks
252
+ return {
253
+ role: msg.role,
254
+ content: sanitizedContent
255
+ };
256
+ }
257
+
258
+ // Fallback: return message as-is
259
+ return msg;
260
+ });
261
+ }
262
+
263
+ /**
264
+ * Extract thinking control tags from user messages
265
+ * @param {Array} messages - Messages array
266
+ * @returns {Object} { thinking: boolean, effort: string }
267
+ * @private
268
+ */
269
+ _extractThinkingControl(messages) {
270
+ const config = {
271
+ thinking: this.defaultThinking,
272
+ effort: 'medium'
273
+ };
274
+
275
+ // Scan user messages for control tags
276
+ for (const msg of messages) {
277
+ if (msg.role !== 'user') continue;
278
+
279
+ const content = msg.content;
280
+ if (typeof content !== 'string') continue;
281
+
282
+ // Check for <Thinking:On|Off>
283
+ const thinkingMatch = content.match(/<Thinking:(On|Off)>/i);
284
+ if (thinkingMatch) {
285
+ config.thinking = thinkingMatch[1].toLowerCase() === 'on';
286
+ }
287
+
288
+ // Check for <Effort:Low|Medium|High>
289
+ const effortMatch = content.match(/<Effort:(Low|Medium|High)>/i);
290
+ if (effortMatch) {
291
+ config.effort = effortMatch[1].toLowerCase();
292
+ }
293
+ }
294
+
295
+ return config;
296
+ }
297
+
298
+ /**
299
+ * Generate thinking signature for Claude Code UI
300
+ * @param {string} thinking - Thinking content
301
+ * @returns {Object} Signature object
302
+ * @private
303
+ */
304
+ _generateThinkingSignature(thinking) {
305
+ // Generate signature hash
306
+ const hash = crypto.createHash('sha256')
307
+ .update(thinking)
308
+ .digest('hex')
309
+ .substring(0, 16);
310
+
311
+ return {
312
+ type: 'thinking_signature',
313
+ hash: hash,
314
+ length: thinking.length,
315
+ timestamp: Date.now()
316
+ };
317
+ }
318
+
319
+ /**
320
+ * Inject reasoning parameters into OpenAI request
321
+ * @param {Object} openaiRequest - OpenAI request to modify
322
+ * @param {Object} thinkingConfig - Thinking configuration
323
+ * @returns {Object} Modified request
324
+ * @private
325
+ */
326
+ _injectReasoningParams(openaiRequest, thinkingConfig) {
327
+ // Always enable sampling for temperature/top_p to work
328
+ openaiRequest.do_sample = true;
329
+
330
+ // Add thinking-specific parameters if enabled
331
+ if (thinkingConfig.thinking) {
332
+ // Z.AI may support these parameters (based on research)
333
+ openaiRequest.reasoning = true;
334
+ openaiRequest.reasoning_effort = thinkingConfig.effort;
335
+ }
336
+
337
+ return openaiRequest;
338
+ }
339
+
340
+ /**
341
+ * Map Anthropic model to GLM model
342
+ * @param {string} anthropicModel - Anthropic model name
343
+ * @returns {string} GLM model name
344
+ * @private
345
+ */
346
+ _mapModel(anthropicModel) {
347
+ // Default to GLM-4.6 (latest and most capable)
348
+ return 'GLM-4.6';
349
+ }
350
+
351
+ /**
352
+ * Get max tokens for model
353
+ * @param {string} model - Model name
354
+ * @returns {number} Max tokens
355
+ * @private
356
+ */
357
+ _getMaxTokens(model) {
358
+ return this.modelMaxTokens[model] || 128000;
359
+ }
360
+
361
+ /**
362
+ * Map OpenAI stop reason to Anthropic stop reason
363
+ * @param {string} openaiReason - OpenAI finish_reason
364
+ * @returns {string} Anthropic stop_reason
365
+ * @private
366
+ */
367
+ _mapStopReason(openaiReason) {
368
+ const mapping = {
369
+ 'stop': 'end_turn',
370
+ 'length': 'max_tokens',
371
+ 'tool_calls': 'tool_use',
372
+ 'content_filter': 'stop_sequence'
373
+ };
374
+ return mapping[openaiReason] || 'end_turn';
375
+ }
376
+
377
+ /**
378
+ * Write debug log to file
379
+ * @param {string} type - 'request-anthropic', 'request-openai', 'response-openai', 'response-anthropic'
380
+ * @param {object} data - Data to log
381
+ * @private
382
+ */
383
+ _writeDebugLog(type, data) {
384
+ if (!this.debugLog) return;
385
+
386
+ try {
387
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('.')[0];
388
+ const filename = `${timestamp}-${type}.json`;
389
+ const filepath = path.join(this.debugLogDir, filename);
390
+
391
+ // Ensure directory exists
392
+ fs.mkdirSync(this.debugLogDir, { recursive: true });
393
+
394
+ // Write file (pretty-printed)
395
+ fs.writeFileSync(filepath, JSON.stringify(data, null, 2) + '\n', 'utf8');
396
+
397
+ if (this.verbose) {
398
+ this.log(`Debug log written: ${filepath}`);
399
+ }
400
+ } catch (error) {
401
+ console.error(`[glmt-transformer] Failed to write debug log: ${error.message}`);
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Validate transformed Anthropic response
407
+ * @param {object} anthropicResponse - Response to validate
408
+ * @returns {object} Validation results
409
+ * @private
410
+ */
411
+ _validateTransformation(anthropicResponse) {
412
+ const checks = {
413
+ hasContent: Boolean(anthropicResponse.content && anthropicResponse.content.length > 0),
414
+ hasThinking: anthropicResponse.content?.some(block => block.type === 'thinking') || false,
415
+ hasText: anthropicResponse.content?.some(block => block.type === 'text') || false,
416
+ validStructure: anthropicResponse.type === 'message' && anthropicResponse.role === 'assistant',
417
+ hasUsage: Boolean(anthropicResponse.usage)
418
+ };
419
+
420
+ const passed = Object.values(checks).filter(Boolean).length;
421
+ const total = Object.keys(checks).length;
422
+
423
+ return { checks, passed, total, valid: passed === total };
424
+ }
425
+
426
+ /**
427
+ * Transform OpenAI streaming delta to Anthropic events
428
+ * @param {Object} openaiEvent - Parsed SSE event from Z.AI
429
+ * @param {DeltaAccumulator} accumulator - State accumulator
430
+ * @returns {Array<Object>} Array of Anthropic SSE events
431
+ */
432
+ transformDelta(openaiEvent, accumulator) {
433
+ const events = [];
434
+
435
+ // Handle [DONE] marker
436
+ if (openaiEvent.event === 'done') {
437
+ return this.finalizeDelta(accumulator);
438
+ }
439
+
440
+ const choice = openaiEvent.data?.choices?.[0];
441
+ if (!choice) return events;
442
+
443
+ const delta = choice.delta;
444
+ if (!delta) return events;
445
+
446
+ // Message start
447
+ if (!accumulator.messageStarted) {
448
+ if (openaiEvent.data.model) {
449
+ accumulator.model = openaiEvent.data.model;
450
+ }
451
+ events.push(this._createMessageStartEvent(accumulator));
452
+ accumulator.messageStarted = true;
453
+ }
454
+
455
+ // Role
456
+ if (delta.role) {
457
+ accumulator.role = delta.role;
458
+ }
459
+
460
+ // Reasoning content delta (Z.AI streams incrementally - confirmed in Phase 02)
461
+ if (delta.reasoning_content) {
462
+ const currentBlock = accumulator.getCurrentBlock();
463
+
464
+ if (!currentBlock || currentBlock.type !== 'thinking') {
465
+ // Start thinking block
466
+ const block = accumulator.startBlock('thinking');
467
+ events.push(this._createContentBlockStartEvent(block));
468
+ }
469
+
470
+ accumulator.addDelta(delta.reasoning_content);
471
+ events.push(this._createThinkingDeltaEvent(
472
+ accumulator.getCurrentBlock(),
473
+ delta.reasoning_content
474
+ ));
475
+ }
476
+
477
+ // Text content delta
478
+ if (delta.content) {
479
+ const currentBlock = accumulator.getCurrentBlock();
480
+
481
+ // Close thinking block if transitioning from thinking to text
482
+ if (currentBlock && currentBlock.type === 'thinking' && !currentBlock.stopped) {
483
+ events.push(this._createSignatureDeltaEvent(currentBlock));
484
+ events.push(this._createContentBlockStopEvent(currentBlock));
485
+ accumulator.stopCurrentBlock();
486
+ }
487
+
488
+ if (!accumulator.getCurrentBlock() || accumulator.getCurrentBlock().type !== 'text') {
489
+ // Start text block
490
+ const block = accumulator.startBlock('text');
491
+ events.push(this._createContentBlockStartEvent(block));
492
+ }
493
+
494
+ accumulator.addDelta(delta.content);
495
+ events.push(this._createTextDeltaEvent(
496
+ accumulator.getCurrentBlock(),
497
+ delta.content
498
+ ));
499
+ }
500
+
501
+ // Usage update (appears in final chunk usually)
502
+ if (openaiEvent.data.usage) {
503
+ accumulator.updateUsage(openaiEvent.data.usage);
504
+ }
505
+
506
+ // Finish reason
507
+ if (choice.finish_reason) {
508
+ accumulator.finishReason = choice.finish_reason;
509
+ }
510
+
511
+ return events;
512
+ }
513
+
514
+ /**
515
+ * Finalize streaming and generate closing events
516
+ * @param {DeltaAccumulator} accumulator - State accumulator
517
+ * @returns {Array<Object>} Final Anthropic SSE events
518
+ */
519
+ finalizeDelta(accumulator) {
520
+ if (accumulator.finalized) {
521
+ return []; // Already finalized
522
+ }
523
+
524
+ const events = [];
525
+
526
+ // Close current content block if any
527
+ const currentBlock = accumulator.getCurrentBlock();
528
+ if (currentBlock && !currentBlock.stopped) {
529
+ if (currentBlock.type === 'thinking') {
530
+ events.push(this._createSignatureDeltaEvent(currentBlock));
531
+ }
532
+ events.push(this._createContentBlockStopEvent(currentBlock));
533
+ accumulator.stopCurrentBlock();
534
+ }
535
+
536
+ // Message delta (stop reason + usage)
537
+ events.push({
538
+ event: 'message_delta',
539
+ data: {
540
+ type: 'message_delta',
541
+ delta: {
542
+ stop_reason: this._mapStopReason(accumulator.finishReason || 'stop')
543
+ },
544
+ usage: {
545
+ output_tokens: accumulator.outputTokens
546
+ }
547
+ }
548
+ });
549
+
550
+ // Message stop
551
+ events.push({
552
+ event: 'message_stop',
553
+ data: {
554
+ type: 'message_stop'
555
+ }
556
+ });
557
+
558
+ accumulator.finalized = true;
559
+ return events;
560
+ }
561
+
562
+ /**
563
+ * Create message_start event
564
+ * @private
565
+ */
566
+ _createMessageStartEvent(accumulator) {
567
+ return {
568
+ event: 'message_start',
569
+ data: {
570
+ type: 'message_start',
571
+ message: {
572
+ id: accumulator.messageId,
573
+ type: 'message',
574
+ role: accumulator.role,
575
+ content: [],
576
+ model: accumulator.model || 'glm-4.6',
577
+ stop_reason: null,
578
+ usage: {
579
+ input_tokens: accumulator.inputTokens,
580
+ output_tokens: 0
581
+ }
582
+ }
583
+ }
584
+ };
585
+ }
586
+
587
+ /**
588
+ * Create content_block_start event
589
+ * @private
590
+ */
591
+ _createContentBlockStartEvent(block) {
592
+ return {
593
+ event: 'content_block_start',
594
+ data: {
595
+ type: 'content_block_start',
596
+ index: block.index,
597
+ content_block: {
598
+ type: block.type,
599
+ [block.type]: ''
600
+ }
601
+ }
602
+ };
603
+ }
604
+
605
+ /**
606
+ * Create thinking_delta event
607
+ * @private
608
+ */
609
+ _createThinkingDeltaEvent(block, delta) {
610
+ return {
611
+ event: 'content_block_delta',
612
+ data: {
613
+ type: 'content_block_delta',
614
+ index: block.index,
615
+ delta: {
616
+ type: 'thinking_delta',
617
+ thinking: delta
618
+ }
619
+ }
620
+ };
621
+ }
622
+
623
+ /**
624
+ * Create text_delta event
625
+ * @private
626
+ */
627
+ _createTextDeltaEvent(block, delta) {
628
+ return {
629
+ event: 'content_block_delta',
630
+ data: {
631
+ type: 'content_block_delta',
632
+ index: block.index,
633
+ delta: {
634
+ type: 'text_delta',
635
+ text: delta
636
+ }
637
+ }
638
+ };
639
+ }
640
+
641
+ /**
642
+ * Create signature_delta event
643
+ * @private
644
+ */
645
+ _createSignatureDeltaEvent(block) {
646
+ const signature = this._generateThinkingSignature(block.content);
647
+ return {
648
+ event: 'signature_delta',
649
+ data: {
650
+ type: 'signature_delta',
651
+ index: block.index,
652
+ signature: signature
653
+ }
654
+ };
655
+ }
656
+
657
+ /**
658
+ * Create content_block_stop event
659
+ * @private
660
+ */
661
+ _createContentBlockStopEvent(block) {
662
+ return {
663
+ event: 'content_block_stop',
664
+ data: {
665
+ type: 'content_block_stop',
666
+ index: block.index
667
+ }
668
+ };
669
+ }
670
+
671
+ /**
672
+ * Log message if verbose
673
+ * @param {string} message - Message to log
674
+ * @private
675
+ */
676
+ log(message) {
677
+ if (this.verbose) {
678
+ const timestamp = new Date().toTimeString().split(' ')[0]; // HH:MM:SS
679
+ console.error(`[glmt-transformer] [${timestamp}] ${message}`);
680
+ }
681
+ }
682
+ }
683
+
684
+ module.exports = GlmtTransformer;