@sifwenf/cc-proxy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.ts ADDED
@@ -0,0 +1,86 @@
1
+ import { readFileSync, existsSync, mkdirSync } from 'fs';
2
+ import { resolve, join, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import type { Config } from './types.js';
5
+
6
+ let configCache: Config | null = null;
7
+ let configPath: string | null = null;
8
+
9
+ // Get user home directory
10
+ function getUserHome(): string {
11
+ return process.env.HOME || process.env.USERPROFILE || '';
12
+ }
13
+
14
+ // Get the proxy data directory in user home
15
+ function getDataDir(): string {
16
+ return join(getUserHome(), '.claude-code-proxy');
17
+ }
18
+
19
+ // Ensure all necessary directories exist
20
+ function ensureDirectories(): void {
21
+ const dataDir = getDataDir();
22
+ const logsDir = join(dataDir, 'logs');
23
+
24
+ if (!existsSync(dataDir)) {
25
+ mkdirSync(dataDir, { recursive: true });
26
+ }
27
+
28
+ if (!existsSync(logsDir)) {
29
+ mkdirSync(logsDir, { recursive: true });
30
+ }
31
+ }
32
+
33
+ // Get default config path
34
+ export function getDefaultConfigPath(): string {
35
+ return join(getDataDir(), 'config.json');
36
+ }
37
+
38
+ export function loadConfig(customConfigPath?: string): Config {
39
+ if (configCache && configPath === (customConfigPath || null)) {
40
+ return configCache;
41
+ }
42
+
43
+ const path = customConfigPath || getDefaultConfigPath();
44
+ configPath = path;
45
+
46
+ ensureDirectories();
47
+
48
+ try {
49
+ const content = readFileSync(path, 'utf-8');
50
+ configCache = JSON.parse(content);
51
+ return configCache;
52
+ } catch (error) {
53
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
54
+ throw new Error(
55
+ `Config file not found: ${path}\n` +
56
+ `Please create a config.json file. See config.example.json for reference.`
57
+ );
58
+ }
59
+ throw error;
60
+ }
61
+ }
62
+
63
+ export function reloadConfig(customConfigPath?: string): Config {
64
+ const path = customConfigPath || getDefaultConfigPath();
65
+
66
+ try {
67
+ const content = readFileSync(path, 'utf-8');
68
+ configCache = JSON.parse(content);
69
+ return configCache;
70
+ } catch (error) {
71
+ console.error('Failed to reload config:', error);
72
+ return configCache || {} as Config;
73
+ }
74
+ }
75
+
76
+ export function getConfig(): Config | null {
77
+ return configCache;
78
+ }
79
+
80
+ export function getConfigDir(): string {
81
+ return getDataDir();
82
+ }
83
+
84
+ export function getLogsDir(): string {
85
+ return join(getDataDir(), 'logs');
86
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Format converter for OpenRouter (OpenAI format) <-> Anthropic format
3
+ */
4
+
5
+ // OpenAI-compatible format (used by OpenRouter)
6
+ export interface OpenAIMessage {
7
+ role: 'system' | 'user' | 'assistant';
8
+ content: string;
9
+ }
10
+
11
+ export interface OpenAIRequest {
12
+ model: string;
13
+ messages: OpenAIMessage[];
14
+ max_tokens?: number;
15
+ temperature?: number;
16
+ top_p?: number;
17
+ stream?: boolean;
18
+ }
19
+
20
+ export interface OpenAIResponse {
21
+ id: string;
22
+ object: string;
23
+ created: number;
24
+ model: string;
25
+ choices: Array<{
26
+ index: number;
27
+ message: {
28
+ role: string;
29
+ content: string;
30
+ };
31
+ finish_reason: string;
32
+ }>;
33
+ usage: {
34
+ prompt_tokens: number;
35
+ completion_tokens: number;
36
+ total_tokens: number;
37
+ };
38
+ }
39
+
40
+ // Anthropic format
41
+ export interface AnthropicContentBlock {
42
+ type: 'text' | 'image';
43
+ text?: string;
44
+ source?: any;
45
+ }
46
+
47
+ export interface AnthropicMessage {
48
+ role: 'user' | 'assistant';
49
+ content: string | AnthropicContentBlock[];
50
+ }
51
+
52
+ export interface AnthropicRequest {
53
+ model: string;
54
+ messages: AnthropicMessage[];
55
+ max_tokens: number;
56
+ temperature?: number;
57
+ top_p?: number;
58
+ stream?: boolean;
59
+ system?: string;
60
+ tools?: any;
61
+ tool_choice?: any;
62
+ }
63
+
64
+ export interface AnthropicResponse {
65
+ id: string;
66
+ type: string;
67
+ role: string;
68
+ content: Array<{ type: string; text: string }>;
69
+ model: string;
70
+ stop_reason: string;
71
+ usage: {
72
+ input_tokens: number;
73
+ output_tokens: number;
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Detect if provider uses OpenAI format (deprecated - use provider.format instead)
79
+ */
80
+ export function isOpenAIFormat(baseUrl: string): boolean {
81
+ return baseUrl.includes('/chat/completions') || baseUrl.includes('openrouter');
82
+ }
83
+
84
+ /**
85
+ * Convert Anthropic format request to OpenAI format
86
+ */
87
+ export function convertAnthropicToOpenAI(anthropic: AnthropicRequest): OpenAIRequest {
88
+ const messages: OpenAIMessage[] = [];
89
+
90
+ // Add system message first if present
91
+ if (anthropic.system) {
92
+ messages.push({
93
+ role: 'system',
94
+ content: anthropic.system,
95
+ });
96
+ }
97
+
98
+ // Convert messages
99
+ for (const msg of anthropic.messages) {
100
+ let content = '';
101
+
102
+ // Handle content as string or array of blocks
103
+ if (typeof msg.content === 'string') {
104
+ content = msg.content;
105
+ } else if (Array.isArray(msg.content)) {
106
+ // Concatenate text blocks
107
+ content = msg.content
108
+ .map(block => block.type === 'text' ? (block.text || '') : '')
109
+ .filter(Boolean)
110
+ .join('\n');
111
+ }
112
+
113
+ messages.push({
114
+ role: msg.role === 'user' ? 'user' : 'assistant',
115
+ content,
116
+ });
117
+ }
118
+
119
+ return {
120
+ model: anthropic.model,
121
+ messages,
122
+ max_tokens: anthropic.max_tokens,
123
+ temperature: anthropic.temperature,
124
+ top_p: anthropic.top_p,
125
+ stream: anthropic.stream,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Convert OpenAI format response to Anthropic format
131
+ */
132
+ export function convertOpenAIToAnthropic(openai: OpenAIResponse, originalModel: string): AnthropicResponse {
133
+ const choice = openai.choices[0];
134
+ if (!choice) {
135
+ throw new Error('No choices in OpenAI response');
136
+ }
137
+
138
+ return {
139
+ id: `msg_${openai.id}`,
140
+ type: 'message',
141
+ role: 'assistant',
142
+ content: [
143
+ {
144
+ type: 'text',
145
+ text: choice.message.content,
146
+ },
147
+ ],
148
+ model: originalModel,
149
+ stop_reason: choice.finish_reason === 'stop' ? 'end_turn' : choice.finish_reason,
150
+ usage: {
151
+ input_tokens: openai.usage.prompt_tokens,
152
+ output_tokens: openai.usage.completion_tokens,
153
+ },
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Extract content from Anthropic message for OpenAI format
159
+ */
160
+ function extractContent(content: string | Array<{ type: string; text?: string; source?: any }>): string {
161
+ if (typeof content === 'string') {
162
+ return content;
163
+ }
164
+ return content
165
+ .map(block => block.type === 'text' ? (block.text || '') : '')
166
+ .filter(Boolean)
167
+ .join('\n');
168
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,213 @@
1
+ import { mkdirSync, appendFileSync, existsSync } from 'fs';
2
+ import { appendFile } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { randomUUID } from 'crypto';
5
+ import type { LogEntry, LoggingConfig } from './types.js';
6
+ import { getLogsDir } from './config.js';
7
+
8
+ export class Logger {
9
+ private config: LoggingConfig;
10
+ private requestId: string | null = null;
11
+ private requestStartTime: number = 0;
12
+ private logFile: string;
13
+ private logBuffer: string[] = [];
14
+ private flushTimer: ReturnType<typeof setInterval> | null = null;
15
+
16
+ constructor(config: LoggingConfig) {
17
+ this.config = config;
18
+ const logsDir = getLogsDir();
19
+ this.logFile = join(logsDir, 'requests.jsonl');
20
+
21
+ if (config.enabled) {
22
+ this.ensureLogDir();
23
+ this.setupBufferFlush();
24
+ }
25
+ }
26
+
27
+ private ensureLogDir(): void {
28
+ const logsDir = getLogsDir();
29
+ if (!existsSync(logsDir)) {
30
+ mkdirSync(logsDir, { recursive: true });
31
+ }
32
+ }
33
+
34
+ private setupBufferFlush(): void {
35
+ // Flush buffer every 100ms to batch writes
36
+ this.flushTimer = setInterval(() => {
37
+ this.flush();
38
+ }, 100);
39
+ }
40
+
41
+ private flush(): void {
42
+ if (this.logBuffer.length === 0) return;
43
+
44
+ const lines = this.logBuffer.splice(0);
45
+ const logData = lines.join('');
46
+
47
+ // Use async write in Bun for better performance
48
+ if (typeof Bun !== 'undefined') {
49
+ appendFile(this.logFile, logData).catch(() => {});
50
+ } else {
51
+ // Fallback to sync for Node.js
52
+ appendFileSync(this.logFile, logData, 'utf-8');
53
+ }
54
+ }
55
+
56
+ private writeLog(entry: LogEntry): void {
57
+ if (!this.config.enabled) return;
58
+
59
+ const logLine = JSON.stringify(entry) + '\n';
60
+ this.logBuffer.push(logLine);
61
+
62
+ // Flush immediately if buffer gets too large (prevent memory bloat)
63
+ if (this.logBuffer.length >= 50) {
64
+ this.flush();
65
+ }
66
+ }
67
+
68
+ startRequest(method: string, path: string, headers: Record<string, string>, body?: any): string {
69
+ this.requestId = randomUUID();
70
+ this.requestStartTime = Date.now();
71
+
72
+ const entry: LogEntry = {
73
+ timestamp: new Date().toISOString(),
74
+ id: this.requestId,
75
+ type: 'request',
76
+ method,
77
+ path,
78
+ headers: this.config.level === 'verbose' ? headers : this.sanitizeHeaders(headers),
79
+ body: this.config.level === 'verbose' ? body : undefined,
80
+ };
81
+
82
+ this.writeLog(entry);
83
+ return this.requestId;
84
+ }
85
+
86
+ logForward(
87
+ providerName: string,
88
+ modelName: string,
89
+ requestFormat: string,
90
+ actualRequest: any,
91
+ actualResponse?: any
92
+ ): void {
93
+ if (!this.requestId || !this.config.enabled) return;
94
+
95
+ const entry: LogEntry = {
96
+ timestamp: new Date().toISOString(),
97
+ id: this.requestId,
98
+ type: 'forward',
99
+ method: 'POST',
100
+ path: '/v1/messages',
101
+ provider: providerName,
102
+ mappedModel: modelName,
103
+ requestFormat,
104
+ actualRequest: this.config.level === 'verbose' ? actualRequest : undefined,
105
+ actualResponse: this.config.level === 'verbose' ? actualResponse : undefined,
106
+ };
107
+
108
+ this.writeLog(entry);
109
+ }
110
+
111
+ logResponse(
112
+ statusCode: number,
113
+ headers: Record<string, string>,
114
+ body?: any,
115
+ provider?: string,
116
+ mappedModel?: string,
117
+ originalModel?: string
118
+ ): void {
119
+ if (!this.requestId) return;
120
+
121
+ const duration = Date.now() - this.requestStartTime;
122
+
123
+ const entry: LogEntry = {
124
+ timestamp: new Date().toISOString(),
125
+ id: this.requestId,
126
+ type: 'response',
127
+ method: 'POST',
128
+ path: '/v1/messages',
129
+ statusCode,
130
+ headers: this.config.level === 'verbose' ? headers : this.sanitizeHeaders(headers),
131
+ body: this.config.level === 'verbose' ? body : undefined,
132
+ provider,
133
+ mappedModel,
134
+ originalModel,
135
+ duration,
136
+ };
137
+
138
+ this.writeLog(entry);
139
+ this.requestId = null;
140
+ }
141
+
142
+ logStreamChunk(
143
+ chunkIndex: number,
144
+ chunkType: string,
145
+ data: any,
146
+ provider?: string
147
+ ): void {
148
+ if (!this.requestId) return;
149
+
150
+ const entry: LogEntry = {
151
+ timestamp: new Date().toISOString(),
152
+ id: this.requestId,
153
+ type: 'stream_chunk',
154
+ method: 'POST',
155
+ path: '/v1/messages',
156
+ chunkIndex,
157
+ headers: { 'x-chunk-type': chunkType },
158
+ body: this.config.level === 'verbose' ? data : undefined,
159
+ provider,
160
+ };
161
+
162
+ this.writeLog(entry);
163
+ }
164
+
165
+ logError(error: string, details?: any): void {
166
+ if (!this.requestId) return;
167
+
168
+ const entry: LogEntry = {
169
+ timestamp: new Date().toISOString(),
170
+ id: this.requestId,
171
+ type: 'error',
172
+ method: 'POST',
173
+ path: '/v1/messages',
174
+ error,
175
+ body: details,
176
+ };
177
+
178
+ this.writeLog(entry);
179
+ this.requestId = null;
180
+ }
181
+
182
+ private sanitizeHeaders(headers: Record<string, string>): Record<string, string> {
183
+ const sanitized: Record<string, string> = {};
184
+
185
+ for (const [key, value] of Object.entries(headers)) {
186
+ if (key.toLowerCase() === 'authorization') {
187
+ sanitized[key] = 'Bearer ***REDACTED***';
188
+ } else if (key.toLowerCase() === 'x-api-key') {
189
+ sanitized[key] = '***REDACTED***';
190
+ } else {
191
+ sanitized[key] = value;
192
+ }
193
+ }
194
+
195
+ return sanitized;
196
+ }
197
+
198
+ // Flush buffer on cleanup
199
+ destroy(): void {
200
+ if (this.flushTimer) {
201
+ clearInterval(this.flushTimer);
202
+ this.flushTimer = null;
203
+ }
204
+ this.flush();
205
+ }
206
+
207
+ /**
208
+ * Update logging configuration (for hot reload)
209
+ */
210
+ updateConfig(config: LoggingConfig): void {
211
+ this.config = config;
212
+ }
213
+ }
package/src/proxy.ts ADDED
@@ -0,0 +1,83 @@
1
+ import type { ProviderConfig, RouterConfig } from './types.js';
2
+
3
+ export interface ProxyContext {
4
+ provider: ProviderConfig;
5
+ modelName: string;
6
+ }
7
+
8
+ export class RequestMapper {
9
+ private providers: ProviderConfig[];
10
+ private router: RouterConfig;
11
+
12
+ constructor(providers: ProviderConfig[], router: RouterConfig) {
13
+ this.providers = providers;
14
+ this.router = router;
15
+ }
16
+
17
+ /**
18
+ * Parse route string like "zp,glm-4.7" into provider name and model name
19
+ */
20
+ private parseRoute(routeStr: string): { providerName: string; modelName: string } | null {
21
+ const parts = routeStr.split(',');
22
+ if (parts.length !== 2) {
23
+ return null;
24
+ }
25
+ return {
26
+ providerName: parts[0].trim(),
27
+ modelName: parts[1].trim()
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Find provider by name
33
+ */
34
+ private findProvider(providerName: string): ProviderConfig | null {
35
+ return this.providers.find(p => p.name === providerName) || null;
36
+ }
37
+
38
+ /**
39
+ * Map Claude model to provider and model based on router config
40
+ */
41
+ resolveProvider(claudeModel: string): ProxyContext {
42
+ let routeStr: string;
43
+
44
+ // Determine which route to use based on model name
45
+ if (claudeModel.includes('haiku')) {
46
+ routeStr = this.router.haiku;
47
+ } else if (claudeModel.includes('opus')) {
48
+ routeStr = this.router.opus;
49
+ } else {
50
+ // Default to sonnet for anything else
51
+ routeStr = this.router.sonnet;
52
+ }
53
+
54
+ const parsed = this.parseRoute(routeStr);
55
+ if (!parsed) {
56
+ throw new Error(`Invalid route configuration: ${routeStr}`);
57
+ }
58
+
59
+ const provider = this.findProvider(parsed.providerName);
60
+ if (!provider) {
61
+ throw new Error(`Provider not found: ${parsed.providerName}`);
62
+ }
63
+
64
+ return {
65
+ provider,
66
+ modelName: parsed.modelName,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Update providers configuration (for hot reload)
72
+ */
73
+ updateProviders(providers: ProviderConfig[]): void {
74
+ this.providers = providers;
75
+ }
76
+
77
+ /**
78
+ * Update router configuration (for hot reload)
79
+ */
80
+ updateRouter(router: RouterConfig): void {
81
+ this.router = router;
82
+ }
83
+ }
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Initialize cc-proxy configuration in user home directory
4
+ */
5
+
6
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
7
+ import { join, dirname } from 'path';
8
+ import { fileURLToPath } from 'url';
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+
12
+ interface Config {
13
+ server: {
14
+ port: number;
15
+ host: string;
16
+ };
17
+ logging: {
18
+ enabled: boolean;
19
+ level: 'basic' | 'standard' | 'verbose';
20
+ dir: string;
21
+ };
22
+ providers: Array<{
23
+ name: string;
24
+ baseUrl: string;
25
+ apiKey: string;
26
+ format?: string;
27
+ }>;
28
+ router: {
29
+ haiku: string;
30
+ sonnet: string;
31
+ opus: string;
32
+ image?: string;
33
+ webSearch?: string | number;
34
+ };
35
+ }
36
+
37
+ function getOldConfigPath(): string {
38
+ return join(process.cwd(), 'config.json');
39
+ }
40
+
41
+ function getNewConfigPath(): string {
42
+ const home = process.env.HOME || process.env.USERPROFILE || '';
43
+ return join(home, '.claude-code-proxy', 'config.json');
44
+ }
45
+
46
+ function getNewLogsDir(): string {
47
+ const home = process.env.HOME || process.env.USERPROFILE || '';
48
+ return join(home, '.claude-code-proxy', 'logs');
49
+ }
50
+
51
+ function migrateConfig(): void {
52
+ const oldConfigPath = getOldConfigPath();
53
+ const newConfigPath = getNewConfigPath();
54
+
55
+ // Check if new config already exists - protect existing config
56
+ if (existsSync(newConfigPath)) {
57
+ console.log('āœ… Config already exists at:', newConfigPath);
58
+ console.log(' Server is using this config file.\n');
59
+
60
+ // Migrate old config if it exists
61
+ if (existsSync(oldConfigPath)) {
62
+ console.log('šŸ“– Project config.json found at:', oldConfigPath);
63
+ console.log('šŸ’” You can delete project config.json - it\'s no longer used');
64
+ console.log(' Server uses: ~/.claude-code-proxy/config.json\n');
65
+ }
66
+ return;
67
+ }
68
+
69
+ // Check if old config exists
70
+ if (!existsSync(oldConfigPath)) {
71
+ console.log('āŒ No config.json found in current directory');
72
+ console.log('Creating default config in user directory...');
73
+ createDefaultConfig(newConfigPath);
74
+ return;
75
+ }
76
+
77
+ // Read old config
78
+ console.log(`šŸ“– Reading config from: ${oldConfigPath}`);
79
+ const oldConfig: Config = JSON.parse(readFileSync(oldConfigPath, 'utf-8'));
80
+
81
+ // Ensure new directory exists
82
+ const newDir = dirname(newConfigPath);
83
+ if (!existsSync(newDir)) {
84
+ mkdirSync(newDir, { recursive: true });
85
+ }
86
+
87
+ // Ensure logs directory exists
88
+ const newLogsDir = getNewLogsDir();
89
+ if (!existsSync(newLogsDir)) {
90
+ mkdirSync(newLogsDir, { recursive: true });
91
+ }
92
+
93
+ // Write new config
94
+ writeFileSync(newConfigPath, JSON.stringify(oldConfig, null, 2), 'utf-8');
95
+ console.log(`āœ… Config migrated to: ${newConfigPath}`);
96
+
97
+ // Ask if user wants to delete old config
98
+ console.log('\nāš ļø Old config.json still exists in project directory');
99
+ console.log('You can now delete the project config.json and use the one in ~/.claude-code-proxy');
100
+ }
101
+
102
+ function createDefaultConfig(configPath: string): void {
103
+ const defaultConfig: Config = {
104
+ server: {
105
+ port: 3457,
106
+ host: '127.0.0.1'
107
+ },
108
+ logging: {
109
+ enabled: true,
110
+ level: 'verbose',
111
+ dir: join(process.env.HOME || process.env.USERPROFILE || '', '.claude-code-proxy', 'logs')
112
+ },
113
+ providers: [
114
+ {
115
+ name: 'openrouter',
116
+ baseUrl: 'https://openrouter.ai/api/v1/chat/completions',
117
+ apiKey: ''
118
+ },
119
+ {
120
+ name: 'zp',
121
+ baseUrl: 'https://api.z.ai/api/anthropic/v1/messages',
122
+ apiKey: ''
123
+ },
124
+ {
125
+ name: 'yescode',
126
+ baseUrl: 'https://co.yes.vg/v1/messages',
127
+ apiKey: ''
128
+ }
129
+ ],
130
+ router: {
131
+ haiku: 'zp,glm-4.7',
132
+ sonnet: 'zp,glm-4.7',
133
+ opus: 'zp,glm-4.7',
134
+ image: 'zp,glm-4.7',
135
+ webSearch: 200000
136
+ }
137
+ };
138
+
139
+ writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2), 'utf-8');
140
+ console.log(`āœ… Default config created at: ${configPath}`);
141
+ }
142
+
143
+ function main() {
144
+ console.log('šŸš€ Initializing cc-proxy...\n');
145
+
146
+ try {
147
+ migrateConfig();
148
+
149
+ console.log('\nāœ… Initialization complete!');
150
+ console.log('\nšŸ“ Next steps:');
151
+ console.log('1. Review your config at: ~/.claude-code-proxy/config.json');
152
+ console.log('2. Add your API keys to provider configurations');
153
+ console.log('3. Start the server: npm start');
154
+ console.log('\nšŸ’” Config hot reload is enabled - changes will be applied automatically!');
155
+ } catch (error) {
156
+ console.error('āŒ Initialization failed:', error);
157
+ process.exit(1);
158
+ }
159
+ }
160
+
161
+ main();