feat(glmt): convert GLMT system to TypeScript

- Add reasoning-enforcer.ts with 4 effort-level prompts
- Add glmt-transformer.ts (~1000 LOC) for Anthropic/OpenAI format conversion
- Add glmt-proxy.ts (~530 LOC) HTTP proxy with SSE streaming
- Extend delta-accumulator.ts with state getters/setters for streaming
- All 39 tests passing
This commit is contained in:
kaitranntt
2025-11-26 19:57:58 -05:00
parent ea6695f4a9
commit 3cb52e6917
4 changed files with 1968 additions and 0 deletions
+133
View File
@@ -346,4 +346,137 @@ export class DeltaAccumulator {
}
};
}
// ========== State Getters ==========
/**
* Check if message has been finalized
*/
isFinalized(): boolean {
return this.finalized;
}
/**
* Check if message has started
*/
isMessageStarted(): boolean {
return this.messageStarted;
}
/**
* Get message ID
*/
getMessageId(): string {
return this.messageId;
}
/**
* Get model name
*/
getModel(): string | null {
return this.model;
}
/**
* Get role
*/
getRole(): string {
return this.role;
}
/**
* Get input tokens
*/
getInputTokens(): number {
return this.inputTokens;
}
/**
* Get output tokens
*/
getOutputTokens(): number {
return this.outputTokens;
}
// ========== State Setters ==========
/**
* Set model name
*/
setModel(model: string): void {
this.model = model;
}
/**
* Set message started flag
*/
setMessageStarted(started: boolean): void {
this.messageStarted = started;
}
/**
* Set role
*/
setRole(role: string): void {
this.role = role;
}
/**
* Set finalized flag
*/
setFinalized(finalized: boolean): void {
this.finalized = finalized;
}
// ========== Finish Reason ==========
private finishReason: string | null = null;
private usageReceived: boolean = false;
/**
* Set finish reason
*/
setFinishReason(reason: string): void {
this.finishReason = reason;
}
/**
* Get finish reason
*/
getFinishReason(): string | null {
return this.finishReason;
}
/**
* Check if usage stats have been received
*/
hasUsageReceived(): boolean {
return this.usageReceived;
}
/**
* Mark usage as received
*/
setUsageReceived(received: boolean): void {
this.usageReceived = received;
}
// ========== Tool Call Helpers ==========
/**
* Check if there are any tool calls, or check if a specific index exists
*/
hasToolCall(index?: number): boolean {
if (index === undefined) {
return this.toolCalls.length > 0;
}
return this.toolCallsIndex[index] !== undefined;
}
/**
* Get tool call by index
*/
getToolCall(index: number): ToolCall | undefined {
return this.toolCallsIndex[index];
}
}
+548
View File
@@ -0,0 +1,548 @@
/**
* GlmtProxy - Embedded HTTP proxy for GLM thinking support
*
* Architecture:
* - Intercepts Claude CLI → Z.AI calls
* - Transforms Anthropic format → OpenAI format
* - Converts reasoning_content → thinking blocks
* - Supports both streaming and buffered modes
*
* Lifecycle:
* - Spawned by bin/ccs.js when 'glmt' profile detected
* - Binds to 127.0.0.1:random_port (security + avoid conflicts)
* - Terminates when parent process exits
*
* Debugging:
* - Verbose: Pass --verbose to see request/response logs
* - Debug: Set CCS_DEBUG=1 to write logs to ~/.ccs/logs/
*/
import * as http from 'http';
import * as https from 'https';
import { GlmtTransformer } from './glmt-transformer';
import { SSEParser } from './sse-parser';
import { DeltaAccumulator } from './delta-accumulator';
interface GlmtProxyConfig {
verbose?: boolean;
debugLog?: boolean;
timeout?: number;
}
interface ThinkingConfig {
thinking: boolean;
effort: string;
}
interface OpenAIRequest {
model: string;
messages: unknown[];
max_tokens?: number;
stream?: boolean;
[key: string]: unknown;
}
interface AnthropicRequest {
model: string;
messages: unknown[];
max_tokens?: number;
stream?: boolean;
thinking?: {
type: 'enabled' | 'disabled';
budget_tokens?: number;
};
[key: string]: unknown;
}
interface OpenAIResponse {
id?: string;
model?: string;
choices?: Array<{
message: {
role: string;
content?: string;
reasoning_content?: string;
tool_calls?: unknown[];
};
finish_reason?: string;
}>;
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
};
}
export class GlmtProxy {
private transformer: GlmtTransformer;
private upstreamUrl: string;
private server: http.Server | null;
private port: number | null;
private verbose: boolean;
private timeout: number;
constructor(config: GlmtProxyConfig = {}) {
this.transformer = new GlmtTransformer({
verbose: config.verbose,
debugLog: config.debugLog || process.env.CCS_DEBUG === '1' || process.env.CCS_DEBUG_LOG === '1'
});
// 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
}
/**
* Start HTTP server on random port
*/
async start(): Promise<number> {
return new Promise((resolve, reject) => {
this.server = http.createServer((req, res) => {
this.handleRequest(req, res);
});
// Bind to 127.0.0.1:0 (random port for security + avoid conflicts)
this.server.listen(0, '127.0.0.1', () => {
const address = this.server!.address();
this.port = typeof address === 'object' && address ? address.port : 0;
// Signal parent process
console.log(`PROXY_READY:${this.port}`);
// Info message (only show in verbose mode)
if (this.verbose) {
console.error(`[glmt] Proxy listening on port ${this.port} (streaming with auto-fallback)`);
}
// Debug mode notice
if ((this.transformer as unknown as { debugLog: boolean }).debugLog) {
console.error(`[glmt] Debug logging enabled: ${(this.transformer as unknown as { debugLogDir: string }).debugLogDir}`);
console.error(`[glmt] WARNING: Debug logs contain full request/response data`);
}
this.log(`Verbose logging enabled`);
resolve(this.port);
});
this.server.on('error', (error) => {
console.error('[glmt-proxy] Server error:', error);
reject(error);
});
});
}
/**
* Handle incoming HTTP request
*/
async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
const startTime = Date.now();
this.log(`Request: ${req.method} ${req.url}`);
try {
// Only accept POST requests
if (req.method !== 'POST') {
res.writeHead(405, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
// Read request body
const body = await this.readBody(req);
this.log(`Request body size: ${body.length} bytes`);
// Parse JSON with error handling
let anthropicRequest: AnthropicRequest;
try {
anthropicRequest = JSON.parse(body);
} catch (jsonError) {
const err = jsonError as Error;
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: {
type: 'invalid_request_error',
message: 'Invalid JSON in request body: ' + err.message
}
}));
return;
}
// Log thinking parameter for debugging
if (anthropicRequest.thinking) {
this.log(`Request contains thinking parameter: ${JSON.stringify(anthropicRequest.thinking)}`);
} else {
this.log(`Request does NOT contain thinking parameter (will use message tags or default)`);
}
// Try streaming first (default), fallback to buffered on error
const useStreaming = anthropicRequest.stream !== false;
if (useStreaming) {
try {
await this.handleStreamingRequest(req, res, anthropicRequest, startTime);
} catch (streamError) {
const err = streamError as Error;
this.log(`Streaming failed: ${err.message}, retrying buffered mode`);
try {
await this.handleBufferedRequest(req, res, anthropicRequest, startTime);
} catch (bufferedError) {
// Both modes failed, propagate error
throw bufferedError;
}
}
} else {
await this.handleBufferedRequest(req, res, anthropicRequest, startTime);
}
} catch (error) {
const err = error as Error;
console.error('[glmt-proxy] Request error:', err.message);
const duration = Date.now() - startTime;
this.log(`Request failed after ${duration}ms: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: {
type: 'proxy_error',
message: err.message
}
}));
}
}
/**
* Handle buffered (non-streaming) request
*/
private async handleBufferedRequest(
_req: http.IncomingMessage,
res: http.ServerResponse,
anthropicRequest: AnthropicRequest,
startTime: number
): Promise<void> {
// Transform to OpenAI format
const { openaiRequest, thinkingConfig } =
this.transformer.transformRequest(anthropicRequest as unknown as Parameters<typeof this.transformer.transformRequest>[0]);
this.log(`Transformed request, thinking: ${thinkingConfig.thinking}`);
// Forward to Z.AI
const openaiResponse = await this.forwardToUpstream(
openaiRequest as unknown as OpenAIRequest,
{}
) as OpenAIResponse;
this.log(`Received response from upstream`);
// Transform back to Anthropic format
const anthropicResponse = this.transformer.transformResponse(
openaiResponse as Parameters<typeof this.transformer.transformResponse>[0],
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: http.IncomingMessage,
res: http.ServerResponse,
anthropicRequest: AnthropicRequest,
startTime: number
): Promise<void> {
this.log('Using streaming mode');
// Transform request
const { openaiRequest, thinkingConfig } =
this.transformer.transformRequest(anthropicRequest as unknown as Parameters<typeof this.transformer.transformRequest>[0]);
// Force streaming
(openaiRequest as 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': '*',
'X-Accel-Buffering': 'no' // Disable proxy buffering
});
// Disable Nagle's algorithm to prevent buffering at socket level
if (res.socket) {
res.socket.setNoDelay(true);
}
this.log('Starting SSE stream to Claude CLI (socket buffering disabled)');
// Forward and stream
await this.forwardAndStreamUpstream(
openaiRequest as unknown as OpenAIRequest,
{},
res,
thinkingConfig,
startTime
);
}
/**
* Read request body
*/
private readBody(req: http.IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
const maxSize = 10 * 1024 * 1024; // 10MB limit
let totalSize = 0;
req.on('data', (chunk: Buffer) => {
totalSize += chunk.length;
if (totalSize > maxSize) {
reject(new Error('Request body too large (max 10MB)'));
return;
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
req.on('error', reject);
});
}
/**
* Forward request to Z.AI upstream
*/
private forwardToUpstream(
openaiRequest: OpenAIRequest,
_originalHeaders: Record<string, string | undefined>
): Promise<unknown> {
return new Promise((resolve, reject) => {
const url = new URL(this.upstreamUrl);
const requestBody = JSON.stringify(openaiRequest);
const options: https.RequestOptions = {
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': process.env.ANTHROPIC_AUTH_TOKEN || '',
'User-Agent': 'CCS-GLMT-Proxy/1.0'
}
};
// Debug logging
this.log(`Forwarding to: ${url.hostname}${url.pathname}`);
// Set timeout
const timeoutHandle = setTimeout(() => {
req.destroy();
reject(new Error('Upstream request timeout'));
}, this.timeout);
const req = https.request(options, (res) => {
clearTimeout(timeoutHandle);
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
try {
const body = Buffer.concat(chunks).toString();
this.log(`Upstream response size: ${body.length} bytes`);
// Check for non-200 status
if (res.statusCode !== 200) {
reject(new Error(
`Upstream error: ${res.statusCode} ${res.statusMessage}\n${body}`
));
return;
}
const response = JSON.parse(body);
resolve(response);
} catch (error) {
const err = error as Error;
reject(new Error('Invalid JSON from upstream: ' + err.message));
}
});
});
req.on('error', (error) => {
clearTimeout(timeoutHandle);
reject(error);
});
req.write(requestBody);
req.end();
});
}
/**
* Forward request to Z.AI and stream response
*/
private async forwardAndStreamUpstream(
openaiRequest: OpenAIRequest,
_originalHeaders: Record<string, string | undefined>,
clientRes: http.ServerResponse,
thinkingConfig: ThinkingConfig,
startTime: number
): Promise<void> {
return new Promise((resolve, reject) => {
const url = new URL(this.upstreamUrl);
const requestBody = JSON.stringify(openaiRequest);
const options: https.RequestOptions = {
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': process.env.ANTHROPIC_AUTH_TOKEN || '',
'User-Agent': 'CCS-GLMT-Proxy/1.0',
'Accept': 'text/event-stream'
}
};
this.log(`Forwarding streaming request to: ${url.hostname}${url.pathname}`);
// 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: Buffer) => body += chunk.toString());
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: Buffer) => {
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 with immediate flush
anthropicEvents.forEach(evt => {
const eventLine = `event: ${evt.event}\n`;
const dataLine = `data: ${JSON.stringify(evt.data)}\n\n`;
clientRes.write(eventLine + dataLine);
// Flush immediately if method available
if (typeof (clientRes as unknown as { flush?: () => void }).flush === 'function') {
(clientRes as unknown as { flush: () => void }).flush();
}
});
});
} catch (error) {
const err = error as Error;
this.log(`Error processing chunk: ${err.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
*/
stop(): void {
if (this.server) {
this.log('Stopping proxy server');
this.server.close();
}
}
/**
* Log message if verbose
*/
private log(message: string): void {
if (this.verbose) {
console.error(`[glmt-proxy] ${message}`);
}
}
}
// Main entry point
if (require.main === module) {
const args = process.argv.slice(2);
const verbose = args.includes('--verbose') || args.includes('-v');
const proxy = new GlmtProxy({ verbose });
proxy.start().catch(error => {
console.error('[glmt-proxy] Failed to start:', error);
process.exit(1);
});
// Cleanup on signals
process.on('SIGTERM', () => {
proxy.stop();
process.exit(0);
});
process.on('SIGINT', () => {
proxy.stop();
process.exit(0);
});
// Keep process alive
process.on('uncaughtException', (error) => {
console.error('[glmt-proxy] Uncaught exception:', error);
proxy.stop();
process.exit(1);
});
}
export default GlmtProxy;
File diff suppressed because it is too large Load Diff
+187
View File
@@ -0,0 +1,187 @@
/**
* ReasoningEnforcer - Inject explicit reasoning instructions into prompts
*
* Purpose: Force GLM models to use structured reasoning output format (<reasoning_content>)
* This complements API parameters (reasoning: true) with explicit prompt instructions.
*
* Strategy:
* 1. If system prompt exists: Prepend reasoning instruction
* 2. If no system prompt: Prepend to first user message
* 3. Select prompt template based on effort level (low/medium/high/max)
* 4. Preserve message structure (string vs array content)
*/
type EffortLevel = 'low' | 'medium' | 'high' | 'max';
interface ContentBlock {
type: string;
text?: string;
[key: string]: unknown;
}
interface Message {
role: string;
content: string | ContentBlock[];
}
interface ThinkingConfig {
thinking?: boolean;
effort?: string;
}
interface ReasoningEnforcerOptions {
enabled?: boolean;
prompts?: Record<EffortLevel, string>;
}
export class ReasoningEnforcer {
private enabled: boolean;
private prompts: Record<EffortLevel, string>;
constructor(options: ReasoningEnforcerOptions = {}) {
this.enabled = options.enabled ?? false; // Opt-in by default
this.prompts = options.prompts || this.getDefaultPrompts();
}
/**
* Inject reasoning instruction into messages
* @param messages - Messages array to modify
* @param thinkingConfig - { thinking: boolean, effort: string }
* @returns Modified messages array
*/
injectInstruction(messages: Message[], thinkingConfig: ThinkingConfig = {}): Message[] {
// Only inject if enabled or thinking explicitly requested
if (!this.enabled && !thinkingConfig.thinking) {
return messages;
}
// Clone messages to avoid mutation
const modifiedMessages: Message[] = JSON.parse(JSON.stringify(messages));
// Select prompt based on effort level
const effort = (thinkingConfig.effort?.toLowerCase() || 'medium') as EffortLevel;
const prompt = this.selectPrompt(effort);
// Strategy 1: Inject into system prompt (preferred)
const systemIndex = modifiedMessages.findIndex(m => m.role === 'system');
if (systemIndex >= 0) {
const systemMsg = modifiedMessages[systemIndex];
if (typeof systemMsg.content === 'string') {
systemMsg.content = `${prompt}\n\n${systemMsg.content}`;
} else if (Array.isArray(systemMsg.content)) {
systemMsg.content.unshift({
type: 'text',
text: prompt
});
}
return modifiedMessages;
}
// Strategy 2: Prepend to first user message
const userIndex = modifiedMessages.findIndex(m => m.role === 'user');
if (userIndex >= 0) {
const userMsg = modifiedMessages[userIndex];
if (typeof userMsg.content === 'string') {
userMsg.content = `${prompt}\n\n${userMsg.content}`;
} else if (Array.isArray(userMsg.content)) {
userMsg.content.unshift({
type: 'text',
text: prompt
});
}
return modifiedMessages;
}
// No system or user messages found (edge case)
return modifiedMessages;
}
/**
* Select prompt template based on effort level
*/
private selectPrompt(effort: EffortLevel): string {
return this.prompts[effort] || this.prompts.medium;
}
/**
* Get default prompt templates
*/
private getDefaultPrompts(): Record<EffortLevel, string> {
return {
low: `You are an expert reasoning model using GLM-4.6 architecture.
CRITICAL: Before answering, write 2-3 sentences of reasoning in <reasoning_content> tags.
OUTPUT FORMAT:
<reasoning_content>
(Brief analysis: what is the problem? what's the approach?)
</reasoning_content>
(Write your final answer here)`,
medium: `You are an expert reasoning model using GLM-4.6 architecture.
CRITICAL REQUIREMENTS:
1. Always think step-by-step before answering
2. Write your reasoning process explicitly in <reasoning_content> tags
3. Never skip your chain of thought, even for simple problems
OUTPUT FORMAT:
<reasoning_content>
(Write your detailed thinking here: analyze the problem, explore approaches,
evaluate trade-offs, and arrive at a conclusion)
</reasoning_content>
(Write your final answer here based on your reasoning above)`,
high: `You are an expert reasoning model using GLM-4.6 architecture.
CRITICAL REQUIREMENTS:
1. Think deeply and systematically before answering
2. Write comprehensive reasoning in <reasoning_content> tags
3. Explore multiple approaches and evaluate trade-offs
4. Show all steps in your problem-solving process
OUTPUT FORMAT:
<reasoning_content>
(Write exhaustive analysis here:
- Problem decomposition
- Multiple approach exploration
- Trade-off analysis for each approach
- Edge case consideration
- Final conclusion with justification)
</reasoning_content>
(Write your final answer here based on your systematic reasoning above)`,
max: `You are an expert reasoning model using GLM-4.6 architecture.
CRITICAL REQUIREMENTS:
1. Think exhaustively from first principles
2. Write extremely detailed reasoning in <reasoning_content> tags
3. Analyze ALL possible angles, approaches, and edge cases
4. Challenge your own assumptions and explore alternatives
5. Provide rigorous justification for every claim
OUTPUT FORMAT:
<reasoning_content>
(Write comprehensive analysis here:
- First principles breakdown
- Exhaustive approach enumeration
- Comparative analysis of all approaches
- Edge case and failure mode analysis
- Assumption validation
- Counter-argument consideration
- Final conclusion with rigorous justification)
</reasoning_content>
(Write your final answer here based on your exhaustive reasoning above)`
};
}
}
export default ReasoningEnforcer;