mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 06:16:37 +00:00
- Extract Retry-After from actual HTTP response headers instead of parsing error messages - Cap GLMT_MAX_RETRIES at 10 to prevent misconfiguration - Prefer header-based Retry-After over message parsing (fallback preserved) Addresses PR review feedback.
885 lines
26 KiB
TypeScript
885 lines
26 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Configuration for retry behavior on rate limit errors
|
|
*/
|
|
interface RetryConfig {
|
|
maxRetries: number;
|
|
baseDelay: number;
|
|
enabled: boolean;
|
|
}
|
|
|
|
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;
|
|
private retryConfig: RetryConfig;
|
|
private httpsAgent: https.Agent;
|
|
|
|
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
|
|
|
|
// Retry configuration for handling 429 rate limit errors
|
|
const maxRetries = parseInt(process.env.GLMT_MAX_RETRIES || '3', 10);
|
|
const baseDelay = parseInt(process.env.GLMT_RETRY_BASE_DELAY || '1000', 10);
|
|
this.retryConfig = {
|
|
maxRetries: isNaN(maxRetries) || maxRetries < 0 ? 3 : Math.min(maxRetries, 10),
|
|
baseDelay: isNaN(baseDelay) || baseDelay < 0 ? 1000 : baseDelay,
|
|
enabled: process.env.GLMT_DISABLE_RETRY !== '1',
|
|
};
|
|
|
|
// Connection pooling for better performance
|
|
this.httpsAgent = new https.Agent({
|
|
keepAlive: true,
|
|
maxSockets: 5,
|
|
maxFreeSockets: 2,
|
|
timeout: this.timeout,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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}`);
|
|
|
|
// Parse error to provide clearer messages
|
|
const errorInfo = this.parseUpstreamError(err);
|
|
|
|
// Check if headers already sent (streaming mode may have started)
|
|
if (res.headersSent) {
|
|
// Headers already sent, write error as SSE event and close
|
|
this.log('Headers already sent, writing error as SSE event');
|
|
res.write(`event: error\n`);
|
|
res.write(`data: ${JSON.stringify({ error: errorInfo })}\n\n`);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
res.writeHead(errorInfo.statusCode, { 'Content-Type': 'application/json' });
|
|
res.end(
|
|
JSON.stringify({
|
|
error: {
|
|
type: errorInfo.type,
|
|
message: errorInfo.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 with retry on rate limit
|
|
const openaiResponse = (await this.forwardWithRetry(
|
|
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 with retry on rate limit
|
|
await this.forwardAndStreamWithRetry(
|
|
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);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sleep for specified milliseconds
|
|
*/
|
|
private sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
/**
|
|
* Calculate retry delay with exponential backoff and jitter
|
|
* Honors Retry-After header if provided
|
|
*/
|
|
private calculateRetryDelay(attempt: number, retryAfterHeader?: string): number {
|
|
// Honor Retry-After header if present and valid
|
|
if (retryAfterHeader) {
|
|
const retryAfter = parseInt(retryAfterHeader, 10);
|
|
if (!isNaN(retryAfter) && retryAfter > 0) {
|
|
return retryAfter * 1000; // Convert seconds to ms
|
|
}
|
|
}
|
|
// Exponential backoff: 2^attempt * baseDelay + random jitter (0-500ms), capped at 30s
|
|
const MAX_DELAY_MS = 30000;
|
|
const exponentialDelay = Math.min(
|
|
Math.pow(2, attempt) * this.retryConfig.baseDelay,
|
|
MAX_DELAY_MS
|
|
);
|
|
const jitter = Math.random() * 500;
|
|
return exponentialDelay + jitter;
|
|
}
|
|
|
|
/**
|
|
* Check if error is retryable (429 rate limit)
|
|
* Returns retryable status and optional Retry-After value
|
|
*/
|
|
private isRetryableError(error: Error): { retryable: boolean; retryAfter?: string } {
|
|
const message = error.message;
|
|
if (message.includes('429') || message.toLowerCase().includes('rate limit')) {
|
|
// Prefer Retry-After from error property (captured from HTTP header), fallback to parsing message
|
|
const errWithRetryAfter = error as Error & { retryAfter?: string };
|
|
if (errWithRetryAfter.retryAfter) {
|
|
return { retryable: true, retryAfter: errWithRetryAfter.retryAfter };
|
|
}
|
|
const retryAfterMatch = message.match(/retry-after:\s*(\d+)/i);
|
|
return {
|
|
retryable: true,
|
|
retryAfter: retryAfterMatch?.[1],
|
|
};
|
|
}
|
|
return { retryable: false };
|
|
}
|
|
|
|
/**
|
|
* Forward request with retry logic for rate limit errors
|
|
*/
|
|
private async forwardWithRetry(
|
|
openaiRequest: OpenAIRequest,
|
|
headers: Record<string, string | undefined>
|
|
): Promise<unknown> {
|
|
if (!this.retryConfig.enabled) {
|
|
return this.forwardToUpstream(openaiRequest, headers);
|
|
}
|
|
|
|
let lastError: Error | null = null;
|
|
|
|
for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) {
|
|
try {
|
|
return await this.forwardToUpstream(openaiRequest, headers);
|
|
} catch (error) {
|
|
const err = error as Error;
|
|
const { retryable, retryAfter } = this.isRetryableError(err);
|
|
|
|
if (!retryable || attempt >= this.retryConfig.maxRetries) {
|
|
throw err;
|
|
}
|
|
|
|
lastError = err;
|
|
const delay = this.calculateRetryDelay(attempt, retryAfter);
|
|
|
|
console.error(
|
|
`[glmt-proxy] Rate limited, retry ${attempt + 1}/${this.retryConfig.maxRetries} after ${Math.round(delay)}ms`
|
|
);
|
|
|
|
await this.sleep(delay);
|
|
}
|
|
}
|
|
|
|
throw lastError ?? new Error('Retry failed');
|
|
}
|
|
|
|
/**
|
|
* Forward streaming request with retry logic for rate limit errors
|
|
* Only retries if headers haven't been sent yet
|
|
*/
|
|
private async forwardAndStreamWithRetry(
|
|
openaiRequest: OpenAIRequest,
|
|
headers: Record<string, string | undefined>,
|
|
clientRes: http.ServerResponse,
|
|
thinkingConfig: ThinkingConfig,
|
|
startTime: number
|
|
): Promise<void> {
|
|
if (!this.retryConfig.enabled) {
|
|
return this.forwardAndStreamUpstream(
|
|
openaiRequest,
|
|
headers,
|
|
clientRes,
|
|
thinkingConfig,
|
|
startTime
|
|
);
|
|
}
|
|
|
|
let lastError: Error | null = null;
|
|
|
|
for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) {
|
|
try {
|
|
return await this.forwardAndStreamUpstream(
|
|
openaiRequest,
|
|
headers,
|
|
clientRes,
|
|
thinkingConfig,
|
|
startTime
|
|
);
|
|
} catch (error) {
|
|
const err = error as Error;
|
|
|
|
// Don't retry if headers already sent (streaming started)
|
|
if (clientRes.headersSent) {
|
|
throw err;
|
|
}
|
|
|
|
const { retryable, retryAfter } = this.isRetryableError(err);
|
|
|
|
if (!retryable || attempt >= this.retryConfig.maxRetries) {
|
|
throw err;
|
|
}
|
|
|
|
lastError = err;
|
|
const delay = this.calculateRetryDelay(attempt, retryAfter);
|
|
|
|
console.error(
|
|
`[glmt-proxy] Rate limited, retry ${attempt + 1}/${this.retryConfig.maxRetries} after ${Math.round(delay)}ms`
|
|
);
|
|
|
|
await this.sleep(delay);
|
|
}
|
|
}
|
|
|
|
throw lastError ?? new Error('Retry failed');
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
|
|
// OpenAI-compatible endpoints require "Bearer " prefix
|
|
const token = process.env.ANTHROPIC_AUTH_TOKEN || '';
|
|
const authHeader = this.formatAuthHeader(token, url.pathname);
|
|
|
|
const options: https.RequestOptions = {
|
|
hostname: url.hostname,
|
|
port: url.port || 443,
|
|
path: url.pathname || '/api/coding/paas/v4/chat/completions',
|
|
method: 'POST',
|
|
agent: this.httpsAgent,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': Buffer.byteLength(requestBody),
|
|
Authorization: authHeader,
|
|
'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) {
|
|
const err = new Error(
|
|
`Upstream error: ${res.statusCode} ${res.statusMessage}\n${body}`
|
|
) as Error & { retryAfter?: string };
|
|
// Capture Retry-After header for rate limit handling
|
|
const retryAfterHeader = res.headers['retry-after'];
|
|
if (retryAfterHeader) {
|
|
err.retryAfter = Array.isArray(retryAfterHeader)
|
|
? retryAfterHeader[0]
|
|
: retryAfterHeader;
|
|
}
|
|
reject(err);
|
|
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);
|
|
|
|
// OpenAI-compatible endpoints require "Bearer " prefix
|
|
const token = process.env.ANTHROPIC_AUTH_TOKEN || '';
|
|
const authHeader = this.formatAuthHeader(token, url.pathname);
|
|
|
|
const options: https.RequestOptions = {
|
|
hostname: url.hostname,
|
|
port: url.port || 443,
|
|
path: url.pathname || '/api/coding/paas/v4/chat/completions',
|
|
method: 'POST',
|
|
agent: this.httpsAgent,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': Buffer.byteLength(requestBody),
|
|
Authorization: authHeader,
|
|
'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', () => {
|
|
const err = new Error(`Upstream error: ${upstreamRes.statusCode}\n${body}`) as Error & {
|
|
retryAfter?: string;
|
|
};
|
|
// Capture Retry-After header for rate limit handling
|
|
const retryAfterHeader = upstreamRes.headers['retry-after'];
|
|
if (retryAfterHeader) {
|
|
err.retryAfter = Array.isArray(retryAfterHeader)
|
|
? retryAfterHeader[0]
|
|
: retryAfterHeader;
|
|
}
|
|
reject(err);
|
|
});
|
|
return;
|
|
}
|
|
|
|
const parser = new SSEParser();
|
|
const accumulator = new DeltaAccumulator(
|
|
thinkingConfig as unknown as Record<string, unknown>
|
|
);
|
|
|
|
upstreamRes.on('data', (chunk: Buffer) => {
|
|
try {
|
|
const events = parser.parse(chunk);
|
|
|
|
events.forEach((event) => {
|
|
// Transform OpenAI delta → Anthropic events
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const anthropicEvents = this.transformer.transformDelta(event as any, 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();
|
|
}
|
|
// Destroy connection pool
|
|
if (this.httpsAgent) {
|
|
this.httpsAgent.destroy();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log message if verbose
|
|
*/
|
|
private log(message: string): void {
|
|
if (this.verbose) {
|
|
console.error(`[glmt-proxy] ${message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format Authorization header based on endpoint type
|
|
* OpenAI-compatible endpoints (chat/completions) require "Bearer " prefix
|
|
* Anthropic-compatible endpoints use token directly
|
|
*/
|
|
private formatAuthHeader(token: string, pathname: string): string {
|
|
// OpenAI-compatible endpoints use Bearer token format
|
|
const isOpenAICompatible =
|
|
pathname.includes('chat/completions') ||
|
|
pathname.includes('/v1/') ||
|
|
pathname.includes('/paas/');
|
|
|
|
if (isOpenAICompatible && token && !token.startsWith('Bearer ')) {
|
|
return `Bearer ${token}`;
|
|
}
|
|
return token;
|
|
}
|
|
|
|
/**
|
|
* Parse upstream error and return user-friendly error info
|
|
*/
|
|
private parseUpstreamError(err: Error): {
|
|
statusCode: number;
|
|
type: string;
|
|
message: string;
|
|
} {
|
|
const errorMessage = err.message;
|
|
|
|
// Check for 401 Unauthorized / auth token missing
|
|
if (
|
|
errorMessage.includes('401') ||
|
|
errorMessage.toLowerCase().includes('unauthorized') ||
|
|
errorMessage.toLowerCase().includes('authorization token missing') ||
|
|
errorMessage.toLowerCase().includes('invalid token')
|
|
) {
|
|
return {
|
|
statusCode: 401,
|
|
type: 'authentication_error',
|
|
message:
|
|
'API key rejected by Z.AI. This can happen when:\n' +
|
|
' 1. Key expired on Z.AI server (most common after idle periods)\n' +
|
|
' 2. Key was revoked or regenerated\n' +
|
|
' 3. Key is missing or malformed\n\n' +
|
|
'To fix:\n' +
|
|
' 1. Go to Z.AI dashboard and regenerate your API key\n' +
|
|
' 2. Update ~/.ccs/glm.settings.json with the new key\n' +
|
|
' 3. Or run: ccs config -> API Profiles -> GLM -> Update key',
|
|
};
|
|
}
|
|
|
|
// Check for 403 Forbidden
|
|
if (errorMessage.includes('403') || errorMessage.toLowerCase().includes('forbidden')) {
|
|
return {
|
|
statusCode: 403,
|
|
type: 'permission_error',
|
|
message: 'Access forbidden. Your token may not have permission to access this resource.',
|
|
};
|
|
}
|
|
|
|
// Check for 429 Rate limit
|
|
if (errorMessage.includes('429') || errorMessage.toLowerCase().includes('rate limit')) {
|
|
return {
|
|
statusCode: 429,
|
|
type: 'rate_limit_error',
|
|
message: 'Rate limit exceeded. Please wait before making more requests.',
|
|
};
|
|
}
|
|
|
|
// Check for timeout
|
|
if (errorMessage.toLowerCase().includes('timeout')) {
|
|
return {
|
|
statusCode: 504,
|
|
type: 'timeout_error',
|
|
message: 'Request timed out. The upstream server took too long to respond.',
|
|
};
|
|
}
|
|
|
|
// Check for connection errors
|
|
if (
|
|
errorMessage.toLowerCase().includes('econnrefused') ||
|
|
errorMessage.toLowerCase().includes('enotfound') ||
|
|
errorMessage.toLowerCase().includes('connection')
|
|
) {
|
|
return {
|
|
statusCode: 502,
|
|
type: 'connection_error',
|
|
message:
|
|
'Failed to connect to upstream server. Please check your network and ANTHROPIC_BASE_URL configuration.',
|
|
};
|
|
}
|
|
|
|
// Default: proxy error
|
|
return {
|
|
statusCode: 500,
|
|
type: 'proxy_error',
|
|
message: errorMessage,
|
|
};
|
|
}
|
|
}
|
|
|
|
// 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;
|