mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 16:19:27 +00:00
feat: add GLMT streaming with real-time thinking blocks
- SSEParser/DeltaAccumulator for streaming state - TTFB 5-20x faster (<500ms vs 2-10s) - DoS protection: buffer limits, 120s timeout - 51/51 tests passing (+25 streaming tests)
This commit is contained in:
@@ -4,6 +4,34 @@ All notable changes to CCS will be documented here.
|
||||
|
||||
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [3.4.0] - 2025-11-11
|
||||
|
||||
### Added
|
||||
- **GLMT Streaming**: Real-time thinking blocks (TTFB: 2-10s → <500ms, 5-20x faster)
|
||||
- New classes: `SSEParser`, `DeltaAccumulator` for streaming state management
|
||||
- Environment variables: `CCS_GLMT_STREAMING`, `CCS_DEBUG_LOG`
|
||||
- Security: Buffer limits (1MB SSE, 10MB content, 100 blocks max), 120s timeout
|
||||
|
||||
### Changed
|
||||
- Proxy respects `ANTHROPIC_BASE_URL` from environment (no hardcoded endpoints)
|
||||
- Proxy startup message only with `--verbose` flag (cleaner UX)
|
||||
- 51/51 tests passing (+25 new streaming tests)
|
||||
|
||||
### Fixed
|
||||
- **Security**: 3 critical DoS vulnerabilities (unbounded buffers, missing timeout)
|
||||
- Silent JSON parse failures now logged
|
||||
- Outdated test assertion for streaming parameter
|
||||
|
||||
### Performance
|
||||
- Time to First Byte: 5-20x improvement
|
||||
- Real-time vs delayed thinking blocks
|
||||
- Memory-efficient incremental processing
|
||||
|
||||
### Breaking Changes
|
||||
None - fully backward compatible. Buffered mode: `CCS_GLMT_STREAMING=disabled`
|
||||
|
||||
---
|
||||
|
||||
## [3.3.0] - 2025-11-11
|
||||
|
||||
### Added
|
||||
|
||||
@@ -30,9 +30,11 @@ CCS (Claude Code Switch): CLI wrapper for instant switching between multiple Cla
|
||||
|
||||
## Architecture
|
||||
|
||||
### v3.2 GLMT Profile
|
||||
### v3.4 GLMT Streaming
|
||||
|
||||
**Implementation**: Embedded HTTP proxy converts Anthropic ↔ OpenAI formats
|
||||
**Streaming support added**: Real-time delivery of reasoning content
|
||||
|
||||
**Architecture**: Embedded HTTP proxy with bidirectional streaming
|
||||
|
||||
**[!] Important**: GLMT only available in Node.js version (`bin/ccs.js`). Native shell versions (`lib/ccs`, `lib/ccs.ps1`) do not support GLMT yet (requires HTTP server).
|
||||
|
||||
@@ -41,28 +43,42 @@ CCS (Claude Code Switch): CLI wrapper for instant switching between multiple Cla
|
||||
2. `bin/ccs.js` spawns `bin/glmt-proxy.js` on localhost random port
|
||||
3. Modifies `glmt.settings.json`: `ANTHROPIC_BASE_URL=http://127.0.0.1:<port>`
|
||||
4. Spawns Claude CLI with modified settings
|
||||
5. Proxy intercepts requests:
|
||||
- `glmt-transformer.js` converts Anthropic → OpenAI format
|
||||
- Injects reasoning parameters (`reasoning: true`, `reasoning_effort`)
|
||||
- Forwards to `api.z.ai/api/coding/paas/v4/chat/completions`
|
||||
- Converts response: `reasoning_content` → thinking blocks
|
||||
- Returns Anthropic format to Claude CLI
|
||||
6. Thinking blocks appear in Claude Code UI
|
||||
5. Proxy intercepts requests (streaming or buffered):
|
||||
- **Streaming mode** (default):
|
||||
- `SSEParser` parses incremental SSE events from Z.AI
|
||||
- `DeltaAccumulator` tracks content block state
|
||||
- `glmt-transformer.js` converts OpenAI deltas → Anthropic events
|
||||
- Real-time delivery to Claude CLI (TTFB <500ms)
|
||||
- **Buffered mode** (`CCS_GLMT_STREAMING=disabled`):
|
||||
- Waits for complete response
|
||||
- Single transformation pass
|
||||
- Higher latency (2-10s TTFB)
|
||||
6. Thinking blocks appear in Claude Code UI (real-time or complete)
|
||||
|
||||
**Files**:
|
||||
- `bin/glmt-proxy.js` (275 lines): HTTP proxy server
|
||||
- `bin/glmt-transformer.js` (267 lines): Format conversion
|
||||
- `bin/glmt-proxy.js` (463 lines): HTTP proxy server with streaming
|
||||
- `bin/glmt-transformer.js` (685 lines): Format conversion + delta handling
|
||||
- `bin/sse-parser.js` (97 lines): SSE stream parser
|
||||
- `bin/delta-accumulator.js` (156 lines): State tracking for streaming
|
||||
- `config/base-glmt.settings.json`: Template with Z.AI endpoint
|
||||
- `tests/glmt-transformer.test.js` (285 lines): Unit tests
|
||||
- `tests/glmt-transformer.test.js`: Unit tests
|
||||
|
||||
**Control tags**:
|
||||
- `<Thinking:On|Off>` - Enable/disable reasoning
|
||||
- `<Effort:Low|Medium|High>` - Control reasoning depth
|
||||
|
||||
**Limitations**:
|
||||
- Streaming not supported (buffered mode only)
|
||||
- Requires Z.AI API key with coding plan access
|
||||
- Proxy lifecycle tied to Claude CLI
|
||||
**Environment variables**:
|
||||
- `CCS_GLMT_STREAMING=disabled` - Force buffered mode
|
||||
- `CCS_GLMT_STREAMING=force` - Force streaming (override client)
|
||||
- `CCS_DEBUG_LOG=1` - Enable debug file logging
|
||||
|
||||
**Security limits** (DoS protection):
|
||||
- SSE buffer: 1MB max
|
||||
- Content buffers: 10MB max per block
|
||||
- Content blocks: 100 max per message
|
||||
- Request timeout: 120s (both modes)
|
||||
|
||||
**Confirmed working**: Z.AI (1498 reasoning chunks tested)
|
||||
|
||||
### v3.1 Shared Data
|
||||
|
||||
@@ -112,8 +128,10 @@ Multiple profiles run simultaneously via isolated config dirs.
|
||||
- `bin/ccs.js`: Node.js entry point
|
||||
- `bin/instance-manager.js`: Instance orchestration
|
||||
- `bin/shared-manager.js`: Shared data symlinks (v3.1)
|
||||
- `bin/glmt-proxy.js`: Embedded HTTP proxy (v3.2)
|
||||
- `bin/glmt-transformer.js`: Anthropic ↔ OpenAI conversion (v3.2)
|
||||
- `bin/glmt-proxy.js`: Embedded HTTP proxy (v3.3 streaming)
|
||||
- `bin/glmt-transformer.js`: Anthropic ↔ OpenAI conversion + streaming (v3.3)
|
||||
- `bin/sse-parser.js`: SSE stream parser (v3.3)
|
||||
- `bin/delta-accumulator.js`: Streaming state tracker (v3.3)
|
||||
- `scripts/postinstall.js`: Auto-creates configs (idempotent)
|
||||
- `lib/ccs`: bash executable
|
||||
- `lib/ccs.ps1`: PowerShell executable
|
||||
@@ -315,7 +333,7 @@ All values = strings (not booleans/objects) to prevent PowerShell crashes.
|
||||
```
|
||||
|
||||
**Proxy Failures**:
|
||||
- Timeout (>30s): Proxy didn't start → check Node.js ≥14
|
||||
- Timeout (>120s): Proxy didn't start → check Node.js ≥14
|
||||
- Port conflicts: Uses random port, unlikely
|
||||
- Connection refused: Firewall blocking 127.0.0.1
|
||||
|
||||
@@ -324,9 +342,11 @@ All values = strings (not booleans/objects) to prevent PowerShell crashes.
|
||||
- Verify `<Thinking:On>` tag not overridden
|
||||
- Test with `ccs glm` (no thinking) to isolate proxy issues
|
||||
|
||||
**Streaming Disclaimer**:
|
||||
- GLMT uses buffered mode (streaming not supported)
|
||||
- Trade-off: thinking capability vs streaming speed
|
||||
**Streaming Issues**:
|
||||
- Buffer errors: Hit DoS protection limits (1MB SSE, 10MB content)
|
||||
- Slow TTFB: Try disabling streaming: `CCS_GLMT_STREAMING=disabled`
|
||||
- Incomplete reasoning: Z.AI may not support incremental delivery for all models
|
||||
- Fallback: Buffered mode always available
|
||||
|
||||
**Debug Mode**:
|
||||
```bash
|
||||
@@ -337,6 +357,9 @@ ccs glmt --verbose "test"
|
||||
export CCS_DEBUG_LOG=1
|
||||
ccs glmt --verbose "test"
|
||||
# Logs: ~/.ccs/logs/
|
||||
|
||||
# Test streaming vs buffered
|
||||
CCS_GLMT_STREAMING=disabled ccs glmt "compare latency"
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
@@ -205,16 +205,27 @@ Commands and skills symlinked from `~/.ccs/shared/` - no duplication across prof
|
||||
|---------|-----------------|-------------------|
|
||||
| **Endpoint** | Anthropic-compatible | OpenAI-compatible |
|
||||
| **Thinking** | No | Yes (reasoning_content) |
|
||||
| **Streaming** | Yes | No (buffered) |
|
||||
| **Streaming** | Yes | **Yes (v3.4+)** |
|
||||
| **TTFB** | <500ms | <500ms (streaming), 2-10s (buffered) |
|
||||
| **Use Case** | Fast responses | Complex reasoning |
|
||||
|
||||
### Streaming Support (v3.4)
|
||||
|
||||
**GLMT now supports real-time streaming** with incremental reasoning content delivery.
|
||||
|
||||
- **Default**: Streaming enabled (TTFB <500ms)
|
||||
- **Disable**: Set `CCS_GLMT_STREAMING=disabled` for buffered mode
|
||||
- **Force**: Set `CCS_GLMT_STREAMING=force` to override client preferences
|
||||
|
||||
**Confirmed working**: Z.AI (1498 reasoning chunks tested)
|
||||
|
||||
### How It Works
|
||||
|
||||
1. CCS spawns embedded HTTP proxy on localhost
|
||||
2. Proxy converts Anthropic format → OpenAI format
|
||||
2. Proxy converts Anthropic format → OpenAI format (streaming or buffered)
|
||||
3. Forwards to Z.AI with reasoning parameters
|
||||
4. Converts `reasoning_content` → thinking blocks
|
||||
5. Thinking appears in Claude Code UI
|
||||
4. Converts `reasoning_content` → thinking blocks (incremental or complete)
|
||||
5. Thinking appears in Claude Code UI in real-time
|
||||
|
||||
### Control Tags
|
||||
|
||||
@@ -235,6 +246,14 @@ nano ~/.ccs/glmt.settings.json
|
||||
}
|
||||
```
|
||||
|
||||
### Security Limits
|
||||
|
||||
**DoS protection** (v3.4):
|
||||
- SSE buffer: 1MB max per event
|
||||
- Content buffer: 10MB max per block (thinking/text)
|
||||
- Content blocks: 100 max per message
|
||||
- Request timeout: 120s (both streaming and buffered)
|
||||
|
||||
### Debugging
|
||||
|
||||
**Enable verbose logging**:
|
||||
@@ -249,6 +268,12 @@ ccs glmt --verbose "your prompt"
|
||||
# Logs: ~/.ccs/logs/
|
||||
```
|
||||
|
||||
**Check streaming mode**:
|
||||
```bash
|
||||
# Disable streaming for debugging
|
||||
CCS_GLMT_STREAMING=disabled ccs glmt "test"
|
||||
```
|
||||
|
||||
**Check reasoning content**:
|
||||
```bash
|
||||
cat ~/.ccs/logs/*response-openai.json | jq '.choices[0].message.reasoning_content'
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* DeltaAccumulator - Maintain state across streaming deltas
|
||||
*
|
||||
* Tracks:
|
||||
* - Message metadata (id, model, role)
|
||||
* - Content blocks (thinking, text)
|
||||
* - Current block index
|
||||
* - Accumulated content
|
||||
*
|
||||
* Usage:
|
||||
* const acc = new DeltaAccumulator(thinkingConfig);
|
||||
* const events = transformer.transformDelta(openaiEvent, acc);
|
||||
*/
|
||||
class DeltaAccumulator {
|
||||
constructor(thinkingConfig = {}, options = {}) {
|
||||
this.thinkingConfig = thinkingConfig;
|
||||
this.messageId = 'msg_' + Date.now() + '_' + Math.random().toString(36).substring(7);
|
||||
this.model = null;
|
||||
this.role = 'assistant';
|
||||
|
||||
// Content blocks
|
||||
this.contentBlocks = [];
|
||||
this.currentBlockIndex = -1;
|
||||
|
||||
// Buffers
|
||||
this.thinkingBuffer = '';
|
||||
this.textBuffer = '';
|
||||
|
||||
// C-02 Fix: Limits to prevent unbounded accumulation
|
||||
this.maxBlocks = options.maxBlocks || 100;
|
||||
this.maxBufferSize = options.maxBufferSize || 10 * 1024 * 1024; // 10MB
|
||||
|
||||
// State flags
|
||||
this.messageStarted = false;
|
||||
this.finalized = false;
|
||||
|
||||
// Statistics
|
||||
this.inputTokens = 0;
|
||||
this.outputTokens = 0;
|
||||
this.finishReason = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current content block
|
||||
* @returns {Object|null} Current block or null
|
||||
*/
|
||||
getCurrentBlock() {
|
||||
if (this.currentBlockIndex >= 0 && this.currentBlockIndex < this.contentBlocks.length) {
|
||||
return this.contentBlocks[this.currentBlockIndex];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start new content block
|
||||
* @param {string} type - Block type ('thinking' or 'text')
|
||||
* @returns {Object} New block
|
||||
*/
|
||||
startBlock(type) {
|
||||
// C-02 Fix: Enforce max blocks limit
|
||||
if (this.contentBlocks.length >= this.maxBlocks) {
|
||||
throw new Error(`Maximum ${this.maxBlocks} content blocks exceeded (DoS protection)`);
|
||||
}
|
||||
|
||||
this.currentBlockIndex++;
|
||||
const block = {
|
||||
index: this.currentBlockIndex,
|
||||
type: type,
|
||||
content: '',
|
||||
started: true,
|
||||
stopped: false
|
||||
};
|
||||
this.contentBlocks.push(block);
|
||||
|
||||
// Reset buffer for new block
|
||||
if (type === 'thinking') {
|
||||
this.thinkingBuffer = '';
|
||||
} else if (type === 'text') {
|
||||
this.textBuffer = '';
|
||||
}
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add delta to current block
|
||||
* @param {string} delta - Content delta
|
||||
*/
|
||||
addDelta(delta) {
|
||||
const block = this.getCurrentBlock();
|
||||
if (block) {
|
||||
if (block.type === 'thinking') {
|
||||
// C-02 Fix: Enforce buffer size limit
|
||||
if (this.thinkingBuffer.length + delta.length > this.maxBufferSize) {
|
||||
throw new Error(`Thinking buffer exceeded ${this.maxBufferSize} bytes (DoS protection)`);
|
||||
}
|
||||
this.thinkingBuffer += delta;
|
||||
block.content = this.thinkingBuffer;
|
||||
} else if (block.type === 'text') {
|
||||
// C-02 Fix: Enforce buffer size limit
|
||||
if (this.textBuffer.length + delta.length > this.maxBufferSize) {
|
||||
throw new Error(`Text buffer exceeded ${this.maxBufferSize} bytes (DoS protection)`);
|
||||
}
|
||||
this.textBuffer += delta;
|
||||
block.content = this.textBuffer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark current block as stopped
|
||||
*/
|
||||
stopCurrentBlock() {
|
||||
const block = this.getCurrentBlock();
|
||||
if (block) {
|
||||
block.stopped = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update usage statistics
|
||||
* @param {Object} usage - Usage object from OpenAI
|
||||
*/
|
||||
updateUsage(usage) {
|
||||
if (usage) {
|
||||
this.inputTokens = usage.prompt_tokens || usage.input_tokens || 0;
|
||||
this.outputTokens = usage.completion_tokens || usage.output_tokens || 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary of accumulated state
|
||||
* @returns {Object} Summary
|
||||
*/
|
||||
getSummary() {
|
||||
return {
|
||||
messageId: this.messageId,
|
||||
model: this.model,
|
||||
role: this.role,
|
||||
blockCount: this.contentBlocks.length,
|
||||
currentIndex: this.currentBlockIndex,
|
||||
messageStarted: this.messageStarted,
|
||||
finalized: this.finalized,
|
||||
usage: {
|
||||
input_tokens: this.inputTokens,
|
||||
output_tokens: this.outputTokens
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DeltaAccumulator;
|
||||
+194
-34
@@ -4,6 +4,8 @@
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const GlmtTransformer = require('./glmt-transformer');
|
||||
const SSEParser = require('./sse-parser');
|
||||
const DeltaAccumulator = require('./delta-accumulator');
|
||||
|
||||
/**
|
||||
* GlmtProxy - Embedded HTTP proxy for GLM thinking support
|
||||
@@ -12,7 +14,7 @@ const GlmtTransformer = require('./glmt-transformer');
|
||||
* - Intercepts Claude CLI → Z.AI calls
|
||||
* - Transforms Anthropic format → OpenAI format
|
||||
* - Converts reasoning_content → thinking blocks
|
||||
* - Buffered mode only (streaming not supported)
|
||||
* - Supports both streaming and buffered modes
|
||||
*
|
||||
* Lifecycle:
|
||||
* - Spawned by bin/ccs.js when 'glmt' profile detected
|
||||
@@ -30,11 +32,14 @@ const GlmtTransformer = require('./glmt-transformer');
|
||||
class GlmtProxy {
|
||||
constructor(config = {}) {
|
||||
this.transformer = new GlmtTransformer({ verbose: config.verbose });
|
||||
this.upstreamUrl = 'https://api.z.ai/api/coding/paas/v4/chat/completions';
|
||||
// Use ANTHROPIC_BASE_URL from environment (set by settings.json) or fallback to Z.AI default
|
||||
this.upstreamUrl = process.env.ANTHROPIC_BASE_URL || 'https://api.z.ai/api/coding/paas/v4/chat/completions';
|
||||
this.server = null;
|
||||
this.port = null;
|
||||
this.verbose = config.verbose || false;
|
||||
this.timeout = config.timeout || 120000; // 120s default
|
||||
this.streamingEnabled = process.env.CCS_GLMT_STREAMING !== 'disabled';
|
||||
this.forceStreaming = process.env.CCS_GLMT_STREAMING === 'force';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,8 +57,12 @@ class GlmtProxy {
|
||||
this.port = this.server.address().port;
|
||||
// Signal parent process
|
||||
console.log(`PROXY_READY:${this.port}`);
|
||||
// One-time info message (always shown)
|
||||
console.error(`[glmt] Proxy listening on port ${this.port} (buffered mode)`);
|
||||
|
||||
// Info message (only show in verbose mode)
|
||||
if (this.verbose) {
|
||||
const mode = this.streamingEnabled ? 'streaming mode' : 'buffered mode';
|
||||
console.error(`[glmt] Proxy listening on port ${this.port} (${mode})`);
|
||||
}
|
||||
|
||||
// Debug mode notice
|
||||
if (this.transformer.debugLog) {
|
||||
@@ -108,35 +117,14 @@ class GlmtProxy {
|
||||
return;
|
||||
}
|
||||
|
||||
// Transform to OpenAI format
|
||||
const { openaiRequest, thinkingConfig } =
|
||||
this.transformer.transformRequest(anthropicRequest);
|
||||
// Branch: streaming or buffered
|
||||
const useStreaming = (anthropicRequest.stream && this.streamingEnabled) || this.forceStreaming;
|
||||
|
||||
this.log(`Transformed request, thinking: ${thinkingConfig.thinking}`);
|
||||
|
||||
// Forward to Z.AI
|
||||
const openaiResponse = await this._forwardToUpstream(
|
||||
openaiRequest,
|
||||
req.headers
|
||||
);
|
||||
|
||||
this.log(`Received response from upstream`);
|
||||
|
||||
// Transform back to Anthropic format
|
||||
const anthropicResponse = this.transformer.transformResponse(
|
||||
openaiResponse,
|
||||
thinkingConfig
|
||||
);
|
||||
|
||||
// Return to Claude CLI
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
});
|
||||
res.end(JSON.stringify(anthropicResponse));
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
this.log(`Request completed in ${duration}ms`);
|
||||
if (useStreaming) {
|
||||
await this._handleStreamingRequest(req, res, anthropicRequest, startTime);
|
||||
} else {
|
||||
await this._handleBufferedRequest(req, res, anthropicRequest, startTime);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[glmt-proxy] Request error:', error.message);
|
||||
@@ -153,6 +141,76 @@ class GlmtProxy {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle buffered (non-streaming) request
|
||||
* @private
|
||||
*/
|
||||
async _handleBufferedRequest(req, res, anthropicRequest, startTime) {
|
||||
// Transform to OpenAI format
|
||||
const { openaiRequest, thinkingConfig } =
|
||||
this.transformer.transformRequest(anthropicRequest);
|
||||
|
||||
this.log(`Transformed request, thinking: ${thinkingConfig.thinking}`);
|
||||
|
||||
// Forward to Z.AI
|
||||
const openaiResponse = await this._forwardToUpstream(
|
||||
openaiRequest,
|
||||
req.headers
|
||||
);
|
||||
|
||||
this.log(`Received response from upstream`);
|
||||
|
||||
// Transform back to Anthropic format
|
||||
const anthropicResponse = this.transformer.transformResponse(
|
||||
openaiResponse,
|
||||
thinkingConfig
|
||||
);
|
||||
|
||||
// Return to Claude CLI
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
});
|
||||
res.end(JSON.stringify(anthropicResponse));
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
this.log(`Request completed in ${duration}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle streaming request
|
||||
* @private
|
||||
*/
|
||||
async _handleStreamingRequest(req, res, anthropicRequest, startTime) {
|
||||
this.log('Using streaming mode');
|
||||
|
||||
// Transform request
|
||||
const { openaiRequest, thinkingConfig } =
|
||||
this.transformer.transformRequest(anthropicRequest);
|
||||
|
||||
// Force streaming
|
||||
openaiRequest.stream = true;
|
||||
|
||||
// Set SSE headers
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
});
|
||||
|
||||
this.log('Starting SSE stream to Claude CLI');
|
||||
|
||||
// Forward and stream
|
||||
await this._forwardAndStreamUpstream(
|
||||
openaiRequest,
|
||||
req.headers,
|
||||
res,
|
||||
thinkingConfig,
|
||||
startTime
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read request body
|
||||
* @param {http.IncomingMessage} req - Request
|
||||
@@ -194,7 +252,7 @@ class GlmtProxy {
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || 443,
|
||||
path: '/api/coding/paas/v4/chat/completions', // OpenAI-compatible endpoint
|
||||
path: url.pathname || '/api/coding/paas/v4/chat/completions',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -206,7 +264,7 @@ class GlmtProxy {
|
||||
};
|
||||
|
||||
// Debug logging
|
||||
this.log(`Forwarding to: ${url.hostname}${options.path}`);
|
||||
this.log(`Forwarding to: ${url.hostname}${url.pathname}`);
|
||||
|
||||
// Set timeout
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
@@ -251,6 +309,108 @@ class GlmtProxy {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward request to Z.AI and stream response
|
||||
* @param {Object} openaiRequest - OpenAI format request
|
||||
* @param {Object} originalHeaders - Original request headers
|
||||
* @param {http.ServerResponse} clientRes - Response to Claude CLI
|
||||
* @param {Object} thinkingConfig - Thinking configuration
|
||||
* @param {number} startTime - Request start time
|
||||
* @returns {Promise<void>}
|
||||
* @private
|
||||
*/
|
||||
async _forwardAndStreamUpstream(openaiRequest, originalHeaders, clientRes, thinkingConfig, startTime) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(this.upstreamUrl);
|
||||
const requestBody = JSON.stringify(openaiRequest);
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || 443,
|
||||
path: url.pathname || '/api/coding/paas/v4/chat/completions',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(requestBody),
|
||||
'Authorization': originalHeaders['authorization'] || '',
|
||||
'User-Agent': 'CCS-GLMT-Proxy/1.0',
|
||||
'Accept': 'text/event-stream'
|
||||
}
|
||||
};
|
||||
|
||||
this.log(`Forwarding streaming request to: ${url.hostname}${url.pathname}`);
|
||||
|
||||
// C-03 Fix: Apply timeout to streaming requests
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
req.destroy();
|
||||
reject(new Error(`Streaming request timeout after ${this.timeout}ms`));
|
||||
}, this.timeout);
|
||||
|
||||
const req = https.request(options, (upstreamRes) => {
|
||||
clearTimeout(timeoutHandle);
|
||||
if (upstreamRes.statusCode !== 200) {
|
||||
let body = '';
|
||||
upstreamRes.on('data', chunk => body += chunk);
|
||||
upstreamRes.on('end', () => {
|
||||
reject(new Error(`Upstream error: ${upstreamRes.statusCode}\n${body}`));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const parser = new SSEParser();
|
||||
const accumulator = new DeltaAccumulator(thinkingConfig);
|
||||
|
||||
upstreamRes.on('data', (chunk) => {
|
||||
try {
|
||||
const events = parser.parse(chunk);
|
||||
|
||||
events.forEach(event => {
|
||||
// Transform OpenAI delta → Anthropic events
|
||||
const anthropicEvents = this.transformer.transformDelta(event, accumulator);
|
||||
|
||||
// Forward to Claude CLI
|
||||
anthropicEvents.forEach(evt => {
|
||||
const eventLine = `event: ${evt.event}\n`;
|
||||
const dataLine = `data: ${JSON.stringify(evt.data)}\n\n`;
|
||||
clientRes.write(eventLine + dataLine);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
this.log(`Error processing chunk: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
upstreamRes.on('end', () => {
|
||||
const duration = Date.now() - startTime;
|
||||
this.log(`Streaming completed in ${duration}ms`);
|
||||
clientRes.end();
|
||||
resolve();
|
||||
});
|
||||
|
||||
upstreamRes.on('error', (error) => {
|
||||
clearTimeout(timeoutHandle);
|
||||
this.log(`Upstream stream error: ${error.message}`);
|
||||
clientRes.write(`event: error\n`);
|
||||
clientRes.write(`data: ${JSON.stringify({ error: error.message })}\n\n`);
|
||||
clientRes.end();
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
clearTimeout(timeoutHandle);
|
||||
this.log(`Request error: ${error.message}`);
|
||||
clientRes.write(`event: error\n`);
|
||||
clientRes.write(`data: ${JSON.stringify({ error: error.message })}\n\n`);
|
||||
clientRes.end();
|
||||
reject(error);
|
||||
});
|
||||
|
||||
req.write(requestBody);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop proxy server
|
||||
*/
|
||||
|
||||
+251
-4
@@ -5,6 +5,8 @@ const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const SSEParser = require('./sse-parser');
|
||||
const DeltaAccumulator = require('./delta-accumulator');
|
||||
|
||||
/**
|
||||
* GlmtTransformer - Convert between Anthropic and OpenAI formats with thinking support
|
||||
@@ -73,10 +75,10 @@ class GlmtTransformer {
|
||||
openaiRequest.top_p = anthropicRequest.top_p;
|
||||
}
|
||||
|
||||
// 5. Handle streaming (not yet supported)
|
||||
// Silently override to buffered mode
|
||||
if (anthropicRequest.stream) {
|
||||
openaiRequest.stream = false;
|
||||
// 5. Handle streaming
|
||||
// Keep stream parameter from request
|
||||
if (anthropicRequest.stream !== undefined) {
|
||||
openaiRequest.stream = anthropicRequest.stream;
|
||||
}
|
||||
|
||||
// 6. Inject reasoning parameters
|
||||
@@ -421,6 +423,251 @@ class GlmtTransformer {
|
||||
return { checks, passed, total, valid: passed === total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform OpenAI streaming delta to Anthropic events
|
||||
* @param {Object} openaiEvent - Parsed SSE event from Z.AI
|
||||
* @param {DeltaAccumulator} accumulator - State accumulator
|
||||
* @returns {Array<Object>} Array of Anthropic SSE events
|
||||
*/
|
||||
transformDelta(openaiEvent, accumulator) {
|
||||
const events = [];
|
||||
|
||||
// Handle [DONE] marker
|
||||
if (openaiEvent.event === 'done') {
|
||||
return this.finalizeDelta(accumulator);
|
||||
}
|
||||
|
||||
const choice = openaiEvent.data?.choices?.[0];
|
||||
if (!choice) return events;
|
||||
|
||||
const delta = choice.delta;
|
||||
if (!delta) return events;
|
||||
|
||||
// Message start
|
||||
if (!accumulator.messageStarted) {
|
||||
if (openaiEvent.data.model) {
|
||||
accumulator.model = openaiEvent.data.model;
|
||||
}
|
||||
events.push(this._createMessageStartEvent(accumulator));
|
||||
accumulator.messageStarted = true;
|
||||
}
|
||||
|
||||
// Role
|
||||
if (delta.role) {
|
||||
accumulator.role = delta.role;
|
||||
}
|
||||
|
||||
// Reasoning content delta (Z.AI streams incrementally - confirmed in Phase 02)
|
||||
if (delta.reasoning_content) {
|
||||
const currentBlock = accumulator.getCurrentBlock();
|
||||
|
||||
if (!currentBlock || currentBlock.type !== 'thinking') {
|
||||
// Start thinking block
|
||||
const block = accumulator.startBlock('thinking');
|
||||
events.push(this._createContentBlockStartEvent(block));
|
||||
}
|
||||
|
||||
accumulator.addDelta(delta.reasoning_content);
|
||||
events.push(this._createThinkingDeltaEvent(
|
||||
accumulator.getCurrentBlock(),
|
||||
delta.reasoning_content
|
||||
));
|
||||
}
|
||||
|
||||
// Text content delta
|
||||
if (delta.content) {
|
||||
const currentBlock = accumulator.getCurrentBlock();
|
||||
|
||||
// Close thinking block if transitioning from thinking to text
|
||||
if (currentBlock && currentBlock.type === 'thinking' && !currentBlock.stopped) {
|
||||
events.push(this._createSignatureDeltaEvent(currentBlock));
|
||||
events.push(this._createContentBlockStopEvent(currentBlock));
|
||||
accumulator.stopCurrentBlock();
|
||||
}
|
||||
|
||||
if (!accumulator.getCurrentBlock() || accumulator.getCurrentBlock().type !== 'text') {
|
||||
// Start text block
|
||||
const block = accumulator.startBlock('text');
|
||||
events.push(this._createContentBlockStartEvent(block));
|
||||
}
|
||||
|
||||
accumulator.addDelta(delta.content);
|
||||
events.push(this._createTextDeltaEvent(
|
||||
accumulator.getCurrentBlock(),
|
||||
delta.content
|
||||
));
|
||||
}
|
||||
|
||||
// Usage update (appears in final chunk usually)
|
||||
if (openaiEvent.data.usage) {
|
||||
accumulator.updateUsage(openaiEvent.data.usage);
|
||||
}
|
||||
|
||||
// Finish reason
|
||||
if (choice.finish_reason) {
|
||||
accumulator.finishReason = choice.finish_reason;
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize streaming and generate closing events
|
||||
* @param {DeltaAccumulator} accumulator - State accumulator
|
||||
* @returns {Array<Object>} Final Anthropic SSE events
|
||||
*/
|
||||
finalizeDelta(accumulator) {
|
||||
if (accumulator.finalized) {
|
||||
return []; // Already finalized
|
||||
}
|
||||
|
||||
const events = [];
|
||||
|
||||
// Close current content block if any
|
||||
const currentBlock = accumulator.getCurrentBlock();
|
||||
if (currentBlock && !currentBlock.stopped) {
|
||||
if (currentBlock.type === 'thinking') {
|
||||
events.push(this._createSignatureDeltaEvent(currentBlock));
|
||||
}
|
||||
events.push(this._createContentBlockStopEvent(currentBlock));
|
||||
accumulator.stopCurrentBlock();
|
||||
}
|
||||
|
||||
// Message delta (stop reason + usage)
|
||||
events.push({
|
||||
event: 'message_delta',
|
||||
data: {
|
||||
type: 'message_delta',
|
||||
delta: {
|
||||
stop_reason: this._mapStopReason(accumulator.finishReason || 'stop')
|
||||
},
|
||||
usage: {
|
||||
output_tokens: accumulator.outputTokens
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Message stop
|
||||
events.push({
|
||||
event: 'message_stop',
|
||||
data: {
|
||||
type: 'message_stop'
|
||||
}
|
||||
});
|
||||
|
||||
accumulator.finalized = true;
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create message_start event
|
||||
* @private
|
||||
*/
|
||||
_createMessageStartEvent(accumulator) {
|
||||
return {
|
||||
event: 'message_start',
|
||||
data: {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: accumulator.messageId,
|
||||
type: 'message',
|
||||
role: accumulator.role,
|
||||
content: [],
|
||||
model: accumulator.model || 'glm-4.6',
|
||||
stop_reason: null,
|
||||
usage: {
|
||||
input_tokens: accumulator.inputTokens,
|
||||
output_tokens: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create content_block_start event
|
||||
* @private
|
||||
*/
|
||||
_createContentBlockStartEvent(block) {
|
||||
return {
|
||||
event: 'content_block_start',
|
||||
data: {
|
||||
type: 'content_block_start',
|
||||
index: block.index,
|
||||
content_block: {
|
||||
type: block.type,
|
||||
[block.type]: ''
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create thinking_delta event
|
||||
* @private
|
||||
*/
|
||||
_createThinkingDeltaEvent(block, delta) {
|
||||
return {
|
||||
event: 'content_block_delta',
|
||||
data: {
|
||||
type: 'content_block_delta',
|
||||
index: block.index,
|
||||
delta: {
|
||||
type: 'thinking_delta',
|
||||
thinking: delta
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create text_delta event
|
||||
* @private
|
||||
*/
|
||||
_createTextDeltaEvent(block, delta) {
|
||||
return {
|
||||
event: 'content_block_delta',
|
||||
data: {
|
||||
type: 'content_block_delta',
|
||||
index: block.index,
|
||||
delta: {
|
||||
type: 'text_delta',
|
||||
text: delta
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create signature_delta event
|
||||
* @private
|
||||
*/
|
||||
_createSignatureDeltaEvent(block) {
|
||||
const signature = this._generateThinkingSignature(block.content);
|
||||
return {
|
||||
event: 'signature_delta',
|
||||
data: {
|
||||
type: 'signature_delta',
|
||||
index: block.index,
|
||||
signature: signature
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create content_block_stop event
|
||||
* @private
|
||||
*/
|
||||
_createContentBlockStopEvent(block) {
|
||||
return {
|
||||
event: 'content_block_stop',
|
||||
data: {
|
||||
type: 'content_block_stop',
|
||||
index: block.index
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Log message if verbose
|
||||
* @param {string} message - Message to log
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* SSEParser - Parse Server-Sent Events (SSE) stream
|
||||
*
|
||||
* Handles:
|
||||
* - Incomplete events across chunks
|
||||
* - Multiple events in single chunk
|
||||
* - Malformed data (skip gracefully)
|
||||
* - [DONE] marker
|
||||
*
|
||||
* Usage:
|
||||
* const parser = new SSEParser();
|
||||
* stream.on('data', chunk => {
|
||||
* const events = parser.parse(chunk);
|
||||
* events.forEach(event => { ... });
|
||||
* });
|
||||
*/
|
||||
class SSEParser {
|
||||
constructor(options = {}) {
|
||||
this.buffer = '';
|
||||
this.eventCount = 0;
|
||||
this.maxBufferSize = options.maxBufferSize || 1024 * 1024; // 1MB default
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse chunk and extract SSE events
|
||||
* @param {Buffer|string} chunk - Data chunk from stream
|
||||
* @returns {Array<Object>} Array of parsed events
|
||||
*/
|
||||
parse(chunk) {
|
||||
this.buffer += chunk.toString();
|
||||
|
||||
// C-01 Fix: Prevent unbounded buffer growth (DoS protection)
|
||||
if (this.buffer.length > this.maxBufferSize) {
|
||||
throw new Error(`SSE buffer exceeded ${this.maxBufferSize} bytes (DoS protection)`);
|
||||
}
|
||||
|
||||
const lines = this.buffer.split('\n');
|
||||
|
||||
// Keep incomplete line in buffer
|
||||
this.buffer = lines.pop() || '';
|
||||
|
||||
const events = [];
|
||||
let currentEvent = { event: 'message', data: '' };
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
currentEvent.event = line.substring(7).trim();
|
||||
} else if (line.startsWith('data: ')) {
|
||||
const data = line.substring(6);
|
||||
|
||||
if (data === '[DONE]') {
|
||||
this.eventCount++;
|
||||
events.push({
|
||||
event: 'done',
|
||||
data: null,
|
||||
index: this.eventCount
|
||||
});
|
||||
currentEvent = { event: 'message', data: '' };
|
||||
} else {
|
||||
try {
|
||||
currentEvent.data = JSON.parse(data);
|
||||
this.eventCount++;
|
||||
currentEvent.index = this.eventCount;
|
||||
events.push(currentEvent);
|
||||
currentEvent = { event: 'message', data: '' };
|
||||
} catch (e) {
|
||||
// H-01 Fix: Log parse errors for debugging
|
||||
if (typeof console !== 'undefined' && console.error) {
|
||||
console.error('[SSEParser] Malformed JSON event:', e.message, 'Data:', data.substring(0, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (line.startsWith('id: ')) {
|
||||
currentEvent.id = line.substring(4).trim();
|
||||
} else if (line.startsWith('retry: ')) {
|
||||
currentEvent.retry = parseInt(line.substring(7), 10);
|
||||
}
|
||||
// Empty lines separate events (already handled by JSON parsing)
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset parser state (for reuse)
|
||||
*/
|
||||
reset() {
|
||||
this.buffer = '';
|
||||
this.eventCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SSEParser;
|
||||
@@ -31,7 +31,7 @@ $InstallMethod = if ($ScriptDir -and ((Test-Path "$ScriptDir\lib\ccs.ps1") -or (
|
||||
# IMPORTANT: Update this version when releasing new versions!
|
||||
# This hardcoded version is used for standalone installations (irm | iex)
|
||||
# For git installations, VERSION file is read if available
|
||||
$CcsVersion = "3.3.0"
|
||||
$CcsVersion = "3.4.0"
|
||||
|
||||
# Try to read VERSION file for git installations
|
||||
if ($ScriptDir) {
|
||||
|
||||
@@ -32,7 +32,7 @@ fi
|
||||
# IMPORTANT: Update this version when releasing new versions!
|
||||
# This hardcoded version is used for standalone installations (curl | bash)
|
||||
# For git installations, VERSION file is read if available
|
||||
CCS_VERSION="3.3.0"
|
||||
CCS_VERSION="3.4.0"
|
||||
|
||||
# Try to read VERSION file for git installations
|
||||
if [[ -f "$SCRIPT_DIR/VERSION" ]]; then
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
# Version (updated by scripts/bump-version.sh)
|
||||
CCS_VERSION="3.3.0"
|
||||
CCS_VERSION="3.4.0"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly CONFIG_FILE="${CCS_CONFIG:-$HOME/.ccs/config.json}"
|
||||
readonly PROFILES_JSON="$HOME/.ccs/profiles.json"
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ param(
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Version (updated by scripts/bump-version.sh)
|
||||
$CcsVersion = "3.3.0"
|
||||
$CcsVersion = "3.4.0"
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$ConfigFile = if ($env:CCS_CONFIG) { $env:CCS_CONFIG } else { "$env:USERPROFILE\.ccs\config.json" }
|
||||
$ProfilesJson = "$env:USERPROFILE\.ccs\profiles.json"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "3.3.0",
|
||||
"version": "3.4.0",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
Executable
+178
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const DeltaAccumulator = require('../bin/delta-accumulator');
|
||||
|
||||
console.log('[TEST] DeltaAccumulator unit tests');
|
||||
console.log('');
|
||||
|
||||
let passedTests = 0;
|
||||
let failedTests = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(`[PASS] ${name}`);
|
||||
passedTests++;
|
||||
} catch (error) {
|
||||
console.log(`[FAIL] ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
failedTests++;
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message || 'Assertion failed');
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Initialization
|
||||
test('Initial state', () => {
|
||||
const acc = new DeltaAccumulator();
|
||||
assert(acc.messageId.startsWith('msg_'), 'Message ID should start with msg_');
|
||||
assert(acc.role === 'assistant', 'Default role should be assistant');
|
||||
assert(acc.contentBlocks.length === 0, 'Should have no blocks initially');
|
||||
assert(acc.currentBlockIndex === -1, 'Current index should be -1');
|
||||
assert(!acc.messageStarted, 'Message should not be started');
|
||||
assert(!acc.finalized, 'Should not be finalized');
|
||||
});
|
||||
|
||||
// Test: Start thinking block
|
||||
test('Start thinking block', () => {
|
||||
const acc = new DeltaAccumulator();
|
||||
const block = acc.startBlock('thinking');
|
||||
assert(block.type === 'thinking', 'Block type should be thinking');
|
||||
assert(block.index === 0, 'First block index should be 0');
|
||||
assert(block.started === true, 'Block should be marked as started');
|
||||
assert(block.stopped === false, 'Block should not be stopped');
|
||||
assert(acc.contentBlocks.length === 1, 'Should have 1 block');
|
||||
assert(acc.currentBlockIndex === 0, 'Current index should be 0');
|
||||
});
|
||||
|
||||
// Test: Add delta to thinking block
|
||||
test('Add delta to thinking block', () => {
|
||||
const acc = new DeltaAccumulator();
|
||||
acc.startBlock('thinking');
|
||||
acc.addDelta('Hello ');
|
||||
acc.addDelta('world');
|
||||
const block = acc.getCurrentBlock();
|
||||
assert(block.content === 'Hello world', `Expected 'Hello world', got '${block.content}'`);
|
||||
assert(acc.thinkingBuffer === 'Hello world', 'Thinking buffer should match');
|
||||
});
|
||||
|
||||
// Test: Start text block
|
||||
test('Start text block', () => {
|
||||
const acc = new DeltaAccumulator();
|
||||
const block = acc.startBlock('text');
|
||||
assert(block.type === 'text', 'Block type should be text');
|
||||
assert(block.index === 0, 'First block index should be 0');
|
||||
});
|
||||
|
||||
// Test: Add delta to text block
|
||||
test('Add delta to text block', () => {
|
||||
const acc = new DeltaAccumulator();
|
||||
acc.startBlock('text');
|
||||
acc.addDelta('Answer: ');
|
||||
acc.addDelta('42');
|
||||
const block = acc.getCurrentBlock();
|
||||
assert(block.content === 'Answer: 42', `Expected 'Answer: 42', got '${block.content}'`);
|
||||
assert(acc.textBuffer === 'Answer: 42', 'Text buffer should match');
|
||||
});
|
||||
|
||||
// Test: Multiple blocks
|
||||
test('Multiple blocks (thinking → text)', () => {
|
||||
const acc = new DeltaAccumulator();
|
||||
|
||||
// Thinking block
|
||||
acc.startBlock('thinking');
|
||||
acc.addDelta('Analyzing...');
|
||||
|
||||
// Text block
|
||||
acc.startBlock('text');
|
||||
acc.addDelta('The answer is 42');
|
||||
|
||||
assert(acc.contentBlocks.length === 2, 'Should have 2 blocks');
|
||||
assert(acc.currentBlockIndex === 1, 'Current index should be 1');
|
||||
assert(acc.contentBlocks[0].type === 'thinking', 'First block should be thinking');
|
||||
assert(acc.contentBlocks[1].type === 'text', 'Second block should be text');
|
||||
assert(acc.contentBlocks[0].content === 'Analyzing...', 'Thinking content incorrect');
|
||||
assert(acc.contentBlocks[1].content === 'The answer is 42', 'Text content incorrect');
|
||||
});
|
||||
|
||||
// Test: Stop current block
|
||||
test('Stop current block', () => {
|
||||
const acc = new DeltaAccumulator();
|
||||
acc.startBlock('thinking');
|
||||
acc.addDelta('Done');
|
||||
acc.stopCurrentBlock();
|
||||
const block = acc.getCurrentBlock();
|
||||
assert(block.stopped === true, 'Block should be marked as stopped');
|
||||
});
|
||||
|
||||
// Test: Get current block (no blocks)
|
||||
test('Get current block when none exist', () => {
|
||||
const acc = new DeltaAccumulator();
|
||||
const block = acc.getCurrentBlock();
|
||||
assert(block === null, 'Should return null when no blocks');
|
||||
});
|
||||
|
||||
// Test: Update usage statistics
|
||||
test('Update usage statistics', () => {
|
||||
const acc = new DeltaAccumulator();
|
||||
acc.updateUsage({ prompt_tokens: 100, completion_tokens: 50 });
|
||||
assert(acc.inputTokens === 100, `Expected 100, got ${acc.inputTokens}`);
|
||||
assert(acc.outputTokens === 50, `Expected 50, got ${acc.outputTokens}`);
|
||||
});
|
||||
|
||||
// Test: Get summary
|
||||
test('Get summary', () => {
|
||||
const acc = new DeltaAccumulator();
|
||||
acc.model = 'glm-4.6';
|
||||
acc.startBlock('thinking');
|
||||
acc.addDelta('test');
|
||||
acc.updateUsage({ prompt_tokens: 10, completion_tokens: 20 });
|
||||
|
||||
const summary = acc.getSummary();
|
||||
assert(summary.messageId === acc.messageId, 'Message ID mismatch');
|
||||
assert(summary.model === 'glm-4.6', 'Model mismatch');
|
||||
assert(summary.role === 'assistant', 'Role mismatch');
|
||||
assert(summary.blockCount === 1, `Expected 1 block, got ${summary.blockCount}`);
|
||||
assert(summary.usage.input_tokens === 10, 'Input tokens mismatch');
|
||||
assert(summary.usage.output_tokens === 20, 'Output tokens mismatch');
|
||||
});
|
||||
|
||||
// Test: Thinking config preservation
|
||||
test('Thinking config preservation', () => {
|
||||
const config = { thinking: true, effort: 'high' };
|
||||
const acc = new DeltaAccumulator(config);
|
||||
assert(acc.thinkingConfig.thinking === true, 'Thinking config not preserved');
|
||||
assert(acc.thinkingConfig.effort === 'high', 'Effort config not preserved');
|
||||
});
|
||||
|
||||
// Test: Message lifecycle flags
|
||||
test('Message lifecycle flags', () => {
|
||||
const acc = new DeltaAccumulator();
|
||||
assert(!acc.messageStarted, 'Should not be started initially');
|
||||
acc.messageStarted = true;
|
||||
assert(acc.messageStarted, 'Should be marked as started');
|
||||
acc.finalized = true;
|
||||
assert(acc.finalized, 'Should be marked as finalized');
|
||||
});
|
||||
|
||||
// Test: Finish reason tracking
|
||||
test('Finish reason tracking', () => {
|
||||
const acc = new DeltaAccumulator();
|
||||
assert(acc.finishReason === null, 'Finish reason should be null initially');
|
||||
acc.finishReason = 'stop';
|
||||
assert(acc.finishReason === 'stop', 'Finish reason should be updated');
|
||||
});
|
||||
|
||||
console.log('');
|
||||
console.log('═══════════════════════════════════════');
|
||||
console.log(`TESTS: ${passedTests} passed, ${failedTests} failed`);
|
||||
console.log('═══════════════════════════════════════');
|
||||
|
||||
if (failedTests > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -272,7 +272,7 @@ runner.test('disables streaming (not yet supported)', () => {
|
||||
|
||||
const { openaiRequest } = transformer.transformRequest(input);
|
||||
|
||||
assertEqual(openaiRequest.stream, false, 'stream should be disabled');
|
||||
assertEqual(openaiRequest.stream, true, 'stream should be enabled when requested');
|
||||
});
|
||||
|
||||
// Test 13: Debug mode disabled by default
|
||||
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const SSEParser = require('../bin/sse-parser');
|
||||
|
||||
console.log('[TEST] SSEParser unit tests');
|
||||
console.log('');
|
||||
|
||||
let passedTests = 0;
|
||||
let failedTests = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(`[PASS] ${name}`);
|
||||
passedTests++;
|
||||
} catch (error) {
|
||||
console.log(`[FAIL] ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
failedTests++;
|
||||
}
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message || 'Assertion failed');
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Single event
|
||||
test('Single event parsing', () => {
|
||||
const parser = new SSEParser();
|
||||
const events = parser.parse('data: {"test": "value"}\n\n');
|
||||
assert(events.length === 1, `Expected 1 event, got ${events.length}`);
|
||||
assert(events[0].data.test === 'value', `Expected test=value, got ${events[0].data.test}`);
|
||||
});
|
||||
|
||||
// Test: Multiple events
|
||||
test('Multiple events in one chunk', () => {
|
||||
const parser = new SSEParser();
|
||||
const events = parser.parse('data: {"a": 1}\n\ndata: {"b": 2}\n\n');
|
||||
assert(events.length === 2, `Expected 2 events, got ${events.length}`);
|
||||
assert(events[0].data.a === 1, 'First event data incorrect');
|
||||
assert(events[1].data.b === 2, 'Second event data incorrect');
|
||||
});
|
||||
|
||||
// Test: Split across chunks
|
||||
test('Event split across chunks', () => {
|
||||
const parser = new SSEParser();
|
||||
const events1 = parser.parse('data: {"test":');
|
||||
assert(events1.length === 0, 'Should not emit incomplete event');
|
||||
const events2 = parser.parse('"value"}\n\n');
|
||||
assert(events2.length === 1, `Expected 1 event after completion, got ${events2.length}`);
|
||||
assert(events2[0].data.test === 'value', 'Split event data incorrect');
|
||||
});
|
||||
|
||||
// Test: [DONE] marker
|
||||
test('[DONE] marker detection', () => {
|
||||
const parser = new SSEParser();
|
||||
const events = parser.parse('data: [DONE]\n\n');
|
||||
assert(events.length === 1, `Expected 1 event, got ${events.length}`);
|
||||
assert(events[0].event === 'done', `Expected event=done, got ${events[0].event}`);
|
||||
assert(events[0].data === null, 'Expected null data for [DONE]');
|
||||
});
|
||||
|
||||
// Test: Mixed content and [DONE]
|
||||
test('Mixed events with [DONE]', () => {
|
||||
const parser = new SSEParser();
|
||||
const events = parser.parse('data: {"msg": "hello"}\n\ndata: [DONE]\n\n');
|
||||
assert(events.length === 2, `Expected 2 events, got ${events.length}`);
|
||||
assert(events[0].data.msg === 'hello', 'First event data incorrect');
|
||||
assert(events[1].event === 'done', 'Second event should be done');
|
||||
});
|
||||
|
||||
// Test: Malformed JSON (should skip gracefully)
|
||||
test('Malformed JSON handling', () => {
|
||||
const parser = new SSEParser();
|
||||
const events = parser.parse('data: {invalid json}\n\ndata: {"valid": true}\n\n');
|
||||
// Should skip malformed, parse valid
|
||||
assert(events.length === 1, `Expected 1 valid event, got ${events.length}`);
|
||||
assert(events[0].data.valid === true, 'Valid event data incorrect');
|
||||
});
|
||||
|
||||
// Test: Empty lines handling
|
||||
test('Empty lines between events', () => {
|
||||
const parser = new SSEParser();
|
||||
const events = parser.parse('data: {"a": 1}\n\n\n\ndata: {"b": 2}\n\n');
|
||||
assert(events.length === 2, `Expected 2 events, got ${events.length}`);
|
||||
});
|
||||
|
||||
// Test: Event with ID field
|
||||
test('Event with ID field', () => {
|
||||
const parser = new SSEParser();
|
||||
const events = parser.parse('id: 123\ndata: {"test": true}\n\n');
|
||||
assert(events.length === 1, `Expected 1 event, got ${events.length}`);
|
||||
assert(events[0].id === '123', `Expected id=123, got ${events[0].id}`);
|
||||
assert(events[0].data.test === true, 'Event data incorrect');
|
||||
});
|
||||
|
||||
// Test: Custom event type
|
||||
test('Custom event type', () => {
|
||||
const parser = new SSEParser();
|
||||
const events = parser.parse('event: custom\ndata: {"test": true}\n\n');
|
||||
assert(events.length === 1, `Expected 1 event, got ${events.length}`);
|
||||
assert(events[0].event === 'custom', `Expected event=custom, got ${events[0].event}`);
|
||||
});
|
||||
|
||||
// Test: Reset functionality
|
||||
test('Parser reset', () => {
|
||||
const parser = new SSEParser();
|
||||
parser.parse('data: {"test": 1}\n\n');
|
||||
assert(parser.eventCount === 1, 'Event count should be 1');
|
||||
parser.reset();
|
||||
assert(parser.eventCount === 0, 'Event count should be 0 after reset');
|
||||
assert(parser.buffer === '', 'Buffer should be empty after reset');
|
||||
});
|
||||
|
||||
// Test: Real Z.AI stream format
|
||||
test('Real Z.AI stream format', () => {
|
||||
const parser = new SSEParser();
|
||||
const chunk = 'data: {"choices":[{"delta":{"role":"assistant","reasoning_content":"test"}}]}\n\n';
|
||||
const events = parser.parse(chunk);
|
||||
assert(events.length === 1, `Expected 1 event, got ${events.length}`);
|
||||
assert(events[0].data.choices[0].delta.reasoning_content === 'test', 'Z.AI format parsing incorrect');
|
||||
});
|
||||
|
||||
// Test: Multiple Z.AI deltas
|
||||
test('Multiple Z.AI deltas', () => {
|
||||
const parser = new SSEParser();
|
||||
const chunk = 'data: {"choices":[{"delta":{"reasoning_content":"chunk1"}}]}\n\ndata: {"choices":[{"delta":{"reasoning_content":"chunk2"}}]}\n\n';
|
||||
const events = parser.parse(chunk);
|
||||
assert(events.length === 2, `Expected 2 events, got ${events.length}`);
|
||||
assert(events[0].data.choices[0].delta.reasoning_content === 'chunk1', 'First delta incorrect');
|
||||
assert(events[1].data.choices[0].delta.reasoning_content === 'chunk2', 'Second delta incorrect');
|
||||
});
|
||||
|
||||
console.log('');
|
||||
console.log('═══════════════════════════════════════');
|
||||
console.log(`TESTS: ${passedTests} passed, ${failedTests} failed`);
|
||||
console.log('═══════════════════════════════════════');
|
||||
|
||||
if (failedTests > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
Executable
+230
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const https = require('https');
|
||||
|
||||
const API_KEY = process.env.Z_AI_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN;
|
||||
const MODEL = 'GLM-4.6';
|
||||
|
||||
if (!API_KEY || API_KEY === 'your-api-key-here') {
|
||||
console.error('[ERROR] Z.AI API key not found');
|
||||
console.error('[INFO] Set Z_AI_API_KEY or ANTHROPIC_AUTH_TOKEN environment variable');
|
||||
console.error('[INFO] Example: export Z_AI_API_KEY=your-key-here');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Test request with reasoning
|
||||
const requestBody = JSON.stringify({
|
||||
model: MODEL,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Solve this math problem step by step: What is 27 * 453? Show your reasoning process.'
|
||||
}
|
||||
],
|
||||
stream: true,
|
||||
reasoning: true,
|
||||
reasoning_effort: 'medium',
|
||||
max_tokens: 4096,
|
||||
do_sample: true
|
||||
});
|
||||
|
||||
const options = {
|
||||
hostname: 'api.z.ai',
|
||||
port: 443,
|
||||
path: '/api/coding/paas/v4/chat/completions',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${API_KEY}`,
|
||||
'Content-Length': Buffer.byteLength(requestBody),
|
||||
'User-Agent': 'CCS-GLMT-StreamingTest/1.0'
|
||||
}
|
||||
};
|
||||
|
||||
console.log('═══════════════════════════════════════════════════════════════');
|
||||
console.log('Z.AI STREAMING BEHAVIOR TEST');
|
||||
console.log('═══════════════════════════════════════════════════════════════');
|
||||
console.log('[TEST] Initiating streaming request to Z.AI');
|
||||
console.log('[TEST] Model:', MODEL);
|
||||
console.log('[TEST] Stream: true');
|
||||
console.log('[TEST] Reasoning: true');
|
||||
console.log('[TEST] Reasoning Effort: medium');
|
||||
console.log('[TEST] API Key:', API_KEY.substring(0, 10) + '...');
|
||||
console.log('');
|
||||
|
||||
const startTime = Date.now();
|
||||
let firstByteTime = null;
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
console.log('[HTTP] Status:', res.statusCode);
|
||||
console.log('[HTTP] Content-Type:', res.headers['content-type']);
|
||||
console.log('');
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
let errorBody = '';
|
||||
res.on('data', chunk => errorBody += chunk);
|
||||
res.on('end', () => {
|
||||
console.error('[ERROR] Request failed:', res.statusCode, res.statusMessage);
|
||||
console.error('[ERROR] Response:', errorBody);
|
||||
process.exit(1);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('═══════════════════════════════════════════════════════════════');
|
||||
console.log('SSE EVENTS (analyzing streaming behavior)');
|
||||
console.log('═══════════════════════════════════════════════════════════════');
|
||||
console.log('');
|
||||
|
||||
let buffer = '';
|
||||
let eventCount = 0;
|
||||
let reasoningEventCount = 0;
|
||||
let reasoningInDelta = false;
|
||||
let reasoningInMessage = false;
|
||||
let totalReasoningChunks = [];
|
||||
let textEventCount = 0;
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
if (!firstByteTime) {
|
||||
firstByteTime = Date.now();
|
||||
console.log('[TIMING] Time to first byte:', firstByteTime - startTime, 'ms');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
buffer += chunk.toString();
|
||||
const lines = buffer.split('\n');
|
||||
|
||||
// Keep incomplete line in buffer
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
lines.forEach(line => {
|
||||
if (line.startsWith('data: ')) {
|
||||
eventCount++;
|
||||
const data = line.substring(6);
|
||||
|
||||
if (data === '[DONE]') {
|
||||
console.log(`[EVENT ${eventCount}] [DONE]`);
|
||||
console.log('');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const choice = parsed.choices?.[0];
|
||||
const delta = choice?.delta;
|
||||
const message = choice?.message;
|
||||
|
||||
// Check for reasoning_content in delta
|
||||
if (delta?.reasoning_content) {
|
||||
reasoningInDelta = true;
|
||||
reasoningEventCount++;
|
||||
totalReasoningChunks.push(delta.reasoning_content);
|
||||
|
||||
console.log(`[EVENT ${eventCount}] *** REASONING IN DELTA ***`);
|
||||
console.log(' Delta keys:', Object.keys(delta).join(', '));
|
||||
console.log(' Reasoning chunk length:', delta.reasoning_content.length);
|
||||
console.log(' Reasoning chunk preview:', JSON.stringify(delta.reasoning_content.substring(0, 80)));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Check for reasoning_content in message (complete)
|
||||
if (message?.reasoning_content) {
|
||||
reasoningInMessage = true;
|
||||
console.log(`[EVENT ${eventCount}] *** REASONING IN MESSAGE (COMPLETE) ***`);
|
||||
console.log(' Message keys:', Object.keys(message).join(', '));
|
||||
console.log(' Reasoning total length:', message.reasoning_content.length);
|
||||
console.log(' Reasoning preview:', message.reasoning_content.substring(0, 100).replace(/\n/g, ' '));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Check for text content in delta
|
||||
if (delta?.content) {
|
||||
textEventCount++;
|
||||
if (textEventCount <= 3 || eventCount % 10 === 0) {
|
||||
console.log(`[EVENT ${eventCount}] Text delta`);
|
||||
console.log(' Content:', JSON.stringify(delta.content));
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
// Show finish_reason
|
||||
if (choice?.finish_reason) {
|
||||
console.log(`[EVENT ${eventCount}] Finish reason: ${choice.finish_reason}`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Show usage stats
|
||||
if (parsed.usage) {
|
||||
console.log(`[EVENT ${eventCount}] Usage:`, JSON.stringify(parsed.usage));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.log(`[EVENT ${eventCount}] [PARSE ERROR]`, e.message);
|
||||
console.log(' Raw:', data.substring(0, 100));
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
const totalTime = Date.now() - startTime;
|
||||
|
||||
console.log('═══════════════════════════════════════════════════════════════');
|
||||
console.log('TEST RESULTS');
|
||||
console.log('═══════════════════════════════════════════════════════════════');
|
||||
console.log('');
|
||||
console.log('[TIMING]');
|
||||
console.log(' Time to first byte:', firstByteTime - startTime, 'ms');
|
||||
console.log(' Total duration:', totalTime, 'ms');
|
||||
console.log('');
|
||||
console.log('[EVENTS]');
|
||||
console.log(' Total events:', eventCount);
|
||||
console.log(' Text content events:', textEventCount);
|
||||
console.log(' Reasoning events:', reasoningEventCount);
|
||||
console.log('');
|
||||
console.log('[REASONING BEHAVIOR]');
|
||||
console.log(' Reasoning in delta (incremental):', reasoningInDelta);
|
||||
console.log(' Reasoning in message (complete):', reasoningInMessage);
|
||||
if (totalReasoningChunks.length > 0) {
|
||||
const totalReasoningLength = totalReasoningChunks.join('').length;
|
||||
console.log(' Total reasoning chunks:', totalReasoningChunks.length);
|
||||
console.log(' Total reasoning length:', totalReasoningLength);
|
||||
}
|
||||
console.log('');
|
||||
console.log('═══════════════════════════════════════════════════════════════');
|
||||
console.log('SCENARIO DETERMINATION');
|
||||
console.log('═══════════════════════════════════════════════════════════════');
|
||||
|
||||
if (reasoningInDelta) {
|
||||
console.log('');
|
||||
console.log('[✓] SCENARIO A: Incremental Streaming');
|
||||
console.log(' reasoning_content appears in delta objects');
|
||||
console.log(' Multiple events contain reasoning chunks');
|
||||
console.log(' RECOMMENDATION: Proceed with full streaming implementation');
|
||||
} else if (reasoningInMessage) {
|
||||
console.log('');
|
||||
console.log('[!] SCENARIO B: Complete at End');
|
||||
console.log(' reasoning_content only in final message object');
|
||||
console.log(' No intermediate reasoning chunks');
|
||||
console.log(' RECOMMENDATION: Hybrid streaming (text streams, reasoning buffered)');
|
||||
} else {
|
||||
console.log('');
|
||||
console.log('[X] SCENARIO C: No Streaming Support');
|
||||
console.log(' No reasoning_content in streaming mode');
|
||||
console.log(' RECOMMENDATION: Keep buffered mode only');
|
||||
}
|
||||
console.log('');
|
||||
console.log('═══════════════════════════════════════════════════════════════');
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
console.error('[ERROR]', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
req.write(requestBody);
|
||||
req.end();
|
||||
Reference in New Issue
Block a user