termux-dev 1.2.2 → 1.4.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.
@@ -0,0 +1,218 @@
1
+ import { spawn } from 'child_process';
2
+ export class MCPClient {
3
+ name;
4
+ config;
5
+ process = null;
6
+ nextRequestId = 1;
7
+ pendingRequests = new Map();
8
+ buffer = '';
9
+ tools = [];
10
+ isConnected = false;
11
+ constructor(name, config) {
12
+ this.name = name;
13
+ this.config = config;
14
+ }
15
+ async start() {
16
+ if (this.config.disabled) {
17
+ throw new Error(`MCP server "${this.name}" is disabled in configuration.`);
18
+ }
19
+ return new Promise(async (resolve, reject) => {
20
+ let isSettled = false;
21
+ const initialTimer = setTimeout(() => {
22
+ if (!isSettled) {
23
+ isSettled = true;
24
+ this.close();
25
+ reject(new Error(`MCP server "${this.name}" initialization timed out after 15s.`));
26
+ }
27
+ }, 15000);
28
+ try {
29
+ const env = {
30
+ ...process.env,
31
+ ...(this.config.env || {})
32
+ };
33
+ const isWindows = process.platform === 'win32';
34
+ const useShell = isWindows && (this.config.command.endsWith('.cmd') ||
35
+ this.config.command.endsWith('.bat') ||
36
+ this.config.command === 'npx' ||
37
+ this.config.command === 'npm');
38
+ this.process = spawn(this.config.command, this.config.args || [], {
39
+ env,
40
+ stdio: ['pipe', 'pipe', 'pipe'],
41
+ shell: useShell
42
+ });
43
+ this.process.stdout?.on('data', (data) => {
44
+ this.handleStdout(data.toString());
45
+ });
46
+ this.process.stderr?.on('data', (_data) => {
47
+ // Stderr from MCP servers is used for logging/debugging
48
+ });
49
+ this.process.on('error', (err) => {
50
+ if (!isSettled) {
51
+ isSettled = true;
52
+ clearTimeout(initialTimer);
53
+ reject(new Error(`Failed to start MCP server "${this.name}": ${err.message}`));
54
+ }
55
+ this.cleanup();
56
+ });
57
+ this.process.on('close', (_code) => {
58
+ this.cleanup();
59
+ });
60
+ // 1. Initialize Handshake
61
+ const initResult = await this.sendRequest('initialize', {
62
+ protocolVersion: '2024-11-05',
63
+ capabilities: {},
64
+ clientInfo: {
65
+ name: 'devx',
66
+ version: '1.4.0'
67
+ }
68
+ });
69
+ if (!initResult) {
70
+ throw new Error(`Invalid initialize response from MCP server "${this.name}".`);
71
+ }
72
+ // 2. Send initialized notification
73
+ this.sendNotification('notifications/initialized', {});
74
+ // 3. Fetch Tools List
75
+ const toolsResult = await this.sendRequest('tools/list', {});
76
+ this.tools = toolsResult?.tools || [];
77
+ this.isConnected = true;
78
+ if (!isSettled) {
79
+ isSettled = true;
80
+ clearTimeout(initialTimer);
81
+ resolve(this.tools);
82
+ }
83
+ }
84
+ catch (err) {
85
+ if (!isSettled) {
86
+ isSettled = true;
87
+ clearTimeout(initialTimer);
88
+ this.close();
89
+ reject(err);
90
+ }
91
+ }
92
+ });
93
+ }
94
+ getTools() {
95
+ return this.tools;
96
+ }
97
+ hasConnected() {
98
+ return this.isConnected;
99
+ }
100
+ async callTool(toolName, args) {
101
+ if (!this.process || !this.isConnected) {
102
+ throw new Error(`MCP server "${this.name}" is not connected.`);
103
+ }
104
+ const res = await this.sendRequest('tools/call', {
105
+ name: toolName,
106
+ arguments: args || {}
107
+ }, 60000); // 60s timeout for tool calls
108
+ if (!res || !res.content) {
109
+ return JSON.stringify(res || {});
110
+ }
111
+ const outputParts = [];
112
+ for (const c of res.content) {
113
+ if (c.type === 'text' && c.text) {
114
+ outputParts.push(c.text);
115
+ }
116
+ else if (c.type === 'image' && c.data) {
117
+ outputParts.push(`[Image content (${c.mimeType || 'image/png'})]`);
118
+ }
119
+ else if (c.type === 'resource') {
120
+ outputParts.push(`[Resource: ${JSON.stringify(c)}]`);
121
+ }
122
+ }
123
+ const finalResult = outputParts.join('\n\n') || JSON.stringify(res);
124
+ if (res.isError) {
125
+ throw new Error(finalResult);
126
+ }
127
+ return finalResult;
128
+ }
129
+ handleStdout(chunk) {
130
+ this.buffer += chunk;
131
+ const lines = this.buffer.split('\n');
132
+ this.buffer = lines.pop() || '';
133
+ for (const line of lines) {
134
+ const trimmed = line.trim();
135
+ if (!trimmed)
136
+ continue;
137
+ try {
138
+ const msg = JSON.parse(trimmed);
139
+ if ('id' in msg && msg.id !== undefined) {
140
+ const pending = this.pendingRequests.get(msg.id);
141
+ if (pending) {
142
+ clearTimeout(pending.timer);
143
+ this.pendingRequests.delete(msg.id);
144
+ if (msg.error) {
145
+ pending.reject(new Error(`MCP error ${msg.error.code}: ${msg.error.message}`));
146
+ }
147
+ else {
148
+ pending.resolve(msg.result);
149
+ }
150
+ }
151
+ }
152
+ }
153
+ catch {
154
+ // Ignore non-JSON line outputs (e.g. startup banner)
155
+ }
156
+ }
157
+ }
158
+ sendRequest(method, params, timeoutMs = 15000) {
159
+ return new Promise((resolve, reject) => {
160
+ if (!this.process || !this.process.stdin) {
161
+ return reject(new Error(`MCP server "${this.name}" process is not running.`));
162
+ }
163
+ const id = this.nextRequestId++;
164
+ const timer = setTimeout(() => {
165
+ if (this.pendingRequests.has(id)) {
166
+ this.pendingRequests.delete(id);
167
+ reject(new Error(`MCP request "${method}" to server "${this.name}" timed out (${timeoutMs / 1000}s).`));
168
+ }
169
+ }, timeoutMs);
170
+ this.pendingRequests.set(id, { resolve, reject, timer });
171
+ const request = {
172
+ jsonrpc: '2.0',
173
+ id,
174
+ method,
175
+ params
176
+ };
177
+ try {
178
+ this.process.stdin.write(JSON.stringify(request) + '\n');
179
+ }
180
+ catch (err) {
181
+ clearTimeout(timer);
182
+ this.pendingRequests.delete(id);
183
+ reject(new Error(`Failed to write to MCP server "${this.name}": ${err.message}`));
184
+ }
185
+ });
186
+ }
187
+ sendNotification(method, params) {
188
+ if (!this.process || !this.process.stdin)
189
+ return;
190
+ const notif = {
191
+ jsonrpc: '2.0',
192
+ method,
193
+ params
194
+ };
195
+ try {
196
+ this.process.stdin.write(JSON.stringify(notif) + '\n');
197
+ }
198
+ catch { }
199
+ }
200
+ close() {
201
+ this.cleanup();
202
+ if (this.process) {
203
+ try {
204
+ this.process.kill();
205
+ }
206
+ catch { }
207
+ this.process = null;
208
+ }
209
+ }
210
+ cleanup() {
211
+ this.isConnected = false;
212
+ for (const [id, req] of this.pendingRequests.entries()) {
213
+ clearTimeout(req.timer);
214
+ req.reject(new Error(`MCP server "${this.name}" disconnected.`));
215
+ }
216
+ this.pendingRequests.clear();
217
+ }
218
+ }
@@ -0,0 +1,196 @@
1
+ import fs from 'fs/promises';
2
+ import fsSync from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import pc from 'picocolors';
6
+ import { MCPClient } from './client.js';
7
+ import { getCurrentTheme } from '../cli/theme.js';
8
+ function stripAnsi(str) {
9
+ return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
10
+ }
11
+ export class MCPManager {
12
+ static instance;
13
+ clients = new Map();
14
+ tools = [];
15
+ serverStatuses = new Map();
16
+ isInitialized = false;
17
+ constructor() { }
18
+ static getInstance() {
19
+ if (!MCPManager.instance) {
20
+ MCPManager.instance = new MCPManager();
21
+ }
22
+ return MCPManager.instance;
23
+ }
24
+ async init(explicitConfigs) {
25
+ if (this.isInitialized) {
26
+ return this.tools;
27
+ }
28
+ const configs = explicitConfigs || await this.loadConfigs();
29
+ const serverEntries = Object.entries(configs);
30
+ if (serverEntries.length === 0) {
31
+ this.isInitialized = true;
32
+ return [];
33
+ }
34
+ const initPromises = serverEntries.map(async ([name, cfg]) => {
35
+ if (cfg.disabled) {
36
+ this.serverStatuses.set(name, {
37
+ name,
38
+ command: `${cfg.command} ${(cfg.args || []).join(' ')}`.trim(),
39
+ status: 'disabled',
40
+ toolsCount: 0,
41
+ tools: []
42
+ });
43
+ return;
44
+ }
45
+ this.serverStatuses.set(name, {
46
+ name,
47
+ command: `${cfg.command} ${(cfg.args || []).join(' ')}`.trim(),
48
+ status: 'connecting',
49
+ toolsCount: 0,
50
+ tools: []
51
+ });
52
+ const client = new MCPClient(name, cfg);
53
+ this.clients.set(name, client);
54
+ try {
55
+ const mcpTools = await client.start();
56
+ this.serverStatuses.set(name, {
57
+ name,
58
+ command: `${cfg.command} ${(cfg.args || []).join(' ')}`.trim(),
59
+ status: 'connected',
60
+ toolsCount: mcpTools.length,
61
+ tools: mcpTools.map(t => t.name)
62
+ });
63
+ // Convert MCP tools into devx Tool format
64
+ for (const mt of mcpTools) {
65
+ const namespacedName = `mcp__${name}__${mt.name}`;
66
+ const devxTool = {
67
+ name: namespacedName,
68
+ definition: {
69
+ name: namespacedName,
70
+ description: `[MCP: ${name}] ${mt.description || mt.name}`,
71
+ parameters: {
72
+ type: 'object',
73
+ properties: mt.inputSchema?.properties || {},
74
+ required: mt.inputSchema?.required
75
+ }
76
+ },
77
+ validateArgs(_args) { },
78
+ async execute(args) {
79
+ return await client.callTool(mt.name, args);
80
+ }
81
+ };
82
+ this.tools.push(devxTool);
83
+ }
84
+ }
85
+ catch (err) {
86
+ this.serverStatuses.set(name, {
87
+ name,
88
+ command: `${cfg.command} ${(cfg.args || []).join(' ')}`.trim(),
89
+ status: 'failed',
90
+ toolsCount: 0,
91
+ tools: [],
92
+ error: err.message
93
+ });
94
+ }
95
+ });
96
+ await Promise.all(initPromises);
97
+ this.isInitialized = true;
98
+ return this.tools;
99
+ }
100
+ getTools() {
101
+ return this.tools;
102
+ }
103
+ getStatuses() {
104
+ return Array.from(this.serverStatuses.values());
105
+ }
106
+ async reload() {
107
+ this.stopAll();
108
+ this.isInitialized = false;
109
+ this.clients.clear();
110
+ this.tools = [];
111
+ this.serverStatuses.clear();
112
+ return await this.init();
113
+ }
114
+ stopAll() {
115
+ for (const client of this.clients.values()) {
116
+ try {
117
+ client.close();
118
+ }
119
+ catch { }
120
+ }
121
+ this.clients.clear();
122
+ }
123
+ renderStatusCard() {
124
+ const theme = getCurrentTheme();
125
+ const statuses = this.getStatuses();
126
+ const cols = Math.min(process.stdout.columns || 80, 80);
127
+ const boxWidth = Math.max(48, cols - 4);
128
+ const innerWidth = boxWidth - 6;
129
+ const padRow = (left, right) => {
130
+ const plainLen = stripAnsi(left).length + stripAnsi(right).length;
131
+ const spaces = Math.max(1, innerWidth - plainLen);
132
+ return `│ ${left}${' '.repeat(spaces)}${right} │`;
133
+ };
134
+ const header = theme.colorFn('┌─ ') + pc.bold('🔌 Model Context Protocol (MCP) Servers') + ' ' + theme.colorFn('─'.repeat(Math.max(2, boxWidth - 43)) + '┐');
135
+ const divider = theme.colorFn('├' + '─'.repeat(boxWidth - 2) + '┤');
136
+ const footer = theme.colorFn('└' + '─'.repeat(boxWidth - 2) + '┘');
137
+ const lines = [header];
138
+ if (statuses.length === 0) {
139
+ lines.push(padRow(pc.dim('No MCP servers configured.'), ''));
140
+ lines.push(padRow(pc.dim('Configure in .devx/mcp.json or ~/.devxrc.json'), ''));
141
+ lines.push(footer);
142
+ return lines.join('\n') + '\n';
143
+ }
144
+ for (let i = 0; i < statuses.length; i++) {
145
+ const s = statuses[i];
146
+ if (i > 0)
147
+ lines.push(divider);
148
+ let statusBadge = pc.green('🟢 Connected');
149
+ if (s.status === 'connecting')
150
+ statusBadge = pc.yellow('🟡 Connecting');
151
+ if (s.status === 'failed')
152
+ statusBadge = pc.red('🔴 Failed');
153
+ if (s.status === 'disabled')
154
+ statusBadge = pc.dim('⚪ Disabled');
155
+ lines.push(padRow(pc.bold(pc.white(`Server: ${s.name}`)), statusBadge));
156
+ lines.push(padRow(pc.dim(` Command: ${s.command.slice(0, 36)}${s.command.length > 36 ? '...' : ''}`), pc.cyan(`${s.toolsCount} tools`)));
157
+ if (s.tools.length > 0) {
158
+ const toolsListStr = s.tools.slice(0, 3).map(t => pc.dim(`• ${t}`)).join(' ');
159
+ const extra = s.tools.length > 3 ? pc.dim(` +${s.tools.length - 3} more`) : '';
160
+ lines.push(padRow(` ${toolsListStr}${extra}`, ''));
161
+ }
162
+ if (s.error) {
163
+ lines.push(padRow(pc.red(` Error: ${s.error.slice(0, innerWidth - 10)}`), ''));
164
+ }
165
+ }
166
+ lines.push(footer);
167
+ return lines.join('\n') + '\n';
168
+ }
169
+ async loadConfigs() {
170
+ const result = {};
171
+ const configPaths = [
172
+ path.join(os.homedir(), '.devxrc.json'),
173
+ path.join(process.cwd(), '.devx', 'mcp.json'),
174
+ path.join(process.cwd(), '.claude', 'mcp.json'),
175
+ path.join(process.cwd(), '.devx.json'),
176
+ path.join(process.cwd(), '.mcp.json')
177
+ ];
178
+ for (const pth of configPaths) {
179
+ try {
180
+ if (fsSync.existsSync(pth)) {
181
+ const raw = await fs.readFile(pth, 'utf8');
182
+ const parsed = JSON.parse(raw);
183
+ if (parsed && typeof parsed.mcpServers === 'object') {
184
+ for (const [name, cfg] of Object.entries(parsed.mcpServers)) {
185
+ if (cfg && typeof cfg === 'object' && cfg.command) {
186
+ result[name] = cfg;
187
+ }
188
+ }
189
+ }
190
+ }
191
+ }
192
+ catch { }
193
+ }
194
+ return result;
195
+ }
196
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -23,25 +23,54 @@ function isPathOutsideCwd(targetPath) {
23
23
  function isDangerousBashCommand(commandStr) {
24
24
  if (!commandStr)
25
25
  return false;
26
- const lower = commandStr.toLowerCase().trim();
27
- return (lower.includes('rm -rf /') ||
28
- lower.includes('rm -rf ~') ||
29
- lower.includes('rm -rf *') ||
30
- lower.includes('mkfs') ||
31
- lower.includes('dd if=') ||
32
- lower.includes(':(){ :|:& };:') ||
33
- lower.includes('chmod -r 777 /') ||
34
- lower.includes('> /dev/sda') ||
35
- lower.includes('format c:'));
26
+ // Normalize whitespace: collapse multiple spaces/tabs into single space
27
+ const norm = commandStr.toLowerCase().replace(/\s+/g, ' ').trim();
28
+ // Pattern matching rm with combined flags (-rf, -fr, -r -f, etc.) targeting root, home, termux prefix, or glob
29
+ const isDangerousRm = /rm\s+-(?:[a-z]*r[a-z]*f|[a-z]*f[a-z]*r)\s+(?:\/|\/\*|~|~\/|\*|\$home|\$prefix|\/data\/data\/com\.termux)/i.test(norm) ||
30
+ /rm\s+-[a-z]*r[a-z]*\s+-[a-z]*f[a-z]*\s+(?:\/|\/\*|~|~\/|\*|\$home|\$prefix|\/data\/data\/com\.termux)/i.test(norm) ||
31
+ /rm\s+-[a-z]*f[a-z]*\s+-[a-z]*r[a-z]*\s+(?:\/|\/\*|~|~\/|\*|\$home|\$prefix|\/data\/data\/com\.termux)/i.test(norm);
32
+ return (isDangerousRm ||
33
+ norm.includes('rm -rf /') ||
34
+ norm.includes('rm -rf ~') ||
35
+ norm.includes('rm -rf *') ||
36
+ norm.includes('rm -rf $prefix') ||
37
+ norm.includes('rm -rf $home') ||
38
+ norm.includes('rm -rf /data/data/com.termux') ||
39
+ norm.includes('mkfs') ||
40
+ norm.includes('dd if=') ||
41
+ norm.includes(':(){ :|:& };:') ||
42
+ norm.includes('chmod -r 777 /') ||
43
+ norm.includes('> /dev/sda') ||
44
+ norm.includes('format c:'));
45
+ }
46
+ /**
47
+ * Checks if a command contains chaining, redirection, or subshell operators.
48
+ * Allowlist matches MUST be clean, single commands without hidden side-effects.
49
+ */
50
+ function hasComplexChainingOrRedirection(commandStr) {
51
+ if (!commandStr)
52
+ return false;
53
+ return (commandStr.includes('&&') ||
54
+ commandStr.includes('||') ||
55
+ commandStr.includes(';') ||
56
+ commandStr.includes('|') ||
57
+ commandStr.includes('>') ||
58
+ commandStr.includes('<') ||
59
+ commandStr.includes('`') ||
60
+ commandStr.includes('$(') ||
61
+ commandStr.includes('\n') ||
62
+ commandStr.includes('\r'));
36
63
  }
37
64
  export class CLIConsoleGuard {
38
65
  autoApprove;
39
- constructor(autoApprove = false) {
66
+ bashAllowlist;
67
+ constructor(autoApprove = false, bashAllowlist = []) {
40
68
  this.autoApprove = autoApprove;
69
+ this.bashAllowlist = bashAllowlist;
41
70
  }
42
71
  check(toolName, args) {
43
72
  const t = (toolName || '').toLowerCase();
44
- const cmd = args?.command || args?.cmd || '';
73
+ const cmd = (args?.command || args?.cmd || '').trim();
45
74
  // Critical safety net: even in YOLO mode, warn and confirm destructive system commands
46
75
  if (this.autoApprove) {
47
76
  if (t === 'bash' && isDangerousBashCommand(cmd)) {
@@ -49,9 +78,19 @@ export class CLIConsoleGuard {
49
78
  }
50
79
  return false;
51
80
  }
52
- // 1. Shell commands always require confirmation in safe mode
53
- if (t === 'bash' || t === 'exec' || t === 'run_command') {
54
- return true;
81
+ // Check Bash Allowlist: only allow if command is NOT dangerous AND does not use chaining/redirection
82
+ if ((t === 'bash' || t === 'exec' || t === 'run_command') && cmd) {
83
+ if (!isDangerousBashCommand(cmd) && !hasComplexChainingOrRedirection(cmd)) {
84
+ const isAllowed = this.bashAllowlist.some(pattern => {
85
+ const p = pattern.trim().toLowerCase();
86
+ const lowerCmd = cmd.toLowerCase();
87
+ return lowerCmd === p || lowerCmd.startsWith(p + ' ');
88
+ });
89
+ if (isAllowed) {
90
+ return false; // Automatically allowed!
91
+ }
92
+ }
93
+ return true; // Requires user confirmation
55
94
  }
56
95
  // 2. Package installations require confirmation
57
96
  if (t === 'install_package' || t === 'packages') {
@@ -68,15 +107,30 @@ export class CLIConsoleGuard {
68
107
  return true;
69
108
  }
70
109
  }
110
+ // 5. MCP Tools: ask if autoApprove is false
111
+ if (t.startsWith('mcp__')) {
112
+ return true;
113
+ }
71
114
  return false;
72
115
  }
73
116
  async askUser(toolName, args) {
74
117
  const t = (toolName || '').toLowerCase();
75
118
  const cmd = args?.command || args?.cmd || '';
76
119
  const isDangerous = t === 'bash' && isDangerousBashCommand(cmd);
120
+ // In headless / non-interactive environment without TTY, deny confirmation-requiring actions immediately
121
+ if (!process.stdin.isTTY) {
122
+ console.error(pc.red(`\n🛡️ [PERMISSION DENIED] Action '${toolName}' requires user confirmation in safe mode, but no interactive terminal is available. Pass --yolo (-y) to auto-approve actions in headless mode.`));
123
+ return false;
124
+ }
77
125
  if (isDangerous) {
78
126
  p.log.error(pc.bold(pc.red('⚠️ [SECURITY WARNING] Agent requested a potentially dangerous system command!')));
79
127
  }
128
+ else if (t.startsWith('mcp__')) {
129
+ const parts = toolName.split('__');
130
+ const serverName = parts[1] || 'mcp';
131
+ const mcpToolName = parts.slice(2).join('__');
132
+ p.log.warn(pc.bold(pc.yellow(`🛡️ [PERMISSION GUARD] Agent wants to call MCP Tool: ${pc.cyan(serverName)} / ${pc.green(mcpToolName)}`)));
133
+ }
80
134
  else {
81
135
  p.log.warn(pc.bold(pc.yellow(`🛡️ [PERMISSION GUARD] Agent wants to execute: ${pc.cyan(toolName)}`)));
82
136
  }
@@ -72,5 +72,14 @@ export async function buildSystemPrompt(planMode) {
72
72
  }
73
73
  catch (e) {
74
74
  }
75
+ // Load Compact Repo Map (AST structure & exported symbols)
76
+ try {
77
+ const { RepoMapGenerator } = await import('../core/repomap.js');
78
+ const repoMap = await RepoMapGenerator.generate(process.cwd(), 50);
79
+ if (repoMap.trim()) {
80
+ prompt += `\n--- Codebase Map & Exported Symbols ---\n${repoMap}\n----------------------------------------\n`;
81
+ }
82
+ }
83
+ catch { }
75
84
  return prompt;
76
85
  }
@@ -1,4 +1,5 @@
1
1
  import { calculateCost } from '../core/pricing.js';
2
+ import { UsageTracker } from '../core/usage.js';
2
3
  export class OpenAIProvider {
3
4
  baseUrl;
4
5
  apiKey;
@@ -66,20 +67,23 @@ export class OpenAIProvider {
66
67
  async chat(request) {
67
68
  const url = `${this.baseUrl}/chat/completions`;
68
69
  const payload = this.buildPayload(request, false);
70
+ const bodyStr = JSON.stringify(payload);
69
71
  const headers = {
70
72
  'Content-Type': 'application/json',
71
73
  };
72
74
  if (this.apiKey) {
73
75
  headers['Authorization'] = `Bearer ${this.apiKey}`;
74
76
  }
77
+ UsageTracker.getInstance().recordRequest(Buffer.byteLength(bodyStr, 'utf8'));
75
78
  const res = await fetch(url, {
76
79
  method: 'POST',
77
80
  headers,
78
- body: JSON.stringify(payload),
81
+ body: bodyStr,
79
82
  signal: request.signal
80
83
  });
81
84
  if (!res.ok) {
82
85
  const errText = await res.text();
86
+ UsageTracker.getInstance().recordResponseChunk(Buffer.byteLength(errText, 'utf8'));
83
87
  let errMsg = errText;
84
88
  try {
85
89
  const parsed = JSON.parse(errText);
@@ -95,7 +99,15 @@ export class OpenAIProvider {
95
99
  err.status = res.status;
96
100
  throw err;
97
101
  }
98
- const data = await res.json();
102
+ const rawResText = await res.text();
103
+ UsageTracker.getInstance().recordResponseChunk(Buffer.byteLength(rawResText, 'utf8'));
104
+ let data = {};
105
+ try {
106
+ data = JSON.parse(rawResText);
107
+ }
108
+ catch {
109
+ throw new Error(`Invalid JSON response from provider`);
110
+ }
99
111
  if (data.error) {
100
112
  const msg = data.error.message || data.error.code || JSON.stringify(data.error);
101
113
  throw new Error(`Provider Error: ${msg}`);
@@ -115,34 +127,47 @@ export class OpenAIProvider {
115
127
  totalTokens,
116
128
  cost
117
129
  };
130
+ UsageTracker.getInstance().recordTokens(promptTokens, completionTokens, cost);
118
131
  }
119
132
  return {
120
133
  content: choice.content || null,
121
- toolCalls: choice.tool_calls ? choice.tool_calls.map((tc) => ({
122
- id: tc.id,
123
- name: tc.function.name,
124
- arguments: JSON.parse(tc.function.arguments)
125
- })) : undefined,
134
+ toolCalls: choice.tool_calls ? choice.tool_calls.map((tc) => {
135
+ let parsedArgs = {};
136
+ try {
137
+ parsedArgs = JSON.parse(tc.function.arguments);
138
+ }
139
+ catch {
140
+ parsedArgs = {};
141
+ }
142
+ return {
143
+ id: tc.id || `call_${Math.random().toString(36).substring(2, 9)}`,
144
+ name: tc.function.name,
145
+ arguments: parsedArgs
146
+ };
147
+ }) : undefined,
126
148
  usage
127
149
  };
128
150
  }
129
151
  async *chatStream(request) {
130
152
  const url = `${this.baseUrl}/chat/completions`;
131
153
  const payload = this.buildPayload(request, true);
154
+ const bodyStr = JSON.stringify(payload);
132
155
  const headers = {
133
156
  'Content-Type': 'application/json',
134
157
  };
135
158
  if (this.apiKey) {
136
159
  headers['Authorization'] = `Bearer ${this.apiKey}`;
137
160
  }
161
+ UsageTracker.getInstance().recordRequest(Buffer.byteLength(bodyStr, 'utf8'));
138
162
  const res = await fetch(url, {
139
163
  method: 'POST',
140
164
  headers,
141
- body: JSON.stringify(payload),
165
+ body: bodyStr,
142
166
  signal: request.signal
143
167
  });
144
168
  if (!res.ok) {
145
169
  const errText = await res.text();
170
+ UsageTracker.getInstance().recordResponseChunk(Buffer.byteLength(errText, 'utf8'));
146
171
  let errMsg = errText;
147
172
  try {
148
173
  const parsed = JSON.parse(errText);
@@ -184,6 +209,9 @@ export class OpenAIProvider {
184
209
  const { done, value } = await reader.read();
185
210
  if (done || request.signal?.aborted)
186
211
  break;
212
+ if (value) {
213
+ UsageTracker.getInstance().recordResponseChunk(value.byteLength);
214
+ }
187
215
  buffer += decoder.decode(value, { stream: true });
188
216
  const lines = buffer.split('\n');
189
217
  buffer = lines.pop() || '';
@@ -333,6 +361,7 @@ export class OpenAIProvider {
333
361
  totalTokens,
334
362
  cost
335
363
  };
364
+ UsageTracker.getInstance().recordTokens(promptTokens, completionTokens, cost);
336
365
  const finalResponse = {
337
366
  content: accumulatedContent || null,
338
367
  toolCalls: toolCalls.length > 0 ? toolCalls : undefined,