@exreve/exk 1.0.56 → 1.0.58
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/bin/exk +1 -5
- package/dist/cli/agentSession.js +57 -2
- package/dist/cli/app-child.js +2 -2
- package/dist/cli/benchmark-models-sdk.js +386 -0
- package/dist/cli/index.js +6 -4
- package/dist/cli/moduleMcpServer.js +45 -3
- package/dist/cli/proxyManager.js +214 -0
- package/dist/shared/types/embed.js +5 -0
- package/dist/shared/types.js +2 -0
- package/dist/ttc-cli.tar.gz +0 -0
- package/package.json +3 -1
package/bin/exk
CHANGED
|
@@ -3,17 +3,13 @@
|
|
|
3
3
|
// Runs compiled JavaScript directly (no tsx needed)
|
|
4
4
|
|
|
5
5
|
import { resolve, dirname } from 'path'
|
|
6
|
-
import { existsSync } from 'fs'
|
|
7
6
|
import { fileURLToPath } from 'url'
|
|
8
7
|
|
|
9
8
|
const __filename = fileURLToPath(import.meta.url)
|
|
10
9
|
const __dirname = dirname(__filename)
|
|
11
10
|
|
|
12
11
|
const pkgDir = resolve(__dirname, '..')
|
|
13
|
-
|
|
14
|
-
const entryPoint = existsSync(resolve(pkgDir, 'dist', 'cli', 'index.js'))
|
|
15
|
-
? resolve(pkgDir, 'dist', 'cli', 'index.js')
|
|
16
|
-
: resolve(pkgDir, 'dist', 'index.js')
|
|
12
|
+
const entryPoint = resolve(pkgDir, 'dist', 'index.js')
|
|
17
13
|
|
|
18
14
|
const args = process.argv.slice(2)
|
|
19
15
|
|
package/dist/cli/agentSession.js
CHANGED
|
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from '
|
|
|
4
4
|
import { symlink as fsSymlink } from 'fs';
|
|
5
5
|
import { getSkillContent } from './skills/index.js';
|
|
6
6
|
import { isLocalModel, unwrapModelName, startOpenAIAdapter, getAdapterConfig } from './openaiAdapter.js';
|
|
7
|
+
import { ensureProxy } from './proxyManager.js';
|
|
7
8
|
import { createModuleMcpServer } from './moduleMcpServer.js';
|
|
8
9
|
import path from 'path';
|
|
9
10
|
import os from 'os';
|
|
@@ -203,6 +204,11 @@ const PROVIDERS = {
|
|
|
203
204
|
baseUrl: 'https://openrouter.ai/api',
|
|
204
205
|
models: ['gpt-oss-120b:cerebras'],
|
|
205
206
|
},
|
|
207
|
+
cerebras: {
|
|
208
|
+
apiKey: '', // Populated from ai-config.json cerebrasApiKey (served by backend)
|
|
209
|
+
baseUrl: '', // Set dynamically when proxy starts
|
|
210
|
+
models: ['zai-glm-4.7'],
|
|
211
|
+
},
|
|
206
212
|
};
|
|
207
213
|
/** Resolve which provider to use based on model name or explicit provider ID.
|
|
208
214
|
* 1. Populate provider API keys from ai-config.json (served by backend).
|
|
@@ -214,6 +220,7 @@ function resolveProvider(model, providerId) {
|
|
|
214
220
|
const aiConfig = loadAiConfig();
|
|
215
221
|
PROVIDERS.minimax.apiKey = aiConfig.minimaxApiKey || process.env.MINIMAX_API_KEY || '';
|
|
216
222
|
PROVIDERS.openrouter.apiKey = aiConfig.openrouterApiKey || process.env.OPENROUTER_API_KEY || '';
|
|
223
|
+
PROVIDERS.cerebras.apiKey = aiConfig.cerebrasApiKey || process.env.CEREBRAS_API_KEY || '';
|
|
217
224
|
if (!PROVIDERS.zai.apiKey)
|
|
218
225
|
PROVIDERS.zai.apiKey = aiConfig.apiKey || '';
|
|
219
226
|
// 1. Explicit provider selection
|
|
@@ -249,12 +256,13 @@ function loadAiConfig() {
|
|
|
249
256
|
const proxy = typeof config.proxy === 'string' ? config.proxy.trim() : '';
|
|
250
257
|
const minimaxApiKey = typeof config.minimaxApiKey === 'string' ? config.minimaxApiKey.trim() : '';
|
|
251
258
|
const openrouterApiKey = typeof config.openrouterApiKey === 'string' ? config.openrouterApiKey.trim() : '';
|
|
252
|
-
const
|
|
259
|
+
const cerebrasApiKey = typeof config.cerebrasApiKey === 'string' ? config.cerebrasApiKey.trim() : '';
|
|
260
|
+
const result = { apiKey, baseUrl, model, proxy, minimaxApiKey, openrouterApiKey, cerebrasApiKey };
|
|
253
261
|
_aiConfigCache = { data: result, ts: now };
|
|
254
262
|
return result;
|
|
255
263
|
}
|
|
256
264
|
catch {
|
|
257
|
-
const fallback = { apiKey: '', baseUrl: '', model: DEFAULT_AI_MODEL, proxy: '', minimaxApiKey: '', openrouterApiKey: '' };
|
|
265
|
+
const fallback = { apiKey: '', baseUrl: '', model: DEFAULT_AI_MODEL, proxy: '', minimaxApiKey: '', openrouterApiKey: '', cerebrasApiKey: '' };
|
|
258
266
|
_aiConfigCache = { data: fallback, ts: now };
|
|
259
267
|
return fallback;
|
|
260
268
|
}
|
|
@@ -873,6 +881,23 @@ export class AgentSessionManager {
|
|
|
873
881
|
settingsEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL = resolved.model;
|
|
874
882
|
settingsEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1';
|
|
875
883
|
}
|
|
884
|
+
// For Cerebras: start anthropic-proxy-rs and route through it
|
|
885
|
+
if (resolved.provider === 'cerebras') {
|
|
886
|
+
const proxyUrl = await ensureProxy({
|
|
887
|
+
upstreamUrl: 'https://api.cerebras.ai/v1',
|
|
888
|
+
apiKey: resolved.apiKey,
|
|
889
|
+
model: resolved.model,
|
|
890
|
+
});
|
|
891
|
+
effectiveApiKey = 'cerebras-via-proxy'; // Proxy doesn't validate the key
|
|
892
|
+
effectiveEnv = envForClaudeCodeChild(undefined, { ...resolved, baseUrl: proxyUrl });
|
|
893
|
+
settingsEnv.ANTHROPIC_API_KEY = effectiveApiKey;
|
|
894
|
+
settingsEnv.ANTHROPIC_BASE_URL = proxyUrl;
|
|
895
|
+
settingsEnv.ANTHROPIC_MODEL = resolved.model;
|
|
896
|
+
settingsEnv.ANTHROPIC_DEFAULT_SONNET_MODEL = resolved.model;
|
|
897
|
+
settingsEnv.ANTHROPIC_DEFAULT_OPUS_MODEL = resolved.model;
|
|
898
|
+
settingsEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL = resolved.model;
|
|
899
|
+
settingsEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1';
|
|
900
|
+
}
|
|
876
901
|
effectiveSettings = { env: settingsEnv };
|
|
877
902
|
console.log(`[agentSession] Provider: ${resolved.provider}, baseUrl: ${resolved.baseUrl}, model: ${resolved.model}`);
|
|
878
903
|
}
|
|
@@ -941,6 +966,36 @@ export class AgentSessionManager {
|
|
|
941
966
|
session.claudeSessionId = msg.session_id;
|
|
942
967
|
saveSessionState(sessionId, { claudeSessionId: msg.session_id, model: session.model, provider: resolveProvider(session.model).provider, updatedAt: Date.now() });
|
|
943
968
|
}
|
|
969
|
+
// Detect context window limit error from the API
|
|
970
|
+
// The SDK sends this as an assistant message with error: 'max_output_tokens'
|
|
971
|
+
// when the provider returns 'model_context_window_exceeded'
|
|
972
|
+
const isContextWindowError = msg.error === 'max_output_tokens' && ((typeof msg.message === 'string' && msg.message.includes('context window limit')) ||
|
|
973
|
+
(Array.isArray(msg.message?.content) && msg.message.content.some((c) => typeof c?.text === 'string' && c.text.includes('context window limit'))));
|
|
974
|
+
if (isContextWindowError) {
|
|
975
|
+
console.warn(`[AgentSessionManager] Context window limit reached for session ${sessionId}. Clearing context for fresh start.`);
|
|
976
|
+
// Clear the session ID so next prompt starts fresh (no resume)
|
|
977
|
+
session.claudeSessionId = undefined;
|
|
978
|
+
session.contextLost = true;
|
|
979
|
+
deleteSessionState(sessionId);
|
|
980
|
+
// Emit a user-friendly system message
|
|
981
|
+
onOutput({
|
|
982
|
+
type: 'system',
|
|
983
|
+
data: {
|
|
984
|
+
message: 'Context window limit reached. Session context has been cleared. The next prompt will start fresh with a summary of previous conversation.',
|
|
985
|
+
subtype: 'context_window_reset',
|
|
986
|
+
},
|
|
987
|
+
timestamp: Date.now(),
|
|
988
|
+
metadata: {
|
|
989
|
+
subtype: 'context_window_reset',
|
|
990
|
+
contextInfo: {
|
|
991
|
+
messageCount: session.messages.length,
|
|
992
|
+
totalInputTokens: session.totalInputTokens,
|
|
993
|
+
totalOutputTokens: session.totalOutputTokens,
|
|
994
|
+
totalTokens: session.totalInputTokens + session.totalOutputTokens,
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
}
|
|
944
999
|
session.messages.push({
|
|
945
1000
|
role: 'assistant',
|
|
946
1001
|
content: msg.message,
|
package/dist/cli/app-child.js
CHANGED
|
@@ -211,7 +211,7 @@ async function connect() {
|
|
|
211
211
|
const deviceId = await getDeviceId();
|
|
212
212
|
return new Promise((resolve, reject) => {
|
|
213
213
|
const socket = io(config.apiUrl, {
|
|
214
|
-
transports: ['
|
|
214
|
+
transports: ['polling', 'websocket'],
|
|
215
215
|
});
|
|
216
216
|
socket.on('connect', () => {
|
|
217
217
|
socket.emit('register', { type: 'cli', deviceId });
|
|
@@ -516,7 +516,7 @@ async function runDaemon(foreground = false, email) {
|
|
|
516
516
|
const connectAndRegister = async () => {
|
|
517
517
|
try {
|
|
518
518
|
socket = io(config.apiUrl, {
|
|
519
|
-
transports: ['
|
|
519
|
+
transports: ['polling', 'websocket'],
|
|
520
520
|
reconnection: true,
|
|
521
521
|
reconnectionDelay: 1000,
|
|
522
522
|
reconnectionDelayMax: 10000,
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SDK Benchmark: MiniMax M2.7-highspeed vs Cerebras zai-glm-4.7 (via anthropic-proxy-rs)
|
|
3
|
+
*
|
|
4
|
+
* Both providers are tested through the Claude Agent SDK query().
|
|
5
|
+
* Task: Generate a complete HTML real estate page.
|
|
6
|
+
* Measures: total output tokens, total duration, TPS.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* cd cli && npx tsx benchmark-models-sdk.ts
|
|
10
|
+
*
|
|
11
|
+
* Requirements:
|
|
12
|
+
* - @anthropic-ai/claude-agent-sdk installed
|
|
13
|
+
* - anthropic-proxy binary (cargo install --git https://github.com/m0n0x41d/anthropic-proxy-rs --locked)
|
|
14
|
+
* - MiniMax API key in ~/.talk-to-code/ai-config.json
|
|
15
|
+
*/
|
|
16
|
+
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
17
|
+
import { createRequire } from 'module';
|
|
18
|
+
import { execSync, spawn } from 'child_process';
|
|
19
|
+
import fs from 'fs';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import os from 'os';
|
|
22
|
+
// ── Config ──────────────────────────────────────────────────────────
|
|
23
|
+
const AI_CONFIG_PATH = path.join(os.homedir(), '.talk-to-code', 'ai-config.json');
|
|
24
|
+
const PROXY_PORT = 8401;
|
|
25
|
+
const EMPTY_DIR = path.join(os.homedir(), '.talk-to-code', 'empty-config-dir');
|
|
26
|
+
const TASK_PROMPT = `Create a complete, beautiful HTML real estate property listing page. Include:
|
|
27
|
+
- A hero section with a large property image placeholder and price overlay
|
|
28
|
+
- Property details: 4 bed, 3 bath, 2,450 sq ft, built 2021
|
|
29
|
+
- A photo gallery section with 6 image placeholders in a grid
|
|
30
|
+
- Features list with icons (pool, garage, garden, smart home, solar panels, fireplace)
|
|
31
|
+
- Neighborhood info section with nearby schools, restaurants, parks
|
|
32
|
+
- A mortgage calculator with sliders for down payment and interest rate
|
|
33
|
+
- An agent contact form with name, email, phone, message fields
|
|
34
|
+
- A Google Maps embed placeholder
|
|
35
|
+
- A "Similar Properties" section with 3 cards
|
|
36
|
+
- Full responsive design with a mobile hamburger menu
|
|
37
|
+
- Use CSS variables for the color scheme (warm earth tones)
|
|
38
|
+
- All in a single self-contained HTML file with embedded CSS and minimal JS for the calculator and menu
|
|
39
|
+
Output ONLY the HTML code, nothing else.`;
|
|
40
|
+
function loadConfig() {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(fs.readFileSync(AI_CONFIG_PATH, 'utf-8'));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// ── Colors ──────────────────────────────────────────────────────────
|
|
49
|
+
const GREEN = '\x1b[32m', RED = '\x1b[31m', CYAN = '\x1b[36m', YELLOW = '\x1b[33m';
|
|
50
|
+
const BOLD = '\x1b[1m', DIM = '\x1b[2m', MAGENTA = '\x1b[35m', RESET = '\x1b[0m';
|
|
51
|
+
function hr() { console.log('─'.repeat(70)); }
|
|
52
|
+
// ── Resolve Claude Code executable ──────────────────────────────────
|
|
53
|
+
function resolveClaudeExe() {
|
|
54
|
+
for (const pkg of [
|
|
55
|
+
'@anthropic-ai/claude-agent-sdk-linux-x64',
|
|
56
|
+
'@anthropic-ai/claude-agent-sdk-linux-x64-musl',
|
|
57
|
+
]) {
|
|
58
|
+
try {
|
|
59
|
+
const req = createRequire(import.meta.url);
|
|
60
|
+
const nativePkgPath = req.resolve(`${pkg}/package.json`);
|
|
61
|
+
const nativePath = path.join(path.dirname(nativePkgPath), 'claude');
|
|
62
|
+
if (fs.existsSync(nativePath)) {
|
|
63
|
+
try {
|
|
64
|
+
execSync(`${nativePath} --version`, { stdio: 'pipe', timeout: 5000 });
|
|
65
|
+
return nativePath;
|
|
66
|
+
}
|
|
67
|
+
catch { }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch { }
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const req = createRequire(import.meta.url);
|
|
74
|
+
const pkgPath = req.resolve('@anthropic-ai/claude-agent-sdk/package.json');
|
|
75
|
+
const cliPath = path.join(path.dirname(pkgPath), 'cli.js');
|
|
76
|
+
if (fs.existsSync(cliPath))
|
|
77
|
+
return cliPath;
|
|
78
|
+
}
|
|
79
|
+
catch { }
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
const CLAUDE_EXE = resolveClaudeExe();
|
|
83
|
+
// ── Proxy management ────────────────────────────────────────────────
|
|
84
|
+
let proxyProcess = null;
|
|
85
|
+
async function startProxy(apiKey, model, port) {
|
|
86
|
+
const proxyBin = path.join(os.homedir(), '.cargo', 'bin', 'anthropic-proxy');
|
|
87
|
+
if (!fs.existsSync(proxyBin)) {
|
|
88
|
+
throw new Error(`anthropic-proxy not found at ${proxyBin}`);
|
|
89
|
+
}
|
|
90
|
+
const proxyEnv = {};
|
|
91
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
92
|
+
if (v !== undefined)
|
|
93
|
+
proxyEnv[k] = v;
|
|
94
|
+
}
|
|
95
|
+
proxyEnv.UPSTREAM_BASE_URL = 'https://api.cerebras.ai/v1';
|
|
96
|
+
proxyEnv.UPSTREAM_API_KEY = apiKey;
|
|
97
|
+
proxyEnv.PORT = String(port);
|
|
98
|
+
proxyEnv.COMPLETION_MODEL = model;
|
|
99
|
+
proxyEnv.REASONING_MODEL = model;
|
|
100
|
+
proxyEnv.PATH = `${path.join(os.homedir(), '.cargo', 'bin')}:${proxyEnv.PATH || ''}`;
|
|
101
|
+
proxyProcess = spawn(proxyBin, [], { env: proxyEnv, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
102
|
+
// Wait for proxy to be ready
|
|
103
|
+
for (let i = 0; i < 30; i++) {
|
|
104
|
+
await new Promise(r => setTimeout(r, 300));
|
|
105
|
+
try {
|
|
106
|
+
const resp = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'Content-Type': 'application/json', 'x-api-key': 'ping', 'anthropic-version': '2023-06-01' },
|
|
109
|
+
body: JSON.stringify({ model, max_tokens: 4, messages: [{ role: 'user', content: 'hi' }] }),
|
|
110
|
+
});
|
|
111
|
+
if (resp.ok || resp.status === 400)
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
catch { }
|
|
115
|
+
}
|
|
116
|
+
throw new Error('Proxy health check timed out');
|
|
117
|
+
}
|
|
118
|
+
function stopProxy() {
|
|
119
|
+
if (proxyProcess) {
|
|
120
|
+
try {
|
|
121
|
+
proxyProcess.kill('SIGTERM');
|
|
122
|
+
}
|
|
123
|
+
catch { }
|
|
124
|
+
setTimeout(() => { try {
|
|
125
|
+
proxyProcess?.kill('SIGKILL');
|
|
126
|
+
}
|
|
127
|
+
catch { } }, 2000);
|
|
128
|
+
proxyProcess = null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async function runSdkBenchmark(provider) {
|
|
132
|
+
const start = Date.now();
|
|
133
|
+
let initMs = 0;
|
|
134
|
+
let firstTokenMs = 0;
|
|
135
|
+
let outputText = '';
|
|
136
|
+
let writeToolCalls = 0;
|
|
137
|
+
let assistantEvents = 0;
|
|
138
|
+
let toolResultEvents = 0;
|
|
139
|
+
let firstAssistant = true;
|
|
140
|
+
const eventTypes = new Set();
|
|
141
|
+
fs.mkdirSync(EMPTY_DIR, { recursive: true });
|
|
142
|
+
const env = {};
|
|
143
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
144
|
+
if (v !== undefined)
|
|
145
|
+
env[k] = v;
|
|
146
|
+
}
|
|
147
|
+
env.ANTHROPIC_API_KEY = provider.apiKey;
|
|
148
|
+
env.ANTHROPIC_BASE_URL = provider.baseUrl;
|
|
149
|
+
env.ANTHROPIC_MODEL = provider.model;
|
|
150
|
+
env.ANTHROPIC_DEFAULT_SONNET_MODEL = provider.model;
|
|
151
|
+
env.ANTHROPIC_DEFAULT_OPUS_MODEL = provider.model;
|
|
152
|
+
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = provider.model;
|
|
153
|
+
env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = '1';
|
|
154
|
+
env.IS_SANDBOX = '1';
|
|
155
|
+
env.CLAUDE_CONFIG_DIR = EMPTY_DIR;
|
|
156
|
+
try {
|
|
157
|
+
const q = query({
|
|
158
|
+
prompt: TASK_PROMPT,
|
|
159
|
+
options: {
|
|
160
|
+
apiKey: provider.apiKey,
|
|
161
|
+
model: provider.model,
|
|
162
|
+
cwd: '/tmp',
|
|
163
|
+
permissionMode: 'bypassPermissions',
|
|
164
|
+
allowDangerouslySkipPermissions: true,
|
|
165
|
+
maxTurns: 5,
|
|
166
|
+
env,
|
|
167
|
+
settings: { env },
|
|
168
|
+
...(CLAUDE_EXE ? { pathToClaudeCodeExecutable: CLAUDE_EXE } : {}),
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
for await (const event of q) {
|
|
172
|
+
const now = Date.now();
|
|
173
|
+
eventTypes.add(event.type);
|
|
174
|
+
if (event.type === 'system' && event.subtype === 'init') {
|
|
175
|
+
initMs = now - start;
|
|
176
|
+
}
|
|
177
|
+
if (event.type === 'assistant') {
|
|
178
|
+
if (firstAssistant) {
|
|
179
|
+
firstTokenMs = now - start;
|
|
180
|
+
firstAssistant = false;
|
|
181
|
+
}
|
|
182
|
+
assistantEvents++;
|
|
183
|
+
const data = event.data;
|
|
184
|
+
if (typeof data === 'string') {
|
|
185
|
+
try {
|
|
186
|
+
const parsed = JSON.parse(data);
|
|
187
|
+
for (const block of (parsed?.content || [])) {
|
|
188
|
+
if (block.type === 'text' && block.text) {
|
|
189
|
+
outputText += block.text;
|
|
190
|
+
}
|
|
191
|
+
if (block.type === 'tool_use') {
|
|
192
|
+
if (block.name === 'Write') {
|
|
193
|
+
writeToolCalls++;
|
|
194
|
+
// Capture the file content from Write tool input
|
|
195
|
+
if (block.input?.content) {
|
|
196
|
+
outputText += block.input.content;
|
|
197
|
+
}
|
|
198
|
+
if (block.input?.file_path) {
|
|
199
|
+
outputText += `\n<!-- File: ${block.input.file_path} -->\n`;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
outputText += data;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (event.type === 'tool_use_summary') {
|
|
211
|
+
toolResultEvents++;
|
|
212
|
+
// tool_use_summary contains tool results in metadata
|
|
213
|
+
const data = event.data;
|
|
214
|
+
if (typeof data === 'string') {
|
|
215
|
+
try {
|
|
216
|
+
const parsed = JSON.parse(data);
|
|
217
|
+
// Extract tool result text
|
|
218
|
+
const result = parsed?.metadata?.toolResult;
|
|
219
|
+
if (result) {
|
|
220
|
+
if (typeof result === 'string')
|
|
221
|
+
outputText += result;
|
|
222
|
+
else if (typeof result === 'object')
|
|
223
|
+
outputText += JSON.stringify(result);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch { }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (event.type === 'result') {
|
|
230
|
+
const totalMs = now - start;
|
|
231
|
+
// Also try to extract text from the result event itself
|
|
232
|
+
const data = event.data;
|
|
233
|
+
if (typeof data === 'string') {
|
|
234
|
+
try {
|
|
235
|
+
const parsed = JSON.parse(data);
|
|
236
|
+
if (parsed.result)
|
|
237
|
+
outputText += parsed.result;
|
|
238
|
+
}
|
|
239
|
+
catch { }
|
|
240
|
+
}
|
|
241
|
+
const outputChars = outputText.length;
|
|
242
|
+
const estimatedTokens = Math.round(outputChars / 3.5);
|
|
243
|
+
const tps = estimatedTokens > 0 && totalMs > 0 ? (estimatedTokens / (totalMs / 1000)) : 0;
|
|
244
|
+
return { provider: provider.name, totalMs, initMs, firstTokenMs, outputText, outputChars, estimatedTokens, tps, writeToolCalls, assistantEvents, toolResultEvents, allEventTypes: Array.from(eventTypes) };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const totalMs = Date.now() - start;
|
|
248
|
+
const outputChars = outputText.length;
|
|
249
|
+
const estimatedTokens = Math.round(outputChars / 3.5);
|
|
250
|
+
const tps = estimatedTokens > 0 && totalMs > 0 ? (estimatedTokens / (totalMs / 1000)) : 0;
|
|
251
|
+
return { provider: provider.name, totalMs, initMs, firstTokenMs, outputText, outputChars, estimatedTokens, tps, writeToolCalls, assistantEvents, toolResultEvents, allEventTypes: Array.from(eventTypes) };
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
return {
|
|
255
|
+
provider: provider.name, totalMs: Date.now() - start, initMs, firstTokenMs,
|
|
256
|
+
outputText, outputChars: outputText.length, estimatedTokens: 0, tps: 0,
|
|
257
|
+
writeToolCalls, assistantEvents, toolResultEvents, allEventTypes: Array.from(eventTypes),
|
|
258
|
+
error: err.message,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// ── Main ────────────────────────────────────────────────────────────
|
|
263
|
+
async function main() {
|
|
264
|
+
console.log(`\n${BOLD}╔══════════════════════════════════════════════════════════════════════╗${RESET}`);
|
|
265
|
+
console.log(`${BOLD}║ SDK Benchmark: MiniMax M2.7-highspeed vs Cerebras zai-glm-4.7 ║${RESET}`);
|
|
266
|
+
console.log(`${BOLD}║ Task: Generate a complete HTML real estate page ║${RESET}`);
|
|
267
|
+
console.log(`${BOLD}╚══════════════════════════════════════════════════════════════════════╝${RESET}\n`);
|
|
268
|
+
const config = loadConfig();
|
|
269
|
+
const minimaxKey = config.minimaxApiKey || process.env.MINIMAX_API_KEY || '';
|
|
270
|
+
const cerebrasKey = process.env.CEREBRAS_API_KEY || 'csk-866r3e66p52mcmj3v4f29fdc86nr8pjx6yd4chxhvk8v2f3r';
|
|
271
|
+
if (!CLAUDE_EXE) {
|
|
272
|
+
console.log(`${RED}ERROR: Claude Code executable not found. Need @anthropic-ai/claude-agent-sdk-linux-x64${RESET}`);
|
|
273
|
+
process.exit(1);
|
|
274
|
+
}
|
|
275
|
+
console.log(`${CYAN}Claude Exe:${RESET} ${CLAUDE_EXE}`);
|
|
276
|
+
console.log(`${CYAN}Task:${RESET} Generate HTML real estate page (~${TASK_PROMPT.length} char prompt)`);
|
|
277
|
+
console.log();
|
|
278
|
+
const providers = [];
|
|
279
|
+
if (minimaxKey) {
|
|
280
|
+
providers.push({
|
|
281
|
+
name: 'MiniMax M2.7-highspeed',
|
|
282
|
+
model: 'MiniMax-M2.7-highspeed',
|
|
283
|
+
apiKey: minimaxKey,
|
|
284
|
+
baseUrl: 'https://api.minimax.io/anthropic',
|
|
285
|
+
needsProxy: false,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
console.log(`${YELLOW}WARNING: No MiniMax API key. Skipping MiniMax test.${RESET}\n`);
|
|
290
|
+
}
|
|
291
|
+
providers.push({
|
|
292
|
+
name: 'Cerebras zai-glm-4.7',
|
|
293
|
+
model: 'zai-glm-4.7',
|
|
294
|
+
apiKey: cerebrasKey,
|
|
295
|
+
baseUrl: `http://127.0.0.1:${PROXY_PORT}`,
|
|
296
|
+
needsProxy: true,
|
|
297
|
+
proxyPort: PROXY_PORT,
|
|
298
|
+
});
|
|
299
|
+
const allResults = [];
|
|
300
|
+
for (const provider of providers) {
|
|
301
|
+
console.log(`${BOLD}${MAGENTA}═══ ${provider.name} ═══${RESET}`);
|
|
302
|
+
hr();
|
|
303
|
+
// Start proxy if needed
|
|
304
|
+
if (provider.needsProxy) {
|
|
305
|
+
console.log(`${DIM}Starting anthropic-proxy-rs for Cerebras...${RESET}`);
|
|
306
|
+
try {
|
|
307
|
+
await startProxy(cerebrasKey, provider.model, provider.proxyPort);
|
|
308
|
+
console.log(`${DIM}Proxy ready on port ${provider.proxyPort}${RESET}\n`);
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
console.log(`${RED}Proxy failed: ${err.message}. Skipping Cerebras.${RESET}\n`);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
console.log(`${CYAN}Model:${RESET} ${provider.model}`);
|
|
316
|
+
console.log(`${CYAN}Base URL:${RESET} ${provider.baseUrl}`);
|
|
317
|
+
console.log(`${CYAN}API Key:${RESET} ${provider.apiKey.slice(0, 12)}...`);
|
|
318
|
+
console.log();
|
|
319
|
+
// Run 1
|
|
320
|
+
console.log(`${DIM}[${new Date().toISOString()}] Starting query()...${RESET}`);
|
|
321
|
+
const result = await runSdkBenchmark(provider);
|
|
322
|
+
allResults.push(result);
|
|
323
|
+
if (result.error) {
|
|
324
|
+
console.log(`${RED}FAILED: ${result.error}${RESET}\n`);
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
console.log(`${GREEN}Completed in ${(result.totalMs / 1000).toFixed(1)}s${RESET}`);
|
|
328
|
+
console.log(` Init (spawn): ${result.initMs}ms`);
|
|
329
|
+
console.log(` Time to 1st token: ${result.firstTokenMs}ms`);
|
|
330
|
+
console.log(` Total duration: ${(result.totalMs / 1000).toFixed(1)}s`);
|
|
331
|
+
console.log(` Event types: ${result.allEventTypes.join(', ')}`);
|
|
332
|
+
console.log(` Assistant events: ${result.assistantEvents}`);
|
|
333
|
+
console.log(` Tool result events: ${result.toolResultEvents}`);
|
|
334
|
+
console.log(` Write tool calls: ${result.writeToolCalls}`);
|
|
335
|
+
console.log(` Output chars: ${result.outputChars.toLocaleString()}`);
|
|
336
|
+
console.log(` Estimated tokens: ~${result.estimatedTokens.toLocaleString()}`);
|
|
337
|
+
console.log(` Throughput: ${BOLD}${GREEN}${result.tps.toFixed(1)} tokens/sec${RESET}`);
|
|
338
|
+
// Save the output
|
|
339
|
+
const slug = provider.name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
340
|
+
const outputPath = `/tmp/benchmark-${slug}.txt`;
|
|
341
|
+
fs.writeFileSync(outputPath, result.outputText);
|
|
342
|
+
console.log(` Output saved: ${outputPath}`);
|
|
343
|
+
}
|
|
344
|
+
console.log();
|
|
345
|
+
// Stop proxy
|
|
346
|
+
if (provider.needsProxy) {
|
|
347
|
+
stopProxy();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// ── Comparison ────────────────────────────────────────────────────
|
|
351
|
+
console.log(`${BOLD}══════════════════════════════════════════════════════════════════════${RESET}`);
|
|
352
|
+
console.log(`${BOLD} COMPARISON${RESET}`);
|
|
353
|
+
console.log(`${BOLD}══════════════════════════════════════════════════════════════════════${RESET}\n`);
|
|
354
|
+
const validResults = allResults.filter(r => !r.error);
|
|
355
|
+
for (const r of validResults) {
|
|
356
|
+
console.log(` ${CYAN}${r.provider}:${RESET}`);
|
|
357
|
+
console.log(` Duration: ${(r.totalMs / 1000).toFixed(1)}s`);
|
|
358
|
+
console.log(` Tokens: ~${r.estimatedTokens.toLocaleString()}`);
|
|
359
|
+
console.log(` TPS: ${GREEN}${BOLD}${r.tps.toFixed(1)}${RESET}`);
|
|
360
|
+
console.log(` 1st token: ${(r.firstTokenMs / 1000).toFixed(1)}s`);
|
|
361
|
+
console.log();
|
|
362
|
+
}
|
|
363
|
+
if (validResults.length === 2) {
|
|
364
|
+
const [a, b] = validResults;
|
|
365
|
+
const faster = a.totalMs < b.totalMs ? a : b;
|
|
366
|
+
const slower = a.totalMs < b.totalMs ? b : a;
|
|
367
|
+
const speedup = (slower.totalMs / faster.totalMs).toFixed(1);
|
|
368
|
+
const saved = ((slower.totalMs - faster.totalMs) / 1000).toFixed(1);
|
|
369
|
+
console.log(` ${BOLD}Winner:${RESET} ${GREEN}${faster.provider}${RESET} is ${BOLD}${speedup}x faster${RESET}`);
|
|
370
|
+
console.log(` ${BOLD}Time saved:${RESET} ${saved}s`);
|
|
371
|
+
console.log();
|
|
372
|
+
if (faster.tps > slower.tps) {
|
|
373
|
+
const tpsDiff = (faster.tps / slower.tps).toFixed(1);
|
|
374
|
+
console.log(` ${BOLD}TPS advantage:${RESET} ${faster.provider} is ${tpsDiff}x higher throughput`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
console.log(`${BOLD}══════════════════════════════════════════════════════════════════════${RESET}\n`);
|
|
378
|
+
}
|
|
379
|
+
process.on('SIGINT', () => { stopProxy(); process.exit(130); });
|
|
380
|
+
process.on('SIGTERM', () => { stopProxy(); process.exit(143); });
|
|
381
|
+
process.on('exit', () => stopProxy());
|
|
382
|
+
main().catch((err) => {
|
|
383
|
+
console.error('Unhandled error:', err);
|
|
384
|
+
stopProxy();
|
|
385
|
+
process.exit(1);
|
|
386
|
+
});
|
package/dist/cli/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import path from 'path';
|
|
|
8
8
|
import os, { networkInterfaces } from 'os';
|
|
9
9
|
import { Buffer } from 'buffer';
|
|
10
10
|
import crypto from 'crypto';
|
|
11
|
+
import { stopAllProxies } from './proxyManager.js';
|
|
11
12
|
import { createProject, deleteProject } from './projectManager.js';
|
|
12
13
|
import { startSending, startReceiving } from './transferService.js';
|
|
13
14
|
import { registerGitHubHandlers } from './githubHandlers.js';
|
|
@@ -212,7 +213,7 @@ async function connect() {
|
|
|
212
213
|
const deviceId = await getDeviceId();
|
|
213
214
|
return new Promise((resolve, reject) => {
|
|
214
215
|
const socket = io(config.apiUrl, {
|
|
215
|
-
transports: ['
|
|
216
|
+
transports: ['polling', 'websocket'],
|
|
216
217
|
});
|
|
217
218
|
socket.on('connect', () => {
|
|
218
219
|
socket.emit('register', { type: 'cli', deviceId });
|
|
@@ -230,8 +231,8 @@ async function connect() {
|
|
|
230
231
|
/** Read CLI version from the npm package.json (works when installed via npm i -g @exreve/exk) */
|
|
231
232
|
function getCliVersion() {
|
|
232
233
|
try {
|
|
233
|
-
// Compiled JS is in dist/, package.json is
|
|
234
|
-
const pkgPath = path.join(__dirname, '..', 'package.json');
|
|
234
|
+
// Compiled JS is in dist/cli/, package.json is two levels up
|
|
235
|
+
const pkgPath = path.join(__dirname, '..', '..', 'package.json');
|
|
235
236
|
const raw = fsSync.readFileSync(pkgPath, 'utf-8');
|
|
236
237
|
const pkg = JSON.parse(raw);
|
|
237
238
|
return pkg.version || '0.0.0';
|
|
@@ -473,7 +474,7 @@ async function runDaemon(foreground = false, email) {
|
|
|
473
474
|
socket = null;
|
|
474
475
|
}
|
|
475
476
|
socket = io(config.apiUrl, {
|
|
476
|
-
transports: ['
|
|
477
|
+
transports: ['polling', 'websocket'],
|
|
477
478
|
reconnection: true,
|
|
478
479
|
reconnectionDelay: 1000,
|
|
479
480
|
reconnectionDelayMax: 10000,
|
|
@@ -971,6 +972,7 @@ async function runDaemon(foreground = false, email) {
|
|
|
971
972
|
}
|
|
972
973
|
socket.disconnect();
|
|
973
974
|
}
|
|
975
|
+
stopAllProxies();
|
|
974
976
|
process.exit(0);
|
|
975
977
|
};
|
|
976
978
|
process.on('SIGTERM', shutdown);
|
|
@@ -9,6 +9,7 @@ import { z } from 'zod';
|
|
|
9
9
|
import * as fs from 'fs';
|
|
10
10
|
import * as path from 'path';
|
|
11
11
|
import * as os from 'os';
|
|
12
|
+
import sharp from 'sharp';
|
|
12
13
|
import { getOpenrouterApiKey, getApiUrl } from './agentSession.js';
|
|
13
14
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
14
15
|
/** Comprehensive MIME type map for file extension detection */
|
|
@@ -51,12 +52,53 @@ function getMimeType(filePath) {
|
|
|
51
52
|
const ext = path.extname(filePath).toLowerCase().replace('.', '');
|
|
52
53
|
return MIME_MAP[ext] || 'application/octet-stream';
|
|
53
54
|
}
|
|
55
|
+
const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff', 'tif', 'avif']);
|
|
56
|
+
const MAX_IMAGE_DIMENSION = 2048; // max width or height in pixels
|
|
57
|
+
const MAX_IMAGE_BYTES = 2 * 1024 * 1024; // 2 MB target after compression
|
|
58
|
+
function isImageFile(filePath) {
|
|
59
|
+
const ext = path.extname(filePath).toLowerCase().replace('.', '');
|
|
60
|
+
return IMAGE_EXTENSIONS.has(ext);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Compress and resize an image buffer using sharp.
|
|
64
|
+
* - Resizes so neither dimension exceeds MAX_IMAGE_DIMENSION (fit: inside, no upscale)
|
|
65
|
+
* - Converts to JPEG quality 80 (or WebP for non-photo sources)
|
|
66
|
+
* - If already small enough, returns the original buffer unchanged
|
|
67
|
+
*/
|
|
68
|
+
async function compressImage(buf) {
|
|
69
|
+
const metadata = await sharp(buf).metadata();
|
|
70
|
+
const { width = 0, height = 0, size = 0 } = metadata;
|
|
71
|
+
// If already under limits, keep as-is
|
|
72
|
+
const needsResize = width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION;
|
|
73
|
+
const needsCompress = (size || buf.length) > MAX_IMAGE_BYTES;
|
|
74
|
+
if (!needsResize && !needsCompress) {
|
|
75
|
+
// Keep original format
|
|
76
|
+
const fmt = metadata.format || 'jpeg';
|
|
77
|
+
const mime = fmt === 'png' ? 'image/png' : fmt === 'webp' ? 'image/webp' : 'image/jpeg';
|
|
78
|
+
return { data: buf, mime };
|
|
79
|
+
}
|
|
80
|
+
let pipeline = sharp(buf)
|
|
81
|
+
.resize(MAX_IMAGE_DIMENSION, MAX_IMAGE_DIMENSION, { fit: 'inside', withoutEnlargement: true });
|
|
82
|
+
// Convert to JPEG for best compression on photos; use WebP for PNGs with alpha
|
|
83
|
+
const hasAlpha = metadata.hasAlpha;
|
|
84
|
+
if (hasAlpha) {
|
|
85
|
+
pipeline = pipeline.webp({ quality: 80 });
|
|
86
|
+
return { data: await pipeline.toBuffer(), mime: 'image/webp' };
|
|
87
|
+
}
|
|
88
|
+
pipeline = pipeline.jpeg({ quality: 80 });
|
|
89
|
+
return { data: await pipeline.toBuffer(), mime: 'image/jpeg' };
|
|
90
|
+
}
|
|
54
91
|
/**
|
|
55
|
-
* Convert a file to a data URI (base64 encoded)
|
|
92
|
+
* Convert a file to a data URI (base64 encoded).
|
|
93
|
+
* Images are compressed and resized before encoding.
|
|
56
94
|
*/
|
|
57
|
-
function fileToDataUri(filePath) {
|
|
95
|
+
async function fileToDataUri(filePath) {
|
|
58
96
|
try {
|
|
59
97
|
const buf = fs.readFileSync(filePath);
|
|
98
|
+
if (isImageFile(filePath)) {
|
|
99
|
+
const { data, mime } = await compressImage(buf);
|
|
100
|
+
return `data:${mime};base64,${data.toString('base64')}`;
|
|
101
|
+
}
|
|
60
102
|
const mime = getMimeType(filePath);
|
|
61
103
|
return `data:${mime};base64,${buf.toString('base64')}`;
|
|
62
104
|
}
|
|
@@ -83,7 +125,7 @@ function createAnalyzeImageTool(attachmentDir) {
|
|
|
83
125
|
if (!fs.existsSync(imagePath)) {
|
|
84
126
|
return { content: [{ type: 'text', text: `Error: Image file not found: ${args.image_path}` }], isError: true };
|
|
85
127
|
}
|
|
86
|
-
const dataUri = fileToDataUri(imagePath);
|
|
128
|
+
const dataUri = await fileToDataUri(imagePath);
|
|
87
129
|
if (!dataUri) {
|
|
88
130
|
return { content: [{ type: 'text', text: `Error: Could not read image file: ${args.image_path}` }], isError: true };
|
|
89
131
|
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxy Manager - Manages anthropic-proxy-rs instances
|
|
3
|
+
*
|
|
4
|
+
* Auto-installs anthropic-proxy-rs via cargo if not found.
|
|
5
|
+
* Provides start/stop/reuse of proxy instances for any OpenAI-compatible provider.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { ensureProxy, stopAllProxies } from './proxyManager.js'
|
|
9
|
+
*
|
|
10
|
+
* const proxyUrl = await ensureProxy({
|
|
11
|
+
* upstreamUrl: 'https://api.cerebras.ai/v1',
|
|
12
|
+
* apiKey: 'csk-...',
|
|
13
|
+
* model: 'zai-glm-4.7',
|
|
14
|
+
* })
|
|
15
|
+
* // proxyUrl = 'http://127.0.0.1:8321' — Anthropic-compatible endpoint
|
|
16
|
+
*/
|
|
17
|
+
import { spawn, execSync } from 'child_process';
|
|
18
|
+
import { existsSync } from 'fs';
|
|
19
|
+
import path from 'path';
|
|
20
|
+
import os from 'os';
|
|
21
|
+
import net from 'net';
|
|
22
|
+
const runningProxies = new Map();
|
|
23
|
+
// ── Binary resolution ───────────────────────────────────────────────
|
|
24
|
+
function getProxyBinPath() {
|
|
25
|
+
return path.join(os.homedir(), '.cargo', 'bin', 'anthropic-proxy');
|
|
26
|
+
}
|
|
27
|
+
function isCargoInstalled() {
|
|
28
|
+
try {
|
|
29
|
+
execSync('cargo --version', { stdio: 'pipe', timeout: 5000 });
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function isProxyInstalled() {
|
|
37
|
+
return existsSync(getProxyBinPath());
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Install anthropic-proxy-rs via cargo.
|
|
41
|
+
* Throws if cargo is not available or install fails.
|
|
42
|
+
*/
|
|
43
|
+
export async function installProxy() {
|
|
44
|
+
const binPath = getProxyBinPath();
|
|
45
|
+
if (existsSync(binPath))
|
|
46
|
+
return;
|
|
47
|
+
console.log('[proxyManager] anthropic-proxy not found, installing via cargo...');
|
|
48
|
+
if (!isCargoInstalled()) {
|
|
49
|
+
console.log('[proxyManager] Installing Rust toolchain...');
|
|
50
|
+
execSync("curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y", {
|
|
51
|
+
stdio: 'pipe',
|
|
52
|
+
timeout: 120_000,
|
|
53
|
+
env: { ...process.env },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const cargoBin = path.join(os.homedir(), '.cargo', 'bin', 'cargo');
|
|
57
|
+
const cargoCmd = existsSync(cargoBin) ? cargoBin : 'cargo';
|
|
58
|
+
execSync(`${cargoCmd} install --git https://github.com/m0n0x41d/anthropic-proxy-rs --locked`, {
|
|
59
|
+
stdio: 'pipe',
|
|
60
|
+
timeout: 300_000,
|
|
61
|
+
env: {
|
|
62
|
+
...process.env,
|
|
63
|
+
PATH: `${path.join(os.homedir(), '.cargo', 'bin')}:${process.env.PATH || ''}`,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
if (!existsSync(binPath)) {
|
|
67
|
+
throw new Error('anthropic-proxy installation completed but binary not found at ' + binPath);
|
|
68
|
+
}
|
|
69
|
+
console.log('[proxyManager] anthropic-proxy installed successfully');
|
|
70
|
+
}
|
|
71
|
+
// ── Port finding ────────────────────────────────────────────────────
|
|
72
|
+
function findFreePort(start, end) {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
function tryPort(port) {
|
|
75
|
+
if (port > end) {
|
|
76
|
+
reject(new Error(`No free port in range ${start}-${end}`));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const server = net.createServer();
|
|
80
|
+
server.listen(port, '127.0.0.1', () => {
|
|
81
|
+
const addr = server.address();
|
|
82
|
+
server.close(() => resolve(typeof addr === 'object' && addr ? addr.port : port));
|
|
83
|
+
});
|
|
84
|
+
server.on('error', () => tryPort(port + 1));
|
|
85
|
+
}
|
|
86
|
+
tryPort(start);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
// ── Wait for proxy to be ready ──────────────────────────────────────
|
|
90
|
+
function waitForProxy(port, timeoutMs) {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const start = Date.now();
|
|
93
|
+
function check() {
|
|
94
|
+
const socket = new net.Socket();
|
|
95
|
+
socket.setTimeout(500);
|
|
96
|
+
socket.on('connect', () => {
|
|
97
|
+
socket.destroy();
|
|
98
|
+
resolve();
|
|
99
|
+
});
|
|
100
|
+
socket.on('error', () => {
|
|
101
|
+
socket.destroy();
|
|
102
|
+
if (Date.now() - start > timeoutMs) {
|
|
103
|
+
reject(new Error(`Proxy on port ${port} did not become ready within ${timeoutMs}ms`));
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
setTimeout(check, 100);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
socket.on('timeout', () => {
|
|
110
|
+
socket.destroy();
|
|
111
|
+
if (Date.now() - start > timeoutMs) {
|
|
112
|
+
reject(new Error(`Proxy on port ${port} did not become ready within ${timeoutMs}ms`));
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
setTimeout(check, 100);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
socket.connect(port, '127.0.0.1');
|
|
119
|
+
}
|
|
120
|
+
check();
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
// ── Start / get proxy ───────────────────────────────────────────────
|
|
124
|
+
function proxyKey(config) {
|
|
125
|
+
return `${config.upstreamUrl}::${config.model}`;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Ensure a proxy is running for the given config.
|
|
129
|
+
* Reuses existing proxy if already running for the same upstream+model.
|
|
130
|
+
* Auto-installs anthropic-proxy-rs if not found.
|
|
131
|
+
*
|
|
132
|
+
* Returns the local proxy URL (e.g. http://127.0.0.1:8321).
|
|
133
|
+
*/
|
|
134
|
+
export async function ensureProxy(config) {
|
|
135
|
+
const key = proxyKey(config);
|
|
136
|
+
// Reuse existing
|
|
137
|
+
const existing = runningProxies.get(key);
|
|
138
|
+
if (existing && !existing.process.killed) {
|
|
139
|
+
return existing.url;
|
|
140
|
+
}
|
|
141
|
+
// Auto-install if needed
|
|
142
|
+
await installProxy();
|
|
143
|
+
const portStart = config.portStart ?? 8321;
|
|
144
|
+
const portEnd = config.portEnd ?? 8340;
|
|
145
|
+
const port = await findFreePort(portStart, portEnd);
|
|
146
|
+
const binPath = getProxyBinPath();
|
|
147
|
+
const env = {};
|
|
148
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
149
|
+
if (v !== undefined)
|
|
150
|
+
env[k] = v;
|
|
151
|
+
}
|
|
152
|
+
env.UPSTREAM_BASE_URL = config.upstreamUrl;
|
|
153
|
+
env.UPSTREAM_API_KEY = config.apiKey;
|
|
154
|
+
env.PORT = String(port);
|
|
155
|
+
env.COMPLETION_MODEL = config.model;
|
|
156
|
+
env.REASONING_MODEL = config.model;
|
|
157
|
+
env.PATH = `${path.join(os.homedir(), '.cargo', 'bin')}:${env.PATH || ''}`;
|
|
158
|
+
const child = spawn(binPath, [], {
|
|
159
|
+
env,
|
|
160
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
161
|
+
detached: false,
|
|
162
|
+
});
|
|
163
|
+
// Log proxy stderr for debugging
|
|
164
|
+
child.stderr?.on('data', (data) => {
|
|
165
|
+
const msg = data.toString().trim();
|
|
166
|
+
if (msg.includes('ERROR') || msg.includes('WARN')) {
|
|
167
|
+
console.error(`[proxyManager:${port}] ${msg}`);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
child.on('exit', (code) => {
|
|
171
|
+
runningProxies.delete(key);
|
|
172
|
+
if (code && code !== 0) {
|
|
173
|
+
console.error(`[proxyManager] Proxy exited with code ${code}`);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
// Wait for proxy to be listening
|
|
177
|
+
await waitForProxy(port, 10_000);
|
|
178
|
+
const url = `http://127.0.0.1:${port}`;
|
|
179
|
+
runningProxies.set(key, { process: child, port, url, key });
|
|
180
|
+
console.log(`[proxyManager] Started proxy on port ${port} -> ${config.upstreamUrl} (model: ${config.model})`);
|
|
181
|
+
return url;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Stop a specific proxy by config.
|
|
185
|
+
*/
|
|
186
|
+
export function stopProxy(config) {
|
|
187
|
+
const key = proxyKey(config);
|
|
188
|
+
const proxy = runningProxies.get(key);
|
|
189
|
+
if (proxy) {
|
|
190
|
+
try {
|
|
191
|
+
proxy.process.kill('SIGTERM');
|
|
192
|
+
}
|
|
193
|
+
catch { }
|
|
194
|
+
runningProxies.delete(key);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Stop all running proxies.
|
|
199
|
+
*/
|
|
200
|
+
export function stopAllProxies() {
|
|
201
|
+
for (const [, proxy] of runningProxies) {
|
|
202
|
+
try {
|
|
203
|
+
proxy.process.kill('SIGTERM');
|
|
204
|
+
}
|
|
205
|
+
catch { }
|
|
206
|
+
}
|
|
207
|
+
runningProxies.clear();
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Check if anthropic-proxy-rs is installed.
|
|
211
|
+
*/
|
|
212
|
+
export function isInstalled() {
|
|
213
|
+
return isProxyInstalled();
|
|
214
|
+
}
|
package/dist/shared/types.js
CHANGED
package/dist/ttc-cli.tar.gz
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exreve/exk",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.58",
|
|
4
4
|
"description": "exk - Control Claude CLI with voice and programmable interfaces",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -44,12 +44,14 @@
|
|
|
44
44
|
"fastify": "^5.7.2",
|
|
45
45
|
"node-fetch": "^3.3.2",
|
|
46
46
|
"pino-pretty": "^11.0.0",
|
|
47
|
+
"sharp": "^0.34.5",
|
|
47
48
|
"socket.io-client": "^4.8.1",
|
|
48
49
|
"uuid": "^11.0.3"
|
|
49
50
|
},
|
|
50
51
|
"devDependencies": {
|
|
51
52
|
"@types/chokidar": "^2.1.3",
|
|
52
53
|
"@types/node": "^22.10.2",
|
|
54
|
+
"@types/sharp": "^0.31.1",
|
|
53
55
|
"@types/uuid": "^10.0.0",
|
|
54
56
|
"@vercel/ncc": "^0.38.1",
|
|
55
57
|
"esbuild": "^0.27.2",
|