@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/README.ja.md +337 -0
- package/README.md +284 -0
- package/README.zh-CN.md +337 -0
- package/TASKS.md +102 -0
- package/config.example.json +29 -0
- package/package.json +21 -0
- package/scripts/ccp +126 -0
- package/scripts/status.js +32 -0
- package/src/config.ts +86 -0
- package/src/format-converter.ts +168 -0
- package/src/logger.ts +213 -0
- package/src/proxy.ts +83 -0
- package/src/scripts/init.ts +161 -0
- package/src/server.ts +338 -0
- package/src/types.ts +78 -0
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();
|