@exreve/exk 1.0.54 → 1.0.56
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 +5 -1
- package/dist/ttc-cli.tar.gz +0 -0
- package/package.json +1 -1
- package/dist/agentLogger.js +0 -143
- package/dist/agentSession.js +0 -1455
- package/dist/app-child.js +0 -1038
- package/dist/appHandlers.js +0 -142
- package/dist/appManager.js +0 -212
- package/dist/appRunner.js +0 -383
- package/dist/benchmark-startup.js +0 -347
- package/dist/cloudflaredHandlers.js +0 -279
- package/dist/containerHandlers.js +0 -193
- package/dist/fsHandlers.js +0 -86
- package/dist/githubHandlers.js +0 -525
- package/dist/index.js +0 -1262
- package/dist/moduleMcpServer.js +0 -284
- package/dist/openaiAdapter.js +0 -181
- package/dist/projectAnalyzer.js +0 -330
- package/dist/projectManager.js +0 -69
- package/dist/runnerGenerator.js +0 -210
- package/dist/sessionHandlers.js +0 -271
- package/dist/skills/index.js +0 -117
- package/dist/transferService.js +0 -284
- package/dist/updateHandlers.js +0 -82
- package/dist/updater.js +0 -422
package/dist/moduleMcpServer.js
DELETED
|
@@ -1,284 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Module MCP Server
|
|
3
|
-
*
|
|
4
|
-
* Provides built-in tools like analyze_image for vision capabilities
|
|
5
|
-
* and send_file for displaying files to the user in chat.
|
|
6
|
-
*/
|
|
7
|
-
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
|
|
8
|
-
import { z } from 'zod';
|
|
9
|
-
import * as fs from 'fs';
|
|
10
|
-
import * as path from 'path';
|
|
11
|
-
import * as os from 'os';
|
|
12
|
-
import { getOpenrouterApiKey, getApiUrl } from './agentSession.js';
|
|
13
|
-
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
14
|
-
/** Comprehensive MIME type map for file extension detection */
|
|
15
|
-
const MIME_MAP = {
|
|
16
|
-
// Images
|
|
17
|
-
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
|
|
18
|
-
gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp',
|
|
19
|
-
svg: 'image/svg+xml', ico: 'image/x-icon', tiff: 'image/tiff', tif: 'image/tiff',
|
|
20
|
-
avif: 'image/avif',
|
|
21
|
-
// Audio
|
|
22
|
-
mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg',
|
|
23
|
-
m4a: 'audio/mp4', flac: 'audio/flac', aac: 'audio/aac',
|
|
24
|
-
wma: 'audio/x-ms-wma', opus: 'audio/opus',
|
|
25
|
-
// Video
|
|
26
|
-
mp4: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska',
|
|
27
|
-
avi: 'video/x-msvideo', mov: 'video/quicktime', wmv: 'video/x-ms-wmv',
|
|
28
|
-
m4v: 'video/mp4', '3gp': 'video/3gpp',
|
|
29
|
-
// Documents
|
|
30
|
-
pdf: 'application/pdf',
|
|
31
|
-
// Text / Code
|
|
32
|
-
txt: 'text/plain', md: 'text/markdown', csv: 'text/csv',
|
|
33
|
-
json: 'application/json', xml: 'text/xml', yaml: 'text/yaml', yml: 'text/yaml',
|
|
34
|
-
toml: 'text/plain', html: 'text/html', htm: 'text/html',
|
|
35
|
-
css: 'text/css', scss: 'text/x-scss', less: 'text/x-less',
|
|
36
|
-
js: 'text/javascript', mjs: 'text/javascript', cjs: 'text/javascript',
|
|
37
|
-
ts: 'text/typescript', tsx: 'text/typescript',
|
|
38
|
-
jsx: 'text/javascript', py: 'text/x-python', rs: 'text/x-rust',
|
|
39
|
-
go: 'text/x-go', java: 'text/x-java', c: 'text/x-c', cpp: 'text/x-c++',
|
|
40
|
-
h: 'text/x-c', hpp: 'text/x-c++', rb: 'text/x-ruby', php: 'text/x-php',
|
|
41
|
-
sh: 'text/x-shellscript', bash: 'text/x-shellscript', zsh: 'text/x-shellscript',
|
|
42
|
-
sql: 'text/x-sql', graphql: 'text/graphql', vue: 'text/x-vue',
|
|
43
|
-
svelte: 'text/x-svelte', dart: 'text/x-dart', swift: 'text/x-swift',
|
|
44
|
-
kt: 'text/x-kotlin', scala: 'text/x-scala', lua: 'text/x-lua',
|
|
45
|
-
r: 'text/x-r', dockerfile: 'text/x-dockerfile',
|
|
46
|
-
};
|
|
47
|
-
/**
|
|
48
|
-
* Get MIME type from file extension
|
|
49
|
-
*/
|
|
50
|
-
function getMimeType(filePath) {
|
|
51
|
-
const ext = path.extname(filePath).toLowerCase().replace('.', '');
|
|
52
|
-
return MIME_MAP[ext] || 'application/octet-stream';
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* Convert a file to a data URI (base64 encoded)
|
|
56
|
-
*/
|
|
57
|
-
function fileToDataUri(filePath) {
|
|
58
|
-
try {
|
|
59
|
-
const buf = fs.readFileSync(filePath);
|
|
60
|
-
const mime = getMimeType(filePath);
|
|
61
|
-
return `data:${mime};base64,${buf.toString('base64')}`;
|
|
62
|
-
}
|
|
63
|
-
catch {
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* Create the analyze_image tool for vision capabilities via OpenRouter
|
|
69
|
-
*/
|
|
70
|
-
function createAnalyzeImageTool(attachmentDir) {
|
|
71
|
-
const workDir = attachmentDir || os.tmpdir();
|
|
72
|
-
return tool('analyze_image', 'Analyze one or more image files using a vision model. Pass the path to an image file and a question. Returns a detailed text answer about the image content.', {
|
|
73
|
-
image_path: z.string().describe('Path to the image file to analyze (can be relative to working directory, e.g. "attachments/photo.jpg")'),
|
|
74
|
-
question: z.string().describe('Question or instruction about the image. Be specific about what you want to know.'),
|
|
75
|
-
}, async (args) => {
|
|
76
|
-
const apiKey = getOpenrouterApiKey();
|
|
77
|
-
if (!apiKey) {
|
|
78
|
-
return { content: [{ type: 'text', text: 'Error: OPENROUTER_API_KEY not configured.' }], isError: true };
|
|
79
|
-
}
|
|
80
|
-
try {
|
|
81
|
-
// Resolve relative paths against the attachment dir
|
|
82
|
-
const imagePath = path.resolve(workDir, args.image_path);
|
|
83
|
-
if (!fs.existsSync(imagePath)) {
|
|
84
|
-
return { content: [{ type: 'text', text: `Error: Image file not found: ${args.image_path}` }], isError: true };
|
|
85
|
-
}
|
|
86
|
-
const dataUri = fileToDataUri(imagePath);
|
|
87
|
-
if (!dataUri) {
|
|
88
|
-
return { content: [{ type: 'text', text: `Error: Could not read image file: ${args.image_path}` }], isError: true };
|
|
89
|
-
}
|
|
90
|
-
const OPENROUTER_ENDPOINT = 'https://openrouter.ai/api/v1/chat/completions';
|
|
91
|
-
const OPENROUTER_MODEL = 'qwen/qwen3.5-27b';
|
|
92
|
-
const res = await fetch(OPENROUTER_ENDPOINT, {
|
|
93
|
-
method: 'POST',
|
|
94
|
-
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
95
|
-
body: JSON.stringify({
|
|
96
|
-
model: OPENROUTER_MODEL,
|
|
97
|
-
messages: [{ role: 'user', content: [
|
|
98
|
-
{ type: 'text', text: args.question },
|
|
99
|
-
{ type: 'image_url', image_url: { url: dataUri } },
|
|
100
|
-
] }],
|
|
101
|
-
}),
|
|
102
|
-
signal: AbortSignal.timeout(60_000),
|
|
103
|
-
});
|
|
104
|
-
const raw = await res.text();
|
|
105
|
-
if (!res.ok) {
|
|
106
|
-
return { content: [{ type: 'text', text: `Error from vision API (${res.status}): ${raw.slice(0, 500)}` }], isError: true };
|
|
107
|
-
}
|
|
108
|
-
const parsed = JSON.parse(raw);
|
|
109
|
-
return { content: [{ type: 'text', text: parsed.choices?.[0]?.message?.content || raw }] };
|
|
110
|
-
}
|
|
111
|
-
catch (error) {
|
|
112
|
-
return { content: [{ type: 'text', text: `Error analyzing image: ${error.message}` }], isError: true };
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
/**
|
|
117
|
-
* Create the send_file tool for displaying files to the user in chat.
|
|
118
|
-
* Supports images, audio, video, PDFs, code, and other files.
|
|
119
|
-
*/
|
|
120
|
-
function createSendFileTool(attachmentDir) {
|
|
121
|
-
const workDir = attachmentDir || os.tmpdir();
|
|
122
|
-
return tool('send_file', 'Send a file to the user for display in chat. Supports images (shown inline), audio/video (with player), PDFs, code files (syntax highlighted), and other files (download link). Use file_path for local files or data for base64-encoded content.', {
|
|
123
|
-
file_path: z.string().optional().describe('Path to a local file on this device (absolute or relative to project directory)'),
|
|
124
|
-
data: z.string().optional().describe('Base64-encoded file content (without data: prefix)'),
|
|
125
|
-
mime_type: z.string().optional().describe('MIME type of the file (required when using data, auto-detected from file_path)'),
|
|
126
|
-
filename: z.string().optional().describe('Display name for the file (auto-detected from file_path)'),
|
|
127
|
-
}, async (args) => {
|
|
128
|
-
try {
|
|
129
|
-
let dataUri;
|
|
130
|
-
let mimeType;
|
|
131
|
-
let fileName;
|
|
132
|
-
let fileSize;
|
|
133
|
-
if (args.file_path) {
|
|
134
|
-
// Read from local file
|
|
135
|
-
const filePath = path.resolve(workDir, args.file_path);
|
|
136
|
-
if (!fs.existsSync(filePath)) {
|
|
137
|
-
return { content: [{ type: 'text', text: `Error: File not found: ${args.file_path}` }], isError: true };
|
|
138
|
-
}
|
|
139
|
-
const stat = fs.statSync(filePath);
|
|
140
|
-
fileSize = stat.size;
|
|
141
|
-
if (fileSize > MAX_FILE_SIZE) {
|
|
142
|
-
return { content: [{ type: 'text', text: `Error: File too large (${(fileSize / (1024 * 1024)).toFixed(1)} MB). Maximum size is ${MAX_FILE_SIZE / (1024 * 1024)} MB.` }], isError: true };
|
|
143
|
-
}
|
|
144
|
-
const buf = fs.readFileSync(filePath);
|
|
145
|
-
mimeType = args.mime_type || getMimeType(filePath);
|
|
146
|
-
fileName = args.filename || path.basename(filePath);
|
|
147
|
-
dataUri = `data:${mimeType};base64,${buf.toString('base64')}`;
|
|
148
|
-
}
|
|
149
|
-
else if (args.data) {
|
|
150
|
-
// Use provided base64 data
|
|
151
|
-
mimeType = args.mime_type || 'application/octet-stream';
|
|
152
|
-
fileName = args.filename || 'file';
|
|
153
|
-
const rawBase64 = args.data.replace(/^data:[^;]+;base64,/, '');
|
|
154
|
-
fileSize = Math.floor(rawBase64.length * 0.75);
|
|
155
|
-
if (fileSize > MAX_FILE_SIZE) {
|
|
156
|
-
return { content: [{ type: 'text', text: `Error: Data too large (~${(fileSize / (1024 * 1024)).toFixed(1)} MB). Maximum size is ${MAX_FILE_SIZE / (1024 * 1024)} MB.` }], isError: true };
|
|
157
|
-
}
|
|
158
|
-
dataUri = `data:${mimeType};base64,${rawBase64}`;
|
|
159
|
-
}
|
|
160
|
-
else {
|
|
161
|
-
return { content: [{ type: 'text', text: 'Error: Either file_path or data must be provided.' }], isError: true };
|
|
162
|
-
}
|
|
163
|
-
// Return structured result that the frontend will detect
|
|
164
|
-
const result = JSON.stringify({
|
|
165
|
-
_type: 'send_file',
|
|
166
|
-
data: dataUri,
|
|
167
|
-
mime_type: mimeType,
|
|
168
|
-
filename: fileName,
|
|
169
|
-
size: fileSize,
|
|
170
|
-
});
|
|
171
|
-
return { content: [{ type: 'text', text: result }] };
|
|
172
|
-
}
|
|
173
|
-
catch (error) {
|
|
174
|
-
return { content: [{ type: 'text', text: `Error sending file: ${error.message}` }], isError: true };
|
|
175
|
-
}
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
/**
|
|
179
|
-
* Create the browser_query tool for web automation via the backend.
|
|
180
|
-
* The agent can fire multiple queries concurrently — screenshots stream
|
|
181
|
-
* to the frontend independently while the tool blocks until completion.
|
|
182
|
-
*/
|
|
183
|
-
function createBrowserQueryTool(config) {
|
|
184
|
-
return tool('browser_query', 'Launch a headless browser to automate web tasks such as searching, reading pages, filling forms, extracting data, etc. ' +
|
|
185
|
-
'Returns the answer, optionally structured data, and step count. ' +
|
|
186
|
-
'IMPORTANT: This tool is slow (30-120 seconds per query). You CAN and SHOULD call browser_query multiple times concurrently ' +
|
|
187
|
-
'— the browser handles each in a separate session. While waiting for results, continue with other work (file edits, analysis, etc.). ' +
|
|
188
|
-
'Do NOT wait for one browser query to finish before starting another if you need multiple lookups.', {
|
|
189
|
-
query: z.string().describe('Natural language task for the browser agent (e.g. "Go to google.com and search for the price of Bitcoin")'),
|
|
190
|
-
schema: z.string().optional().describe('JSON schema for structured output, as a JSON string (e.g. \'{"type":"object","properties":{"price":{"type":"number"}}}\')'),
|
|
191
|
-
maxSteps: z.number().optional().describe('Max automation steps, default 20. Use lower values for simple tasks.'),
|
|
192
|
-
country: z.string().optional().describe('2-letter country code for proxy and locale (e.g. "US", "GB", "DE"). Uses direct connection if omitted.'),
|
|
193
|
-
mobile: z.boolean().optional().describe('If true, use mobile viewport (390x844 — iPhone 14 dimensions) instead of desktop.'),
|
|
194
|
-
}, async (args) => {
|
|
195
|
-
const apiUrl = getApiUrl();
|
|
196
|
-
// Read device ID for CLI auth
|
|
197
|
-
let deviceId = '';
|
|
198
|
-
try {
|
|
199
|
-
const deviceIdPath = path.join(os.homedir(), '.talk-to-code', 'device-id.json');
|
|
200
|
-
const data = fs.readFileSync(deviceIdPath, 'utf-8');
|
|
201
|
-
deviceId = JSON.parse(data).deviceId || '';
|
|
202
|
-
}
|
|
203
|
-
catch {
|
|
204
|
-
// No device ID file — will still work if backend has relaxed auth
|
|
205
|
-
}
|
|
206
|
-
try {
|
|
207
|
-
const body = {
|
|
208
|
-
query: args.query,
|
|
209
|
-
maxSteps: args.maxSteps || 20,
|
|
210
|
-
};
|
|
211
|
-
if (args.schema) {
|
|
212
|
-
try {
|
|
213
|
-
body.schema = JSON.parse(args.schema);
|
|
214
|
-
}
|
|
215
|
-
catch {
|
|
216
|
-
body.schema = args.schema;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
if (args.country)
|
|
220
|
-
body.country = args.country;
|
|
221
|
-
if (args.mobile)
|
|
222
|
-
body.mobile = args.mobile;
|
|
223
|
-
if (config.sessionId)
|
|
224
|
-
body.sessionId = config.sessionId;
|
|
225
|
-
if (config.promptId)
|
|
226
|
-
body.promptId = config.promptId;
|
|
227
|
-
const res = await fetch(`${apiUrl}/api/browser/query`, {
|
|
228
|
-
method: 'POST',
|
|
229
|
-
headers: {
|
|
230
|
-
'Content-Type': 'application/json',
|
|
231
|
-
...(deviceId ? { 'X-Device-ID': deviceId } : {}),
|
|
232
|
-
},
|
|
233
|
-
body: JSON.stringify(body),
|
|
234
|
-
signal: AbortSignal.timeout(10 * 60 * 1000), // 10 min timeout
|
|
235
|
-
});
|
|
236
|
-
const raw = await res.text();
|
|
237
|
-
if (!res.ok) {
|
|
238
|
-
return {
|
|
239
|
-
content: [{ type: 'text', text: `Error from browser agent (${res.status}): ${raw.slice(0, 500)}` }],
|
|
240
|
-
isError: true,
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
const result = JSON.parse(raw);
|
|
244
|
-
// Format a nice summary for the agent
|
|
245
|
-
const summary = [
|
|
246
|
-
`Browser query completed in ${result.steps} steps.`,
|
|
247
|
-
result.answer ? `\n\n**Answer:** ${result.answer}` : '',
|
|
248
|
-
result.data ? `\n\n**Structured Data:**\n\`\`\`json\n${JSON.stringify(result.data, null, 2)}\n\`\`\`` : '',
|
|
249
|
-
result.logs?.length ? `\n\n**Log:**\n${result.logs.slice(-5).join('\n')}` : '',
|
|
250
|
-
].join('');
|
|
251
|
-
return { content: [{ type: 'text', text: summary }] };
|
|
252
|
-
}
|
|
253
|
-
catch (error) {
|
|
254
|
-
if (error.name === 'TimeoutError') {
|
|
255
|
-
return {
|
|
256
|
-
content: [{ type: 'text', text: 'Browser query timed out after 10 minutes. Try reducing maxSteps or simplifying the query.' }],
|
|
257
|
-
isError: true,
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
return {
|
|
261
|
-
content: [{ type: 'text', text: `Error running browser query: ${error.message}` }],
|
|
262
|
-
isError: true,
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
/**
|
|
268
|
-
* Create the MCP server with built-in tools
|
|
269
|
-
*/
|
|
270
|
-
export function createModuleMcpServer(config) {
|
|
271
|
-
const tools = [];
|
|
272
|
-
// Always add analyze_image tool (uses OpenRouter key from ai-config via backend)
|
|
273
|
-
tools.push(createAnalyzeImageTool(config.attachmentDir));
|
|
274
|
-
// Add send_file tool for displaying files to the user in chat
|
|
275
|
-
tools.push(createSendFileTool(config.attachmentDir));
|
|
276
|
-
// Add browser_query tool for web automation
|
|
277
|
-
tools.push(createBrowserQueryTool(config));
|
|
278
|
-
const server = createSdkMcpServer({
|
|
279
|
-
name: 'claude-voice-modules',
|
|
280
|
-
version: '1.0.0',
|
|
281
|
-
tools
|
|
282
|
-
});
|
|
283
|
-
return server;
|
|
284
|
-
}
|
package/dist/openaiAdapter.js
DELETED
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OpenAI Adapter - Spawns an anthropic-proxy that translates Anthropic Messages API
|
|
3
|
-
* to OpenAI Chat Completions API. This allows Claude Agent SDK to talk to any
|
|
4
|
-
* OpenAI-compatible endpoint (Ollama, vLLM, local LLMs, etc.)
|
|
5
|
-
*
|
|
6
|
-
* Usage:
|
|
7
|
-
* import { startOpenAIAdapter, isLocalModel } from './openaiAdapter.js'
|
|
8
|
-
*
|
|
9
|
-
* if (isLocalModel(sessionModel)) {
|
|
10
|
-
* const proxyUrl = await startOpenAIAdapter({
|
|
11
|
-
* targetBaseUrl: 'http://192.168.1.101:3000',
|
|
12
|
-
* model: 'qwen3.5-27b',
|
|
13
|
-
* })
|
|
14
|
-
* // point ANTHROPIC_BASE_URL to proxyUrl
|
|
15
|
-
* }
|
|
16
|
-
*/
|
|
17
|
-
import { spawn } from 'child_process';
|
|
18
|
-
import { createRequire } from 'module';
|
|
19
|
-
const VALID_LOCAL_PREFIXES = ['ollama:', 'openai:', 'local:'];
|
|
20
|
-
/** Check if a model ID refers to a local/OpenAI model that needs the adapter */
|
|
21
|
-
export function isLocalModel(model) {
|
|
22
|
-
return VALID_LOCAL_PREFIXES.some(p => model.startsWith(p));
|
|
23
|
-
}
|
|
24
|
-
/** Extract the actual model name by stripping the prefix (e.g. "ollama:qwen3:4b" -> "qwen3:4b") */
|
|
25
|
-
export function unwrapModelName(model) {
|
|
26
|
-
for (const prefix of VALID_LOCAL_PREFIXES) {
|
|
27
|
-
if (model.startsWith(prefix)) {
|
|
28
|
-
return model.slice(prefix.length);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
return model;
|
|
32
|
-
}
|
|
33
|
-
// Track running proxies by target+model to reuse them
|
|
34
|
-
const runningProxies = new Map();
|
|
35
|
-
function getProxyKey(config) {
|
|
36
|
-
return `${config.targetBaseUrl}::${config.model}`;
|
|
37
|
-
}
|
|
38
|
-
/** Find a free port in the given range */
|
|
39
|
-
async function findFreePort(start, end) {
|
|
40
|
-
const net = await import('net');
|
|
41
|
-
return new Promise((resolve, reject) => {
|
|
42
|
-
function tryPort(port) {
|
|
43
|
-
if (port > end) {
|
|
44
|
-
reject(new Error(`No free port found in range ${start}-${end}`));
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
const server = net.createServer();
|
|
48
|
-
server.listen(port, '127.0.0.1', () => {
|
|
49
|
-
const addr = server.address();
|
|
50
|
-
server.close(() => resolve(typeof addr === 'object' && addr ? addr.port : port));
|
|
51
|
-
});
|
|
52
|
-
server.on('error', () => tryPort(port + 1));
|
|
53
|
-
}
|
|
54
|
-
tryPort(start);
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
/**
|
|
58
|
-
* Start an anthropic-proxy instance for the given config.
|
|
59
|
-
* Returns the local proxy URL (e.g. http://127.0.0.1:8321) that speaks Anthropic Messages API.
|
|
60
|
-
*
|
|
61
|
-
* Proxies are reused - calling this twice with the same target+model returns the same URL.
|
|
62
|
-
*/
|
|
63
|
-
export async function startOpenAIAdapter(config) {
|
|
64
|
-
const key = getProxyKey(config);
|
|
65
|
-
// Reuse existing proxy if running
|
|
66
|
-
const existing = runningProxies.get(key);
|
|
67
|
-
if (existing && !existing.process.killed) {
|
|
68
|
-
return existing.url;
|
|
69
|
-
}
|
|
70
|
-
const port = config.port || await findFreePort(8321, 8340);
|
|
71
|
-
// Resolve anthropic-proxy entry point
|
|
72
|
-
const req = typeof globalThis.require === 'function'
|
|
73
|
-
? globalThis.require
|
|
74
|
-
: createRequire(import.meta.url);
|
|
75
|
-
let proxyPath;
|
|
76
|
-
try {
|
|
77
|
-
const pkgPath = req.resolve('anthropic-proxy/package.json');
|
|
78
|
-
proxyPath = pkgPath.replace(/package\.json$/, 'index.js');
|
|
79
|
-
}
|
|
80
|
-
catch {
|
|
81
|
-
throw new Error('anthropic-proxy package not found. Run: npm install anthropic-proxy');
|
|
82
|
-
}
|
|
83
|
-
const env = {
|
|
84
|
-
...process.env,
|
|
85
|
-
ANTHROPIC_PROXY_BASE_URL: config.targetBaseUrl,
|
|
86
|
-
PORT: String(port),
|
|
87
|
-
REASONING_MODEL: config.model,
|
|
88
|
-
COMPLETION_MODEL: config.model,
|
|
89
|
-
// Suppress fastify logger noise
|
|
90
|
-
FASTIFY_LOG_LEVEL: 'error',
|
|
91
|
-
};
|
|
92
|
-
if (config.apiKey) {
|
|
93
|
-
env.OPENAI_API_KEY = config.apiKey;
|
|
94
|
-
}
|
|
95
|
-
const child = spawn('node', [proxyPath], {
|
|
96
|
-
env,
|
|
97
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
98
|
-
windowsHide: true,
|
|
99
|
-
});
|
|
100
|
-
// Suppress stdout/stderr but log errors
|
|
101
|
-
let stderrBuf = '';
|
|
102
|
-
child.stderr?.on('data', (data) => {
|
|
103
|
-
stderrBuf += data.toString();
|
|
104
|
-
// Only log actual errors, not fastify startup messages
|
|
105
|
-
if (stderrBuf.toLowerCase().includes('error') && !stderrBuf.includes('fastify')) {
|
|
106
|
-
console.error(`[openaiAdapter] Proxy stderr: ${stderrBuf}`);
|
|
107
|
-
}
|
|
108
|
-
stderrBuf = '';
|
|
109
|
-
});
|
|
110
|
-
child.on('exit', (code) => {
|
|
111
|
-
runningProxies.delete(key);
|
|
112
|
-
if (code && code !== 0) {
|
|
113
|
-
console.error(`[openaiAdapter] Proxy exited with code ${code}`);
|
|
114
|
-
}
|
|
115
|
-
});
|
|
116
|
-
const url = `http://127.0.0.1:${port}`;
|
|
117
|
-
// Wait for proxy to be ready (poll /v1/messages with a simple request)
|
|
118
|
-
const maxAttempts = 20;
|
|
119
|
-
for (let i = 0; i < maxAttempts; i++) {
|
|
120
|
-
await new Promise(r => setTimeout(r, 100));
|
|
121
|
-
try {
|
|
122
|
-
const resp = await fetch(`${url}/v1/messages`, {
|
|
123
|
-
method: 'POST',
|
|
124
|
-
headers: { 'Content-Type': 'application/json' },
|
|
125
|
-
body: JSON.stringify({
|
|
126
|
-
model: config.model,
|
|
127
|
-
max_tokens: 1,
|
|
128
|
-
messages: [{ role: 'user', content: 'ping' }],
|
|
129
|
-
}),
|
|
130
|
-
});
|
|
131
|
-
if (resp.ok || resp.status === 400 || resp.status === 422) {
|
|
132
|
-
// 400/422 means the proxy is up but the request was invalid - that's fine for a health check
|
|
133
|
-
runningProxies.set(key, { process: child, port, url });
|
|
134
|
-
console.log(`[openaiAdapter] Proxy ready at ${url} -> ${config.targetBaseUrl} (model: ${config.model})`);
|
|
135
|
-
return url;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
catch {
|
|
139
|
-
// Not ready yet, retry
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
// If health check failed but process is still running, assume it's ready
|
|
143
|
-
if (!child.killed) {
|
|
144
|
-
runningProxies.set(key, { process: child, port, url });
|
|
145
|
-
console.log(`[openaiAdapter] Proxy started at ${url} (health check timed out, assuming ready)`);
|
|
146
|
-
return url;
|
|
147
|
-
}
|
|
148
|
-
throw new Error(`Failed to start anthropic-proxy for ${config.targetBaseUrl}`);
|
|
149
|
-
}
|
|
150
|
-
/**
|
|
151
|
-
* Get the adapter configuration for a local model.
|
|
152
|
-
* Reads from ~/.talk-to-code/openai-adapters.json or falls back to defaults.
|
|
153
|
-
*/
|
|
154
|
-
export function getAdapterConfig(model) {
|
|
155
|
-
// Strip prefix to get the raw model name
|
|
156
|
-
const rawModel = unwrapModelName(model);
|
|
157
|
-
// Determine provider from prefix
|
|
158
|
-
if (model.startsWith('ollama:')) {
|
|
159
|
-
return { targetBaseUrl: 'http://127.0.0.1:11434' };
|
|
160
|
-
}
|
|
161
|
-
// For 'openai:' and 'local:' prefix, read from config file
|
|
162
|
-
try {
|
|
163
|
-
const req = typeof globalThis.require === 'function'
|
|
164
|
-
? globalThis.require
|
|
165
|
-
: createRequire(import.meta.url);
|
|
166
|
-
const fs = req('fs');
|
|
167
|
-
const path = req('path');
|
|
168
|
-
const os = req('os');
|
|
169
|
-
const configPath = path.join(os.homedir(), '.talk-to-code', 'openai-adapters.json');
|
|
170
|
-
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
171
|
-
// Config format: { "default": { "baseUrl": "...", "apiKey": "..." }, ... }
|
|
172
|
-
const entry = config.default || config[rawModel];
|
|
173
|
-
if (entry?.baseUrl) {
|
|
174
|
-
return { targetBaseUrl: entry.baseUrl, apiKey: entry.apiKey };
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
catch (e) {
|
|
178
|
-
console.error('[openaiAdapter] Failed to read adapter config:', e);
|
|
179
|
-
}
|
|
180
|
-
return null;
|
|
181
|
-
}
|