@kaitranntt/ccs 3.3.0 → 3.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,919 @@
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
+ const LocaleEnforcer = require('./locale-enforcer');
11
+ const BudgetCalculator = require('./budget-calculator');
12
+ const TaskClassifier = require('./task-classifier');
13
+
14
+ /**
15
+ * GlmtTransformer - Convert between Anthropic and OpenAI formats with thinking and tool support
16
+ *
17
+ * Features:
18
+ * - Request: Anthropic → OpenAI (inject reasoning params, transform tools)
19
+ * - Response: OpenAI reasoning_content → Anthropic thinking blocks
20
+ * - Tool Support: Anthropic tools ↔ OpenAI function calling (bidirectional)
21
+ * - Streaming: Real-time tool calls with input_json deltas
22
+ * - Debug mode: Log raw data to ~/.ccs/logs/ (CCS_DEBUG_LOG=1)
23
+ * - Verbose mode: Console logging with timestamps
24
+ * - Validation: Self-test transformation results
25
+ *
26
+ * Usage:
27
+ * const transformer = new GlmtTransformer({ verbose: true, debugLog: true });
28
+ * const { openaiRequest, thinkingConfig } = transformer.transformRequest(req);
29
+ * const anthropicResponse = transformer.transformResponse(resp, thinkingConfig);
30
+ *
31
+ * Control Tags (in user prompt):
32
+ * <Thinking:On|Off> - Enable/disable reasoning
33
+ * <Effort:Low|Medium|High> - Control reasoning depth
34
+ */
35
+ class GlmtTransformer {
36
+ constructor(config = {}) {
37
+ this.defaultThinking = config.defaultThinking ?? true;
38
+ this.verbose = config.verbose || false;
39
+ this.debugLog = config.debugLog ?? process.env.CCS_DEBUG_LOG === '1';
40
+ this.debugLogDir = config.debugLogDir || path.join(os.homedir(), '.ccs', 'logs');
41
+ this.modelMaxTokens = {
42
+ 'GLM-4.6': 128000,
43
+ 'GLM-4.5': 96000,
44
+ 'GLM-4.5-air': 16000
45
+ };
46
+ // Effort level thresholds (budget_tokens)
47
+ this.EFFORT_LOW_THRESHOLD = 2048;
48
+ this.EFFORT_HIGH_THRESHOLD = 8192;
49
+
50
+ // Initialize locale enforcer
51
+ this.localeEnforcer = new LocaleEnforcer({
52
+ forceEnglish: process.env.CCS_GLMT_FORCE_ENGLISH !== 'false'
53
+ });
54
+
55
+ // Initialize budget calculator and task classifier
56
+ this.budgetCalculator = new BudgetCalculator();
57
+ this.taskClassifier = new TaskClassifier();
58
+ }
59
+
60
+ /**
61
+ * Transform Anthropic request to OpenAI format
62
+ * @param {Object} anthropicRequest - Anthropic Messages API request
63
+ * @returns {Object} { openaiRequest, thinkingConfig }
64
+ */
65
+ transformRequest(anthropicRequest) {
66
+ // Log original request
67
+ this._writeDebugLog('request-anthropic', anthropicRequest);
68
+
69
+ try {
70
+ // 1. Extract thinking control from messages (tags like <Thinking:On|Off>)
71
+ const thinkingConfig = this._extractThinkingControl(
72
+ anthropicRequest.messages || []
73
+ );
74
+ const hasControlTags = this._hasThinkingTags(anthropicRequest.messages || []);
75
+
76
+ // 2. Classify task type for intelligent thinking control
77
+ const taskType = this.taskClassifier.classify(anthropicRequest.messages || []);
78
+ this.log(`Task classified as: ${taskType}`);
79
+
80
+ // 3. Check budget and decide if thinking should be enabled
81
+ const envBudget = process.env.CCS_GLMT_THINKING_BUDGET;
82
+ const shouldThink = this.budgetCalculator.shouldEnableThinking(taskType, envBudget);
83
+ this.log(`Budget decision: thinking=${shouldThink} (budget: ${envBudget || 'default'}, type: ${taskType})`);
84
+
85
+ // Apply budget-based thinking control ONLY if:
86
+ // - No Claude CLI thinking parameter AND
87
+ // - No control tags in messages AND
88
+ // - Budget env var is explicitly set
89
+ if (!anthropicRequest.thinking && !hasControlTags && envBudget) {
90
+ thinkingConfig.thinking = shouldThink;
91
+ this.log('Applied budget-based thinking control');
92
+ }
93
+
94
+ // 4. Check anthropicRequest.thinking parameter (takes precedence over budget)
95
+ // Claude CLI sends this when alwaysThinkingEnabled is configured
96
+ if (anthropicRequest.thinking) {
97
+ if (anthropicRequest.thinking.type === 'enabled') {
98
+ thinkingConfig.thinking = true;
99
+ this.log('Claude CLI explicitly enabled thinking (overrides budget)');
100
+ } else if (anthropicRequest.thinking.type === 'disabled') {
101
+ thinkingConfig.thinking = false;
102
+ this.log('Claude CLI explicitly disabled thinking (overrides budget)');
103
+ } else {
104
+ this.log(`Warning: Unknown thinking type: ${anthropicRequest.thinking.type}`);
105
+ }
106
+ }
107
+
108
+ this.log(`Final thinking control: ${JSON.stringify(thinkingConfig)}`);
109
+
110
+ // 3. Map model
111
+ const glmModel = this._mapModel(anthropicRequest.model);
112
+
113
+ // 4. Inject locale instruction before sanitization
114
+ const messagesWithLocale = this.localeEnforcer.injectInstruction(
115
+ anthropicRequest.messages || []
116
+ );
117
+
118
+ // 5. Convert to OpenAI format
119
+ const openaiRequest = {
120
+ model: glmModel,
121
+ messages: this._sanitizeMessages(messagesWithLocale),
122
+ max_tokens: this._getMaxTokens(glmModel),
123
+ stream: anthropicRequest.stream ?? false
124
+ };
125
+
126
+ // 5.5. Transform tools parameter if present
127
+ if (anthropicRequest.tools && anthropicRequest.tools.length > 0) {
128
+ openaiRequest.tools = this._transformTools(anthropicRequest.tools);
129
+ // Always use "auto" as Z.AI doesn't support other modes
130
+ openaiRequest.tool_choice = "auto";
131
+ this.log(`Transformed ${anthropicRequest.tools.length} tools for OpenAI format`);
132
+ }
133
+
134
+ // 6. Preserve optional parameters
135
+ if (anthropicRequest.temperature !== undefined) {
136
+ openaiRequest.temperature = anthropicRequest.temperature;
137
+ }
138
+ if (anthropicRequest.top_p !== undefined) {
139
+ openaiRequest.top_p = anthropicRequest.top_p;
140
+ }
141
+
142
+ // 7. Handle streaming
143
+ // Keep stream parameter from request
144
+ if (anthropicRequest.stream !== undefined) {
145
+ openaiRequest.stream = anthropicRequest.stream;
146
+ }
147
+
148
+ // 8. Inject reasoning parameters
149
+ this._injectReasoningParams(openaiRequest, thinkingConfig);
150
+
151
+ // Log transformed request
152
+ this._writeDebugLog('request-openai', openaiRequest);
153
+
154
+ return { openaiRequest, thinkingConfig };
155
+ } catch (error) {
156
+ console.error('[glmt-transformer] Request transformation error:', error);
157
+ // Return original request with warning
158
+ return {
159
+ openaiRequest: anthropicRequest,
160
+ thinkingConfig: { thinking: false },
161
+ error: error.message
162
+ };
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Transform OpenAI response to Anthropic format
168
+ * @param {Object} openaiResponse - OpenAI Chat Completions response
169
+ * @param {Object} thinkingConfig - Config from request transformation
170
+ * @returns {Object} Anthropic Messages API response
171
+ */
172
+ transformResponse(openaiResponse, thinkingConfig = {}) {
173
+ // Log original response
174
+ this._writeDebugLog('response-openai', openaiResponse);
175
+
176
+ try {
177
+ const choice = openaiResponse.choices?.[0];
178
+ if (!choice) {
179
+ throw new Error('No choices in OpenAI response');
180
+ }
181
+
182
+ const message = choice.message;
183
+ const content = [];
184
+
185
+ // Add thinking block if reasoning_content exists
186
+ if (message.reasoning_content) {
187
+ const length = message.reasoning_content.length;
188
+ const lineCount = message.reasoning_content.split('\n').length;
189
+ const preview = message.reasoning_content
190
+ .substring(0, 100)
191
+ .replace(/\n/g, ' ')
192
+ .trim();
193
+
194
+ this.log(`Detected reasoning_content:`);
195
+ this.log(` Length: ${length} characters`);
196
+ this.log(` Lines: ${lineCount}`);
197
+ this.log(` Preview: ${preview}...`);
198
+
199
+ content.push({
200
+ type: 'thinking',
201
+ thinking: message.reasoning_content,
202
+ signature: this._generateThinkingSignature(message.reasoning_content)
203
+ });
204
+ } else {
205
+ this.log('No reasoning_content in OpenAI response');
206
+ this.log('Note: This is expected if thinking not requested or model cannot reason');
207
+ }
208
+
209
+ // Add text content
210
+ if (message.content) {
211
+ content.push({
212
+ type: 'text',
213
+ text: message.content
214
+ });
215
+ }
216
+
217
+ // Handle tool_calls if present
218
+ if (message.tool_calls && message.tool_calls.length > 0) {
219
+ message.tool_calls.forEach(toolCall => {
220
+ let parsedInput;
221
+ try {
222
+ parsedInput = JSON.parse(toolCall.function.arguments || '{}');
223
+ } catch (parseError) {
224
+ this.log(`Warning: Invalid JSON in tool arguments: ${parseError.message}`);
225
+ parsedInput = { _error: 'Invalid JSON', _raw: toolCall.function.arguments };
226
+ }
227
+
228
+ content.push({
229
+ type: 'tool_use',
230
+ id: toolCall.id,
231
+ name: toolCall.function.name,
232
+ input: parsedInput
233
+ });
234
+ });
235
+ }
236
+
237
+ const anthropicResponse = {
238
+ id: openaiResponse.id || 'msg_' + Date.now(),
239
+ type: 'message',
240
+ role: 'assistant',
241
+ content: content,
242
+ model: openaiResponse.model || 'glm-4.6',
243
+ stop_reason: this._mapStopReason(choice.finish_reason),
244
+ usage: {
245
+ input_tokens: openaiResponse.usage?.prompt_tokens || 0,
246
+ output_tokens: openaiResponse.usage?.completion_tokens || 0
247
+ }
248
+ };
249
+
250
+ // Validate transformation in verbose mode
251
+ if (this.verbose) {
252
+ const validation = this._validateTransformation(anthropicResponse);
253
+ this.log(`Transformation validation: ${validation.passed}/${validation.total} checks passed`);
254
+ if (!validation.valid) {
255
+ this.log(`Failed checks: ${JSON.stringify(validation.checks, null, 2)}`);
256
+ }
257
+ }
258
+
259
+ // Log transformed response
260
+ this._writeDebugLog('response-anthropic', anthropicResponse);
261
+
262
+ return anthropicResponse;
263
+ } catch (error) {
264
+ console.error('[glmt-transformer] Response transformation error:', error);
265
+ // Return minimal valid response
266
+ return {
267
+ id: 'msg_error_' + Date.now(),
268
+ type: 'message',
269
+ role: 'assistant',
270
+ content: [{
271
+ type: 'text',
272
+ text: '[Transformation Error] ' + error.message
273
+ }],
274
+ stop_reason: 'end_turn',
275
+ usage: { input_tokens: 0, output_tokens: 0 }
276
+ };
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Sanitize messages for OpenAI API compatibility
282
+ * Convert tool_result blocks to separate tool messages
283
+ * Filter out thinking blocks
284
+ * @param {Array} messages - Messages array
285
+ * @returns {Array} Sanitized messages
286
+ * @private
287
+ */
288
+ _sanitizeMessages(messages) {
289
+ const result = [];
290
+
291
+ for (const msg of messages) {
292
+ // If content is a string, add as-is
293
+ if (typeof msg.content === 'string') {
294
+ result.push(msg);
295
+ continue;
296
+ }
297
+
298
+ // If content is an array, process blocks
299
+ if (Array.isArray(msg.content)) {
300
+ // Separate tool_result blocks from other content
301
+ const toolResults = msg.content.filter(block => block.type === 'tool_result');
302
+ const textBlocks = msg.content.filter(block => block.type === 'text');
303
+ const toolUseBlocks = msg.content.filter(block => block.type === 'tool_use');
304
+
305
+ // CRITICAL: Tool messages must come BEFORE user text in OpenAI API
306
+ // Convert tool_result blocks to OpenAI tool messages FIRST
307
+ for (const toolResult of toolResults) {
308
+ result.push({
309
+ role: 'tool',
310
+ tool_call_id: toolResult.tool_use_id,
311
+ content: typeof toolResult.content === 'string'
312
+ ? toolResult.content
313
+ : JSON.stringify(toolResult.content)
314
+ });
315
+ }
316
+
317
+ // Add text content as user/assistant message AFTER tool messages
318
+ if (textBlocks.length > 0) {
319
+ const textContent = textBlocks.length === 1
320
+ ? textBlocks[0].text
321
+ : textBlocks.map(b => b.text).join('\n');
322
+
323
+ result.push({
324
+ role: msg.role,
325
+ content: textContent
326
+ });
327
+ }
328
+
329
+ // Add tool_use blocks (assistant's tool calls) - skip for now, they're in assistant messages
330
+ // OpenAI handles these differently in response, not request
331
+
332
+ // If no content at all, add empty message (but not if we added tool messages)
333
+ if (textBlocks.length === 0 && toolResults.length === 0 && toolUseBlocks.length === 0) {
334
+ result.push({
335
+ role: msg.role,
336
+ content: ''
337
+ });
338
+ }
339
+
340
+ continue;
341
+ }
342
+
343
+ // Fallback: return message as-is
344
+ result.push(msg);
345
+ }
346
+
347
+ return result;
348
+ }
349
+
350
+ /**
351
+ * Transform Anthropic tools to OpenAI tools format
352
+ * @param {Array} anthropicTools - Anthropic tools array
353
+ * @returns {Array} OpenAI tools array
354
+ * @private
355
+ */
356
+ _transformTools(anthropicTools) {
357
+ return anthropicTools.map(tool => ({
358
+ type: 'function',
359
+ function: {
360
+ name: tool.name,
361
+ description: tool.description,
362
+ parameters: tool.input_schema || {}
363
+ }
364
+ }));
365
+ }
366
+
367
+ /**
368
+ * Check if messages contain thinking control tags
369
+ * @param {Array} messages - Messages array
370
+ * @returns {boolean} True if tags found
371
+ * @private
372
+ */
373
+ _hasThinkingTags(messages) {
374
+ for (const msg of messages) {
375
+ if (msg.role !== 'user') continue;
376
+ const content = msg.content;
377
+ if (typeof content !== 'string') continue;
378
+
379
+ // Check for control tags
380
+ if (/<Thinking:(On|Off)>/i.test(content) || /<Effort:(Low|Medium|High)>/i.test(content)) {
381
+ return true;
382
+ }
383
+ }
384
+ return false;
385
+ }
386
+
387
+ /**
388
+ * Extract thinking control tags from user messages
389
+ * @param {Array} messages - Messages array
390
+ * @returns {Object} { thinking: boolean, effort: string }
391
+ * @private
392
+ */
393
+ _extractThinkingControl(messages) {
394
+ const config = {
395
+ thinking: this.defaultThinking,
396
+ effort: 'medium'
397
+ };
398
+
399
+ // Scan user messages for control tags
400
+ for (const msg of messages) {
401
+ if (msg.role !== 'user') continue;
402
+
403
+ const content = msg.content;
404
+ if (typeof content !== 'string') continue;
405
+
406
+ // Check for <Thinking:On|Off>
407
+ const thinkingMatch = content.match(/<Thinking:(On|Off)>/i);
408
+ if (thinkingMatch) {
409
+ config.thinking = thinkingMatch[1].toLowerCase() === 'on';
410
+ }
411
+
412
+ // Check for <Effort:Low|Medium|High>
413
+ const effortMatch = content.match(/<Effort:(Low|Medium|High)>/i);
414
+ if (effortMatch) {
415
+ config.effort = effortMatch[1].toLowerCase();
416
+ }
417
+ }
418
+
419
+ return config;
420
+ }
421
+
422
+ /**
423
+ * Generate thinking signature for Claude Code UI
424
+ * @param {string} thinking - Thinking content
425
+ * @returns {Object} Signature object
426
+ * @private
427
+ */
428
+ _generateThinkingSignature(thinking) {
429
+ // Generate signature hash
430
+ const hash = crypto.createHash('sha256')
431
+ .update(thinking)
432
+ .digest('hex')
433
+ .substring(0, 16);
434
+
435
+ return {
436
+ type: 'thinking_signature',
437
+ hash: hash,
438
+ length: thinking.length,
439
+ timestamp: Date.now()
440
+ };
441
+ }
442
+
443
+ /**
444
+ * Inject reasoning parameters into OpenAI request
445
+ * @param {Object} openaiRequest - OpenAI request to modify
446
+ * @param {Object} thinkingConfig - Thinking configuration
447
+ * @returns {Object} Modified request
448
+ * @private
449
+ */
450
+ _injectReasoningParams(openaiRequest, thinkingConfig) {
451
+ // Always enable sampling for temperature/top_p to work
452
+ openaiRequest.do_sample = true;
453
+
454
+ // Add thinking-specific parameters if enabled
455
+ if (thinkingConfig.thinking) {
456
+ // Z.AI may support these parameters (based on research)
457
+ openaiRequest.reasoning = true;
458
+ openaiRequest.reasoning_effort = thinkingConfig.effort;
459
+ }
460
+
461
+ return openaiRequest;
462
+ }
463
+
464
+ /**
465
+ * Map Anthropic model to GLM model
466
+ * @param {string} anthropicModel - Anthropic model name
467
+ * @returns {string} GLM model name
468
+ * @private
469
+ */
470
+ _mapModel(anthropicModel) {
471
+ // Default to GLM-4.6 (latest and most capable)
472
+ return 'GLM-4.6';
473
+ }
474
+
475
+ /**
476
+ * Get max tokens for model
477
+ * @param {string} model - Model name
478
+ * @returns {number} Max tokens
479
+ * @private
480
+ */
481
+ _getMaxTokens(model) {
482
+ return this.modelMaxTokens[model] || 128000;
483
+ }
484
+
485
+ /**
486
+ * Map OpenAI stop reason to Anthropic stop reason
487
+ * @param {string} openaiReason - OpenAI finish_reason
488
+ * @returns {string} Anthropic stop_reason
489
+ * @private
490
+ */
491
+ _mapStopReason(openaiReason) {
492
+ const mapping = {
493
+ 'stop': 'end_turn',
494
+ 'length': 'max_tokens',
495
+ 'tool_calls': 'tool_use',
496
+ 'content_filter': 'stop_sequence'
497
+ };
498
+ return mapping[openaiReason] || 'end_turn';
499
+ }
500
+
501
+ /**
502
+ * Write debug log to file
503
+ * @param {string} type - 'request-anthropic', 'request-openai', 'response-openai', 'response-anthropic'
504
+ * @param {object} data - Data to log
505
+ * @private
506
+ */
507
+ _writeDebugLog(type, data) {
508
+ if (!this.debugLog) return;
509
+
510
+ try {
511
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('.')[0];
512
+ const filename = `${timestamp}-${type}.json`;
513
+ const filepath = path.join(this.debugLogDir, filename);
514
+
515
+ // Ensure directory exists
516
+ fs.mkdirSync(this.debugLogDir, { recursive: true });
517
+
518
+ // Write file (pretty-printed)
519
+ fs.writeFileSync(filepath, JSON.stringify(data, null, 2) + '\n', 'utf8');
520
+
521
+ if (this.verbose) {
522
+ this.log(`Debug log written: ${filepath}`);
523
+ }
524
+ } catch (error) {
525
+ console.error(`[glmt-transformer] Failed to write debug log: ${error.message}`);
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Validate transformed Anthropic response
531
+ * @param {object} anthropicResponse - Response to validate
532
+ * @returns {object} Validation results
533
+ * @private
534
+ */
535
+ _validateTransformation(anthropicResponse) {
536
+ const checks = {
537
+ hasContent: Boolean(anthropicResponse.content && anthropicResponse.content.length > 0),
538
+ hasThinking: anthropicResponse.content?.some(block => block.type === 'thinking') || false,
539
+ hasText: anthropicResponse.content?.some(block => block.type === 'text') || false,
540
+ validStructure: anthropicResponse.type === 'message' && anthropicResponse.role === 'assistant',
541
+ hasUsage: Boolean(anthropicResponse.usage)
542
+ };
543
+
544
+ const passed = Object.values(checks).filter(Boolean).length;
545
+ const total = Object.keys(checks).length;
546
+
547
+ return { checks, passed, total, valid: passed === total };
548
+ }
549
+
550
+ /**
551
+ * Transform OpenAI streaming delta to Anthropic events
552
+ * @param {Object} openaiEvent - Parsed SSE event from Z.AI
553
+ * @param {DeltaAccumulator} accumulator - State accumulator
554
+ * @returns {Array<Object>} Array of Anthropic SSE events
555
+ */
556
+ transformDelta(openaiEvent, accumulator) {
557
+ const events = [];
558
+
559
+ // Debug logging for streaming deltas
560
+ if (this.debugLog && openaiEvent.data) {
561
+ this._writeDebugLog('delta-openai', openaiEvent.data);
562
+ }
563
+
564
+ // Handle [DONE] marker
565
+ // Only finalize if we haven't already (deferred finalization may have already triggered)
566
+ if (openaiEvent.event === 'done') {
567
+ if (!accumulator.finalized) {
568
+ return this.finalizeDelta(accumulator);
569
+ }
570
+ return []; // Already finalized
571
+ }
572
+
573
+ // Usage update (appears in final chunk, may be before choice data)
574
+ // Process this BEFORE early returns to ensure we capture usage
575
+ if (openaiEvent.data?.usage) {
576
+ accumulator.updateUsage(openaiEvent.data.usage);
577
+
578
+ // If we have both usage AND finish_reason, finalize immediately
579
+ if (accumulator.finishReason) {
580
+ events.push(...this.finalizeDelta(accumulator));
581
+ return events; // Early return after finalization
582
+ }
583
+ }
584
+
585
+ const choice = openaiEvent.data?.choices?.[0];
586
+ if (!choice) return events;
587
+
588
+ const delta = choice.delta;
589
+ if (!delta) return events;
590
+
591
+ // Message start
592
+ if (!accumulator.messageStarted) {
593
+ if (openaiEvent.data.model) {
594
+ accumulator.model = openaiEvent.data.model;
595
+ }
596
+ events.push(this._createMessageStartEvent(accumulator));
597
+ accumulator.messageStarted = true;
598
+ }
599
+
600
+ // Role
601
+ if (delta.role) {
602
+ accumulator.role = delta.role;
603
+ }
604
+
605
+ // Reasoning content delta (Z.AI streams incrementally - confirmed in Phase 02)
606
+ if (delta.reasoning_content) {
607
+ const currentBlock = accumulator.getCurrentBlock();
608
+
609
+ if (!currentBlock || currentBlock.type !== 'thinking') {
610
+ // Start thinking block
611
+ const block = accumulator.startBlock('thinking');
612
+ events.push(this._createContentBlockStartEvent(block));
613
+ }
614
+
615
+ accumulator.addDelta(delta.reasoning_content);
616
+ events.push(this._createThinkingDeltaEvent(
617
+ accumulator.getCurrentBlock(),
618
+ delta.reasoning_content
619
+ ));
620
+ }
621
+
622
+ // Text content delta
623
+ if (delta.content) {
624
+ const currentBlock = accumulator.getCurrentBlock();
625
+
626
+ // Close thinking block if transitioning from thinking to text
627
+ if (currentBlock && currentBlock.type === 'thinking' && !currentBlock.stopped) {
628
+ events.push(this._createSignatureDeltaEvent(currentBlock));
629
+ events.push(this._createContentBlockStopEvent(currentBlock));
630
+ accumulator.stopCurrentBlock();
631
+ }
632
+
633
+ if (!accumulator.getCurrentBlock() || accumulator.getCurrentBlock().type !== 'text') {
634
+ // Start text block
635
+ const block = accumulator.startBlock('text');
636
+ events.push(this._createContentBlockStartEvent(block));
637
+ }
638
+
639
+ accumulator.addDelta(delta.content);
640
+ events.push(this._createTextDeltaEvent(
641
+ accumulator.getCurrentBlock(),
642
+ delta.content
643
+ ));
644
+ }
645
+
646
+ // Check for planning loop after each thinking block completes
647
+ if (accumulator.checkForLoop()) {
648
+ this.log('WARNING: Planning loop detected - 3 consecutive thinking blocks with no tool calls');
649
+ this.log('Forcing early finalization to prevent unbounded planning');
650
+
651
+ // Close current block if any
652
+ const currentBlock = accumulator.getCurrentBlock();
653
+ if (currentBlock && !currentBlock.stopped) {
654
+ if (currentBlock.type === 'thinking') {
655
+ events.push(this._createSignatureDeltaEvent(currentBlock));
656
+ }
657
+ events.push(this._createContentBlockStopEvent(currentBlock));
658
+ accumulator.stopCurrentBlock();
659
+ }
660
+
661
+ // Force finalization
662
+ events.push(...this.finalizeDelta(accumulator));
663
+ return events;
664
+ }
665
+
666
+ // Tool calls deltas
667
+ if (delta.tool_calls && delta.tool_calls.length > 0) {
668
+ // Close current content block ONCE before processing any tool calls
669
+ const currentBlock = accumulator.getCurrentBlock();
670
+ if (currentBlock && !currentBlock.stopped) {
671
+ if (currentBlock.type === 'thinking') {
672
+ events.push(this._createSignatureDeltaEvent(currentBlock));
673
+ }
674
+ events.push(this._createContentBlockStopEvent(currentBlock));
675
+ accumulator.stopCurrentBlock();
676
+ }
677
+
678
+ // Process each tool call delta
679
+ for (const toolCallDelta of delta.tool_calls) {
680
+ // Track tool call state
681
+ const isNewToolCall = !accumulator.toolCallsIndex[toolCallDelta.index];
682
+ accumulator.addToolCallDelta(toolCallDelta);
683
+
684
+ // Emit tool use events (start + input_json deltas)
685
+ if (isNewToolCall) {
686
+ // Start new tool_use block in accumulator
687
+ const block = accumulator.startBlock('tool_use');
688
+ const toolCall = accumulator.toolCallsIndex[toolCallDelta.index];
689
+
690
+ events.push({
691
+ event: 'content_block_start',
692
+ data: {
693
+ type: 'content_block_start',
694
+ index: block.index,
695
+ content_block: {
696
+ type: 'tool_use',
697
+ id: toolCall.id || `tool_${toolCallDelta.index}`,
698
+ name: toolCall.function.name || ''
699
+ }
700
+ }
701
+ });
702
+ }
703
+
704
+ // Emit input_json delta if arguments present
705
+ if (toolCallDelta.function?.arguments) {
706
+ const currentToolBlock = accumulator.getCurrentBlock();
707
+ if (currentToolBlock && currentToolBlock.type === 'tool_use') {
708
+ events.push({
709
+ event: 'content_block_delta',
710
+ data: {
711
+ type: 'content_block_delta',
712
+ index: currentToolBlock.index,
713
+ delta: {
714
+ type: 'input_json_delta',
715
+ partial_json: toolCallDelta.function.arguments
716
+ }
717
+ }
718
+ });
719
+ }
720
+ }
721
+ }
722
+ }
723
+
724
+ // Finish reason
725
+ if (choice.finish_reason) {
726
+ accumulator.finishReason = choice.finish_reason;
727
+
728
+ // If we have both finish_reason AND usage, finalize immediately
729
+ if (accumulator.usageReceived) {
730
+ events.push(...this.finalizeDelta(accumulator));
731
+ }
732
+ }
733
+
734
+ // Debug logging for generated events
735
+ if (this.debugLog && events.length > 0) {
736
+ this._writeDebugLog('delta-anthropic-events', { events, accumulator: accumulator.getSummary() });
737
+ }
738
+
739
+ return events;
740
+ }
741
+
742
+ /**
743
+ * Finalize streaming and generate closing events
744
+ * @param {DeltaAccumulator} accumulator - State accumulator
745
+ * @returns {Array<Object>} Final Anthropic SSE events
746
+ */
747
+ finalizeDelta(accumulator) {
748
+ if (accumulator.finalized) {
749
+ return []; // Already finalized
750
+ }
751
+
752
+ const events = [];
753
+
754
+ // Close current content block if any (including tool_use blocks)
755
+ const currentBlock = accumulator.getCurrentBlock();
756
+ if (currentBlock && !currentBlock.stopped) {
757
+ if (currentBlock.type === 'thinking') {
758
+ events.push(this._createSignatureDeltaEvent(currentBlock));
759
+ }
760
+ events.push(this._createContentBlockStopEvent(currentBlock));
761
+ accumulator.stopCurrentBlock();
762
+ }
763
+
764
+ // No need to manually stop tool_use blocks - they're now tracked in contentBlocks
765
+ // and will be stopped by the logic above if they're the current block
766
+
767
+ // Message delta (stop reason + usage)
768
+ events.push({
769
+ event: 'message_delta',
770
+ data: {
771
+ type: 'message_delta',
772
+ delta: {
773
+ stop_reason: this._mapStopReason(accumulator.finishReason || 'stop')
774
+ },
775
+ usage: {
776
+ input_tokens: accumulator.inputTokens,
777
+ output_tokens: accumulator.outputTokens
778
+ }
779
+ }
780
+ });
781
+
782
+ // Message stop
783
+ events.push({
784
+ event: 'message_stop',
785
+ data: {
786
+ type: 'message_stop'
787
+ }
788
+ });
789
+
790
+ accumulator.finalized = true;
791
+ return events;
792
+ }
793
+
794
+ /**
795
+ * Create message_start event
796
+ * @private
797
+ */
798
+ _createMessageStartEvent(accumulator) {
799
+ return {
800
+ event: 'message_start',
801
+ data: {
802
+ type: 'message_start',
803
+ message: {
804
+ id: accumulator.messageId,
805
+ type: 'message',
806
+ role: accumulator.role,
807
+ content: [],
808
+ model: accumulator.model || 'glm-4.6',
809
+ stop_reason: null,
810
+ usage: {
811
+ input_tokens: accumulator.inputTokens,
812
+ output_tokens: 0
813
+ }
814
+ }
815
+ }
816
+ };
817
+ }
818
+
819
+ /**
820
+ * Create content_block_start event
821
+ * @private
822
+ */
823
+ _createContentBlockStartEvent(block) {
824
+ return {
825
+ event: 'content_block_start',
826
+ data: {
827
+ type: 'content_block_start',
828
+ index: block.index,
829
+ content_block: {
830
+ type: block.type,
831
+ [block.type]: ''
832
+ }
833
+ }
834
+ };
835
+ }
836
+
837
+ /**
838
+ * Create thinking_delta event
839
+ * @private
840
+ */
841
+ _createThinkingDeltaEvent(block, delta) {
842
+ return {
843
+ event: 'content_block_delta',
844
+ data: {
845
+ type: 'content_block_delta',
846
+ index: block.index,
847
+ delta: {
848
+ type: 'thinking_delta',
849
+ thinking: delta
850
+ }
851
+ }
852
+ };
853
+ }
854
+
855
+ /**
856
+ * Create text_delta event
857
+ * @private
858
+ */
859
+ _createTextDeltaEvent(block, delta) {
860
+ return {
861
+ event: 'content_block_delta',
862
+ data: {
863
+ type: 'content_block_delta',
864
+ index: block.index,
865
+ delta: {
866
+ type: 'text_delta',
867
+ text: delta
868
+ }
869
+ }
870
+ };
871
+ }
872
+
873
+ /**
874
+ * Create thinking signature delta event
875
+ * @private
876
+ */
877
+ _createSignatureDeltaEvent(block) {
878
+ const signature = this._generateThinkingSignature(block.content);
879
+ return {
880
+ event: 'content_block_delta',
881
+ data: {
882
+ type: 'content_block_delta',
883
+ index: block.index,
884
+ delta: {
885
+ type: 'thinking_signature_delta',
886
+ signature: signature
887
+ }
888
+ }
889
+ };
890
+ }
891
+
892
+ /**
893
+ * Create content_block_stop event
894
+ * @private
895
+ */
896
+ _createContentBlockStopEvent(block) {
897
+ return {
898
+ event: 'content_block_stop',
899
+ data: {
900
+ type: 'content_block_stop',
901
+ index: block.index
902
+ }
903
+ };
904
+ }
905
+
906
+ /**
907
+ * Log message if verbose
908
+ * @param {string} message - Message to log
909
+ * @private
910
+ */
911
+ log(message) {
912
+ if (this.verbose) {
913
+ const timestamp = new Date().toTimeString().split(' ')[0]; // HH:MM:SS
914
+ console.error(`[glmt-transformer] [${timestamp}] ${message}`);
915
+ }
916
+ }
917
+ }
918
+
919
+ module.exports = GlmtTransformer;