testronaut 1.0.26 → 1.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/bin/cli.js CHANGED
@@ -26,6 +26,27 @@ const apiBase = 'http://api.testronaut.app'; // Replace with your actual API bas
26
26
 
27
27
 
28
28
  const args = process.argv.slice(2);
29
+
30
+ // Look for --model=<id> or --model <id>
31
+ let modelOverride;
32
+ const modelFlagIndex = args.findIndex(a => a === '--model' || a.startsWith('--model='));
33
+ if (modelFlagIndex >= 0) {
34
+ if (args[modelFlagIndex].includes('=')) {
35
+ modelOverride = args[modelFlagIndex].split('=')[1];
36
+ } else if (args[modelFlagIndex + 1]) {
37
+ modelOverride = args[modelFlagIndex + 1];
38
+ }
39
+
40
+ if (modelOverride) {
41
+ process.env.TESTRONAUT_MODEL = modelOverride.trim();
42
+ console.log(`🧠 Model override: ${process.env.TESTRONAUT_MODEL}`);
43
+ }
44
+
45
+ // Remove flag & value from args so they don't look like mission filenames
46
+ args.splice(modelFlagIndex, modelOverride ? 2 : 1);
47
+ }
48
+
49
+
29
50
  const missionsDir = path.resolve(process.cwd(), 'missions');
30
51
 
31
52
  const allResults = [];
@@ -165,10 +186,35 @@ const flatMissions = allResults.flatMap(entry => {
165
186
  }));
166
187
  });
167
188
 
189
+ // Read provider/model from config (allow env override for model)
190
+ const cfgPath = path.resolve(process.cwd(), 'testronaut-config.json');
191
+ let llmProvider = 'openai';
192
+ let llmModel = 'gpt-4o';
193
+
194
+ if (fs.existsSync(cfgPath)) {
195
+ try {
196
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
197
+ if (cfg?.provider) llmProvider = String(cfg.provider);
198
+ if (cfg?.model) llmModel = String(cfg.model);
199
+ } catch (e) {
200
+ console.warn('⚠️ Could not read testronaut-config.json:', e.message);
201
+ }
202
+ }
203
+
204
+ // Env override takes precedence for the model (matches runtime behavior)
205
+ if (process.env.TESTRONAUT_MODEL?.trim()) {
206
+ llmModel = process.env.TESTRONAUT_MODEL.trim();
207
+ }
208
+
209
+
168
210
  const report = {
169
211
  runId,
170
212
  startTime: startTime.toISOString(),
171
213
  endTime: endTime.toISOString(),
214
+ llm: {
215
+ provider: llmProvider,
216
+ model: llmModel,
217
+ },
172
218
  summary: {
173
219
  totalMissions: flatMissions.length,
174
220
  passed: flatMissions.filter(m => m.status === 'passed').length,
package/bin/init.js CHANGED
@@ -13,33 +13,76 @@ export async function initializeTestronautProject() {
13
13
  let config = {};
14
14
  if (fs.existsSync(configPath)) {
15
15
  config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
16
+
17
+ // --- Back-compat migration: old configs stored provider in `model`
18
+ if (!config.provider && config.model === 'openai') {
19
+ config.provider = 'openai';
20
+ // don't overwrite model yet; we'll re-ask below only if not initialized
21
+ }
16
22
  }
17
23
 
18
24
  // If not initialized before, walk through setup
19
25
  if (!config.initialized) {
20
- // Prompt for LLM
21
- const response = await prompts({
26
+ // 1) Provider
27
+ const { llmProvider } = await prompts({
22
28
  type: 'select',
23
29
  name: 'llmProvider',
24
30
  message: 'Which LLM provider would you like to use?',
25
31
  choices: [
26
- { title: 'OpenAI (GPT-4o)', value: 'openai' },
32
+ { title: 'OpenAI (GPT family)', value: 'openai' },
27
33
  // Add others like Anthropic, Mistral, etc. later
28
34
  ]
29
35
  });
30
36
 
31
- config.model = response.llmProvider;
37
+ config.provider = llmProvider; // store provider explicitly
38
+
39
+ // 2) If OpenAI, ask for model
40
+ const { openaiModel } = await prompts({
41
+ type: 'select',
42
+ name: 'openaiModel',
43
+ message: 'Select an OpenAI model for agentic workflows (function/tool calling):',
44
+ choices: [
45
+ { title: 'GPT-4.1 (general purpose)', value: 'gpt-4.1' },
46
+ { title: 'GPT-4.1 mini (faster, cheaper)', value: 'gpt-4.1-mini' },
47
+ { title: 'GPT-4o (multimodal, tool use)', value: 'gpt-4o' },
48
+ { title: 'GPT-4o mini (speed/cost optimized)', value: 'gpt-4o-mini' },
49
+ { title: 'o3 (reasoning w/ native tool use)', value: 'o3' },
50
+ { title: 'o4-mini (reasoning, cost-effective)', value: 'o4-mini' },
51
+
52
+ // 👇 GPT-5 family
53
+ { title: 'GPT-5 (latest reasoning model)', value: 'gpt-5' },
54
+ { title: 'GPT-5 mini (faster)', value: 'gpt-5-mini' },
55
+ { title: 'GPT-5 nano (lightweight)', value: 'gpt-5-nano' },
56
+ ],
57
+ initial: 1
58
+ });
59
+
60
+ config.model = openaiModel;
61
+
62
+ // Warn about GPT-5 availability
63
+ if (openaiModel.startsWith('gpt-5')) {
64
+ console.log(`
65
+ ⚠️ Warning: GPT-5 support may not be available for all users.
66
+ - Access depends on your OpenAI account/region
67
+ - Some capabilities may be gated or rate-limited
68
+ `);
69
+ }
70
+
71
+ // Default config fields
32
72
  config.initialized = true;
33
- config.outputDir = 'missions/mission_reports';
34
- config.projectName = path.basename(root);
35
- config.maxTurns = 20;
73
+ config.outputDir = config.outputDir || 'missions/mission_reports';
74
+ config.projectName = config.projectName || path.basename(root);
75
+ config.maxTurns = config.maxTurns ?? 20;
76
+
36
77
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
37
78
  console.log('📝 Created testronaut-config.json');
38
79
 
39
- if (response.llmProvider === 'openai') {
40
- // Display .env instructions
80
+ if (llmProvider === 'openai') {
81
+ // Display .env instructions for OpenAI
41
82
  if (!fs.existsSync(envPath)) {
42
- const exampleEnv = `# Add your OpenAI API key below\nOPENAI_API_KEY=sk-...\n`;
83
+ const exampleEnv = `# Add your OpenAI API key below
84
+ OPENAI_API_KEY=sk-...
85
+ `;
43
86
  fs.writeFileSync(envPath, exampleEnv);
44
87
  console.log('🔐 Created .env file');
45
88
  }
@@ -51,7 +94,7 @@ export async function initializeTestronautProject() {
51
94
 
52
95
  OPENAI_API_KEY=sk-...
53
96
 
54
- 📌 The key must have permissions to call the OpenAI Chat Completions API.
97
+ 📌 The key must have permissions to call the OpenAI API.
55
98
 
56
99
  ✅ You’re now ready to write missions in \`missions/\` and run your first mission using:
57
100
 
package/core/turnLoop.js CHANGED
@@ -11,8 +11,15 @@ import {
11
11
  tokenEstimate,
12
12
  tokenUseCoolOff,
13
13
  recordTokenUsage,
14
- pruneOldTokenUsage
14
+ pruneOldTokenUsage,
15
+ getCurrentTokenLimit,
16
+ updateLimitsFromHeaders
15
17
  } from '../tools/tokenControl.js';
18
+ import { resolveModel } from '../openAI/modelResolver.js';
19
+
20
+
21
+ const MODEL_ID = resolveModel();
22
+ console.log(`🧠 Using OpenAI model: ${MODEL_ID}`);
16
23
 
17
24
  const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
18
25
 
@@ -21,6 +28,14 @@ let turnTimestamps = [];
21
28
  let shouldBackoff;
22
29
  let steps = [];
23
30
 
31
+ function formatBytes(n) {
32
+ if (!Number.isFinite(n)) return `${n}`;
33
+ const u = ['B','KB','MB','GB','TB'];
34
+ let i = 0, v = n;
35
+ while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; }
36
+ return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${u[i]}`;
37
+ }
38
+
24
39
  const pushDOMAssistant = async (browser, messages, agentMemory, { skipIfLastTool } = {}) => {
25
40
  if (skipIfLastTool && skipIfLastTool.includes(messages.at(-1)?.name)) {
26
41
  console.log(`[skip] Skipping DOM push after redundant tool: ${messages.at(-1)?.name}`);
@@ -44,7 +59,7 @@ const pushDOMAssistant = async (browser, messages, agentMemory, { skipIfLastTool
44
59
  });
45
60
 
46
61
  const domHtml = await CHROME_TOOL_MAP.get_dom(browser, { limit: 100000, exclude: true }, agentMemory);
47
- await tokenEstimate('gpt-4o', domHtml);
62
+ await tokenEstimate( MODEL_ID, domHtml);
48
63
  // fs.writeFileSync(`missions/mission_reports/debug-expanded-${Date.now()}.html`, domHtml);
49
64
 
50
65
  messages.push({
@@ -81,7 +96,7 @@ export const turnLoop = async (
81
96
  ({ turnTimestamps, totalTokensUsed } = pruneOldTokenUsage(turnTimestamps));
82
97
 
83
98
  // Cool off check
84
- ({ totalTokensUsed, turnTimestamps, shouldBackoff } = await tokenUseCoolOff(totalTokensUsed, turnTimestamps));
99
+ ({ totalTokensUsed, turnTimestamps, shouldBackoff } = await tokenUseCoolOff(totalTokensUsed, turnTimestamps, MODEL_ID));
85
100
  if(shouldBackoff) return await turnLoop(browser, messages, maxTurns, turn, currentStep);
86
101
 
87
102
  //Tool Validator
@@ -115,12 +130,14 @@ export const turnLoop = async (
115
130
 
116
131
 
117
132
  response = await openai.chat.completions.create({
118
- model: 'gpt-4o',
133
+ model: MODEL_ID,
119
134
  messages,
120
135
  tools: toolsSchema,
121
136
  });
122
137
  } catch (err) {
123
138
  if (err.status === 429) {
139
+ // If headers include limits, learn them for this model
140
+ try { updateLimitsFromHeaders(MODEL_ID, err.headers || err.response?.headers || {}); } catch {}
124
141
  const delay = Math.min(60000, 2 ** retryCount * 2000);
125
142
  console.warn(`⚠️ Rate limited. Retrying in ${delay / 1000}s... (retry ${retryCount + 1})`);
126
143
  await wait(delay);
@@ -187,6 +204,25 @@ export const turnLoop = async (
187
204
  result = errorMessage;
188
205
  }
189
206
 
207
+ // ------ Capture file upload/download events for mission report ------
208
+ try {
209
+ const maybeJson = JSON.parse(result);
210
+ if (maybeJson && maybeJson._testronaut_file_event) {
211
+ currentStep.files = currentStep.files || [];
212
+ currentStep.files.push(maybeJson);
213
+
214
+ if (maybeJson._testronaut_file_event === 'upload') {
215
+ const msgLine = `📤 Uploaded "${maybeJson.fileName}" (${formatBytes(maybeJson.bytes)}) via ${maybeJson.method}`;
216
+ currentStep.events.push(msgLine);
217
+ console.log(msgLine);
218
+ } else if (maybeJson._testronaut_file_event === 'download') {
219
+ const msgLine = `📥 Downloaded "${maybeJson.fileName}" (${formatBytes(maybeJson.bytes)}) via ${maybeJson.mode}`;
220
+ currentStep.events.push(msgLine);
221
+ console.log(msgLine);
222
+ }
223
+ }
224
+ } catch { /* not JSON / ignore */ }
225
+
190
226
  console.log(`[tool ] ← ${fnName} result:`, errorMessage ? '❌ Failed' : '✅ Success');
191
227
  currentStep.events.push(`[tool ] ← ${fnName} result: ${errorMessage ? '❌ Failed' : '✅ Success'}`)
192
228
  const truncated = result.length > 100 ? result.slice(0, 100) + '…' : result;
@@ -221,7 +257,7 @@ export const turnLoop = async (
221
257
  exclude: true,
222
258
  focus: [],
223
259
  }, agentMemory);
224
- await tokenEstimate('gpt-4o', domHtml);
260
+ await tokenEstimate(MODEL_ID, domHtml);
225
261
  await pushDOMAssistant(browser, messages, agentMemory, {
226
262
  skipIfLastTool: ['get_dom', 'check_text']
227
263
  });
@@ -0,0 +1,33 @@
1
+ import path from 'path';
2
+ import fs from 'fs';
3
+
4
+ // Resolve model from config (or ENV) with safe fallback
5
+ const root = process.cwd();
6
+ const configPath = path.join(root, 'testronaut-config.json');
7
+
8
+ export function resolveModel() {
9
+ // Highest priority: env override
10
+ if (process.env.TESTRONAUT_MODEL?.trim()) {
11
+ return process.env.TESTRONAUT_MODEL.trim();
12
+ }
13
+
14
+ try {
15
+ if (fs.existsSync(configPath)) {
16
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
17
+ // Only use the model if provider is openai (future-proofing for other providers)
18
+ if (cfg?.provider === 'openai' && typeof cfg?.model === 'string' && cfg.model.trim()) {
19
+ return cfg.model.trim();
20
+ }
21
+ // Back-compat: older configs may have stored 'openai' in model
22
+ if (!cfg?.provider && cfg?.model === 'openai') {
23
+ return 'gpt-4o';
24
+ }
25
+ }
26
+ } catch (e) {
27
+ console.warn('⚠️ Could not read testronaut-config.json; using default model. Error:', e.message);
28
+ }
29
+
30
+ return 'gpt-4o';
31
+ }
32
+
33
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testronaut",
3
- "version": "1.0.26",
3
+ "version": "1.1.0",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "./bin/cli.js"
@@ -19,8 +19,26 @@ import {
19
19
  removeObfuscatedClassNames,
20
20
  } from './domControl.js';
21
21
  import fs from 'fs';
22
+ import path from 'path';
22
23
  import { ensureBrowsers } from '../tools/playwrightSetup.js';
23
24
 
25
+ const FILES_DIR = path.join('missions', 'files');
26
+ const REPORTS_DIR = path.join('missions', 'mission_reports');
27
+ const FILES_LOG = path.join(REPORTS_DIR, 'files.jsonl');
28
+
29
+ function ensureDir(p) {
30
+ try { fs.mkdirSync(p, { recursive: true }); } catch {}
31
+ }
32
+
33
+ function appendFileEvent(eventObj = {}) {
34
+ try {
35
+ ensureDir(REPORTS_DIR);
36
+ fs.appendFileSync(FILES_LOG, JSON.stringify(eventObj) + '\n', 'utf8');
37
+ } catch (e) {
38
+ console.warn('⚠️ Could not append file event:', e?.message);
39
+ }
40
+ }
41
+
24
42
  export class ChromeBrowser {
25
43
  constructor() {
26
44
  this.browser = null;
@@ -549,7 +567,13 @@ export class ChromeBrowser {
549
567
  });
550
568
  // return await pw.chromium.launch({ headless: true });
551
569
  }
552
- this.context = await this.browser.newContext();
570
+
571
+ ensureDir(FILES_DIR); // ⬅️ make sure missions/files exists
572
+
573
+ this.context = await this.browser.newContext({
574
+ acceptDownloads: true // ⬅️ important for Playwright download handling
575
+ });
576
+ // this.context = await this.browser.newContext();
553
577
  const first = await this.context.newPage();
554
578
  const id = this._addAndFocus(first);
555
579
 
@@ -991,7 +1015,127 @@ export class ChromeBrowser {
991
1015
  return `Screenshot saved at: ./screenshots/${name}_${timestamp}.png`;
992
1016
  }
993
1017
 
994
-
1018
+ async upload_file({ selector, fileName, useChooser = false, timeoutMs = 15000 }) {
1019
+ if (!selector) throw new Error('upload_file: selector is required');
1020
+ if (!fileName) throw new Error('upload_file: fileName is required');
1021
+
1022
+ ensureDir(FILES_DIR);
1023
+ const filePath = path.resolve(FILES_DIR, fileName);
1024
+ if (!fs.existsSync(filePath)) {
1025
+ throw new Error(`upload_file: file not found at ${filePath}`);
1026
+ }
1027
+
1028
+ await this.page.waitForSelector(selector, { timeout: timeoutMs });
1029
+
1030
+ let method = 'input';
1031
+ if (useChooser) {
1032
+ method = 'chooser';
1033
+ const [fileChooser] = await Promise.all([
1034
+ this.page.waitForEvent('filechooser', { timeout: timeoutMs }),
1035
+ this.page.click(selector, { timeout: timeoutMs })
1036
+ ]);
1037
+ await fileChooser.setFiles(filePath);
1038
+ } else {
1039
+ const input = this.page.locator(selector).first();
1040
+ await input.waitFor({ state: 'visible', timeout: timeoutMs }).catch(() => {});
1041
+ await input.setInputFiles(filePath, { timeout: timeoutMs });
1042
+ }
1043
+
1044
+ const stats = fs.statSync(filePath);
1045
+ const payload = {
1046
+ _testronaut_file_event: 'upload',
1047
+ method, // 'input' | 'chooser'
1048
+ fileName,
1049
+ sourcePath: filePath,
1050
+ bytes: stats.size,
1051
+ selector,
1052
+ ts: new Date().toISOString()
1053
+ };
1054
+
1055
+ appendFileEvent(payload);
1056
+ return JSON.stringify(payload);
1057
+ }
1058
+
1059
+
1060
+ async download_file({ selector, url, fileName, timeoutMs = 30000 }) {
1061
+ ensureDir(FILES_DIR);
1062
+
1063
+ if (!selector && !url) {
1064
+ throw new Error('download_file: provide either selector or url');
1065
+ }
1066
+
1067
+ const sanitize = (name) => name.replace(/[\\/:*?"<>|]+/g, '_').trim();
1068
+
1069
+ // Mode 1: page click triggers a browser download
1070
+ if (selector) {
1071
+ await this.page.waitForSelector(selector, { timeout: timeoutMs });
1072
+
1073
+ const [download] = await Promise.all([
1074
+ this.page.waitForEvent('download', { timeout: timeoutMs }),
1075
+ this.page.click(selector)
1076
+ ]);
1077
+
1078
+ const suggested = sanitize(download.suggestedFilename?.() || 'download.bin');
1079
+ const targetPath = path.join(FILES_DIR, sanitize(fileName || suggested));
1080
+ await download.saveAs(targetPath);
1081
+
1082
+ const stats = fs.statSync(targetPath);
1083
+ const payload = {
1084
+ _testronaut_file_event: 'download',
1085
+ mode: 'click',
1086
+ fileName: path.basename(targetPath),
1087
+ destPath: targetPath,
1088
+ bytes: stats.size,
1089
+ selector,
1090
+ url: null,
1091
+ ts: new Date().toISOString()
1092
+ };
1093
+
1094
+ appendFileEvent(payload);
1095
+ return JSON.stringify(payload);
1096
+ }
1097
+
1098
+ // Mode 2: direct URL fetch (no page interaction)
1099
+ const res = await fetch(url);
1100
+ if (!res.ok) {
1101
+ throw new Error(`download_file: GET ${url} failed with ${res.status}`);
1102
+ }
1103
+
1104
+ // Try to infer a filename if not provided
1105
+ const cd = res.headers.get('content-disposition') || '';
1106
+ let inferred = 'download.bin';
1107
+ const match = /filename\*=UTF-8''([^;]+)|filename="([^"]+)"/i.exec(cd);
1108
+ if (match) {
1109
+ inferred = decodeURIComponent(match[1] || match[2]);
1110
+ } else {
1111
+ try {
1112
+ const u = new URL(url);
1113
+ const tail = u.pathname.split('/').filter(Boolean).pop();
1114
+ if (tail) inferred = tail;
1115
+ } catch {}
1116
+ }
1117
+
1118
+ const targetPath = path.join(FILES_DIR, sanitize(fileName || inferred));
1119
+ const buf = Buffer.from(await res.arrayBuffer());
1120
+ fs.writeFileSync(targetPath, buf);
1121
+
1122
+ const stats = fs.statSync(targetPath);
1123
+ const payload = {
1124
+ _testronaut_file_event: 'download',
1125
+ mode: 'url',
1126
+ fileName: path.basename(targetPath),
1127
+ destPath: targetPath,
1128
+ bytes: stats.size,
1129
+ selector: null,
1130
+ url,
1131
+ ts: new Date().toISOString()
1132
+ };
1133
+
1134
+ appendFileEvent(payload);
1135
+ return JSON.stringify(payload);
1136
+ }
1137
+
1138
+
995
1139
  async close() {
996
1140
  await this.browser?.close();
997
1141
  }
@@ -1016,4 +1160,6 @@ export const CHROME_TOOL_MAP = {
1016
1160
  click_and_follow_popup: (b, args) => b.click_and_follow_popup(args),
1017
1161
  switch_to_page: (b, args) => b.switch_to_page(args),
1018
1162
  close_current_page: (b, args) => b.close_current_page(args),
1163
+ upload_file: (b, args) => b.upload_file(args),
1164
+ download_file: (b, args) => b.download_file(args),
1019
1165
  };
@@ -3,7 +3,7 @@ import fs from 'fs';
3
3
  import path from 'path';
4
4
 
5
5
  export function generateHtmlReport(report, outputPath) {
6
- const { runId, startTime, endTime, missions = [], summary = {} } = report;
6
+ const { runId, startTime, endTime, missions = [], summary = {}, llm = {} } = report;
7
7
  const durationSec =
8
8
  (startTime && endTime)
9
9
  ? ((new Date(endTime) - new Date(startTime)) / 1000).toFixed(2)
@@ -241,6 +241,7 @@ export function generateHtmlReport(report, outputPath) {
241
241
  <div><strong>Run ID:</strong> ${esc(runId ?? '—')}</div>
242
242
  <div><strong>Start:</strong> ${esc(startTime ?? '—')}</div>
243
243
  <div><strong>End:</strong> ${esc(endTime ?? '—')} • <strong>Duration:</strong> ${durationSec}s</div>
244
+ <div><strong>LLM:</strong> ${esc(llm.provider ?? '—')} • <strong>Model:</strong> ${esc(llm.model ?? '—')}</div>
244
245
  </div>
245
246
  </div>
246
247
 
@@ -248,6 +249,7 @@ export function generateHtmlReport(report, outputPath) {
248
249
  <div class="pill">Missions: ${esc(summary.totalMissions ?? totals.total)}</div>
249
250
  <div class="pill ok">Passed: ${esc(summary.passed ?? totals.passed)}</div>
250
251
  <div class="pill bad">Failed: ${esc(summary.failed ?? totals.failed)}</div>
252
+ <div class="pill">LLM: ${esc(llm.provider ?? '—')} • ${esc(llm.model ?? '')}</div>
251
253
  </div>
252
254
 
253
255
  <div class="container">
@@ -1,33 +1,164 @@
1
- import { encoding_for_model } from '@dqbd/tiktoken';
1
+ import { encoding_for_model, get_encoding } from '@dqbd/tiktoken';
2
2
  import { wait } from './turnLoopUtils.js';
3
3
 
4
- const TOKEN_BACKOFF_MS = 60000;
5
- const TOKEN_LIMIT_PER_MIN = 90000;
4
+ /**
5
+ * Dynamic token-per-minute limits by model family.
6
+ * - You can tweak these defaults anytime.
7
+ * - ENV override: TESTRONAUT_TOKENS_PER_MIN forces a single limit for everything.
8
+ * - At runtime, you can call `updateLimitsFromHeaders(model, headers)` after a 429
9
+ * to adopt server-advertised limits (if present).
10
+ */
11
+ const DEFAULT_LIMITS = [
12
+ // Newer families first (checked in order)
13
+ { test: /^gpt-5(-|$)/i, tpm: 90000 },
14
+ { test: /^gpt-5-mini(-|$)/i, tpm: 240000 },
15
+ { test: /^gpt-5-nano(-|$)/i, tpm: 600000 },
16
+
17
+ { test: /^gpt-4o(-|$)/i, tpm: 450000 },
18
+ { test: /^gpt-4\.1(-|$)/i, tpm: 1000000 },
19
+
20
+ { test: /^o3(-|$)/i, tpm: 300000 },
21
+ { test: /^o4-mini(-|$)/i, tpm: 600000 },
22
+
23
+ // Older / fallback
24
+ { test: /^gpt-4(-|$)/i, tpm: 150000 },
25
+ { test: /^gpt-3\.5(-|$)/i, tpm: 600000 },
26
+
27
+ // Ultimate fallback for anything else
28
+ { test: /.*/, tpm: 150000 },
29
+ ];
30
+
31
+ // Live, mutable limits (can be updated by headers at runtime)
32
+ const liveLimits = new Map(); // modelId -> { tpm, source: 'default'|'env'|'header' }
33
+
34
+ // One-time warning tracking for tokenizer fallback
35
+ const warnedModels = new Set();
36
+
37
+ /* ---------------- Tokenizer helpers (same idea as before) ---------------- */
38
+
39
+ function getTokenizer(model) {
40
+ try {
41
+ return encoding_for_model(model);
42
+ } catch (_) {
43
+ const m = (model || '').toLowerCase();
44
+ const useO200k =
45
+ m.startsWith('gpt-5') ||
46
+ m.startsWith('gpt-4o') ||
47
+ m.startsWith('gpt-4.1') ||
48
+ m.startsWith('o') ||
49
+ m.includes('omni');
50
+
51
+ try {
52
+ return get_encoding(useO200k ? 'o200k_base' : 'cl100k_base');
53
+ } catch (e2) {
54
+ if (!warnedModels.has(model)) {
55
+ console.warn(`⚠️ Could not load tokenizer for "${model}". Will approximate by bytes/4.`, e2?.message || e2);
56
+ warnedModels.add(model);
57
+ }
58
+ return null;
59
+ }
60
+ }
61
+ }
6
62
 
7
63
  export const tokenEstimate = async (model, text) => {
8
- const encoding = encoding_for_model(model);
9
- const tokenCount = encoding.encode(text).length;
10
- console.log(`🧠 Estimated token count: ${tokenCount}`);
11
- return tokenCount;
64
+ const str = typeof text === 'string' ? text : JSON.stringify(text ?? '');
65
+ const enc = getTokenizer(model);
66
+
67
+ if (enc) {
68
+ try {
69
+ const tokenCount = enc.encode(str).length;
70
+ enc.free?.();
71
+ console.log(`🧠 Estimated token count (${model}): ${tokenCount}`);
72
+ return tokenCount;
73
+ } catch (e) {
74
+ enc.free?.();
75
+ if (!warnedModels.has(model)) {
76
+ console.warn(`⚠️ Tokenizer encode failed for "${model}". Falling back to bytes/4.`, e?.message || e);
77
+ warnedModels.add(model);
78
+ }
79
+ }
80
+ }
81
+
82
+ // Final fallback: rough heuristic (UTF-8 bytes / 4)
83
+ const bytes = Buffer.from(str, 'utf8').length;
84
+ const approx = Math.ceil(bytes / 4);
85
+ console.log(`🧠 Estimated token count (approx, ${model}): ${approx}`);
86
+ return approx;
87
+ };
88
+
89
+ /* ---------------- Dynamic limit resolution ---------------- */
90
+
91
+ function resolveDefaultLimitForModel(model) {
92
+ // Highest priority: ENV override (forces a single limit for all models)
93
+ const envTPM = process.env.TESTRONAUT_TOKENS_PER_MIN
94
+ ? Number(process.env.TESTRONAUT_TOKENS_PER_MIN)
95
+ : undefined;
96
+ if (envTPM && Number.isFinite(envTPM) && envTPM > 0) {
97
+ return { tpm: envTPM, source: 'env' };
98
+ }
99
+
100
+ // Otherwise find best-matching default
101
+ const hit = DEFAULT_LIMITS.find(entry => entry.test.test(model || ''));
102
+ return { tpm: hit?.tpm ?? 150000, source: 'default' };
103
+ }
104
+
105
+ /**
106
+ * Public: get the current token-per-minute limit for a model.
107
+ * Checks liveOverrides first (header-learned), then ENV, then defaults.
108
+ */
109
+ export function getCurrentTokenLimit(model) {
110
+ const m = (model || '').trim() || 'unknown';
111
+ const live = liveLimits.get(m);
112
+ if (live?.tpm) return live;
113
+
114
+ const resolved = resolveDefaultLimitForModel(m);
115
+ liveLimits.set(m, resolved);
116
+ return resolved;
12
117
  }
13
118
 
14
- export const tokenUseCoolOff = async (totalTokensUsed, turnTimestamps) => {
15
- // console.log("total tokens used: ", totalTokensUsed);
16
- // console.log("turnTimestamps: ", turnTimestamps);
17
-
18
- if (totalTokensUsed > TOKEN_LIMIT_PER_MIN) {
19
- const msToWait = await getDynamicBackoffMs(turnTimestamps);
20
- console.warn(`⚠️ Token throttle risk → Waiting ${msToWait / 1000}s to cool off...`);
21
- await wait(msToWait);
22
- // totalTokensUsed = 0; // Reset token count after backoff
23
- // turnTimestamps = []; // Reset timestamps after backoff
119
+ /**
120
+ * Public: update limits from HTTP response headers (e.g., after 429).
121
+ * Looks for typical headers like:
122
+ * - x-ratelimit-limit-tokens: "90000"
123
+ * - x-ratelimit-remaining-tokens, x-ratelimit-reset-tokens, etc.
124
+ * If found, we store the new TPM for this model (source='header').
125
+ */
126
+ export function updateLimitsFromHeaders(model, headers = {}) {
127
+ if (!model) return;
128
+
129
+ // Normalize header keys to lowercase
130
+ const lower = {};
131
+ for (const k of Object.keys(headers || {})) lower[k.toLowerCase()] = headers[k];
132
+
133
+ // Common header names OpenAI/Azure may set (varies!)
134
+ const tokenCap =
135
+ Number(lower['x-ratelimit-limit-tokens']) ||
136
+ Number(lower['x-ratelimit-limit-tpm']) ||
137
+ Number(lower['x-ratelimit-limit-token']) ||
138
+ undefined;
139
+
140
+ if (tokenCap && Number.isFinite(tokenCap) && tokenCap > 0) {
141
+ const cur = getCurrentTokenLimit(model);
142
+ if (cur.tpm !== tokenCap || cur.source !== 'header') {
143
+ liveLimits.set(model, { tpm: tokenCap, source: 'header' });
144
+ console.log(`📏 Updated TPM for ${model}: ${tokenCap} (from headers)`);
145
+ }
146
+ }
147
+ }
148
+
149
+ /* ---------------- Cooloff / backoff logic ---------------- */
150
+
151
+ export const tokenUseCoolOff = async (totalTokensUsed, turnTimestamps, model) => {
152
+ const { tpm } = getCurrentTokenLimit(model);
153
+ if (totalTokensUsed > tpm) {
154
+ const msToWait = await getDynamicBackoffMs(turnTimestamps, tpm);
155
+ console.warn(`⚠️ Token throttle risk (${totalTokensUsed}/${tpm}) → Waiting ${Math.ceil((msToWait || 1000)/1000)}s...`);
156
+ await wait(msToWait || 1000);
24
157
  console.log('✅ Backoff complete, resuming...');
25
- // return true;
26
158
  return { shouldBackoff: true, totalTokensUsed: 0, turnTimestamps: [] };
27
159
  }
28
- // return false;
29
160
  return { shouldBackoff: false, totalTokensUsed, turnTimestamps };
30
- }
161
+ };
31
162
 
32
163
  export const recordTokenUsage = (turnTimestamps, tokensUsed) => {
33
164
  const now = Date.now();
@@ -41,22 +172,18 @@ export const pruneOldTokenUsage = (turnTimestamps, windowMs = 60000) => {
41
172
  return { turnTimestamps: recentEntries, totalTokensUsed };
42
173
  };
43
174
 
44
- const getDynamicBackoffMs = async (turnTimestamps, tokenLimit = TOKEN_LIMIT_PER_MIN) => {
175
+ const getDynamicBackoffMs = async (turnTimestamps, tokenLimit) => {
45
176
  const now = Date.now();
46
177
  let runningTotal = 0;
47
178
 
48
- // Sort oldest → newest to find the earliest point to fall below the limit
49
179
  const sorted = [...turnTimestamps].sort((a, b) => a[0] - b[0]);
50
-
51
180
  for (let i = 0; i < sorted.length; i++) {
52
- runningTotal += sorted[i][1]; // add tokensUsed
53
-
181
+ runningTotal += sorted[i][1];
54
182
  if (runningTotal > tokenLimit) {
55
183
  const [timestampOfExcess] = sorted[i];
56
184
  const msUntilSafe = 60000 - (now - timestampOfExcess);
57
- return Math.max(msUntilSafe, 1000); // wait at least 1s
185
+ return Math.max(msUntilSafe, 1000); // at least 1s
58
186
  }
59
187
  }
60
-
61
- return 0; // Safe to proceed
62
- }
188
+ return 0;
189
+ };
@@ -174,59 +174,118 @@ const toolsSchema = [
174
174
  },
175
175
  },
176
176
  {
177
- type: 'function',
178
- function: {
179
- name: 'click_and_follow_popup',
180
- description:
181
- 'Click a selector that opens a new tab/window (e.g., OAuth) and switch control to it. Falls back to same-tab navigation if no popup appears.',
182
- parameters: {
183
- type: 'object',
184
- properties: {
185
- selector: { type: 'string', description: 'CSS or text selector to click' },
186
- expectedUrlHost: {
187
- type: 'string',
188
- description: "Optional hostname hint like 'accounts.google.com'"
177
+ type: 'function',
178
+ function: {
179
+ name: 'click_and_follow_popup',
180
+ description:
181
+ 'Click a selector that opens a new tab/window (e.g., OAuth) and switch control to it. Falls back to same-tab navigation if no popup appears.',
182
+ parameters: {
183
+ type: 'object',
184
+ properties: {
185
+ selector: { type: 'string', description: 'CSS or text selector to click' },
186
+ expectedUrlHost: {
187
+ type: 'string',
188
+ description: "Optional hostname hint like 'accounts.google.com'"
189
+ },
190
+ timeoutMs: {
191
+ type: 'number',
192
+ default: 20000,
193
+ description: 'How long to wait for the popup or navigation'
194
+ }
189
195
  },
190
- timeoutMs: {
191
- type: 'number',
192
- default: 20000,
193
- description: 'How long to wait for the popup or navigation'
194
- }
195
- },
196
- required: ['selector']
196
+ required: ['selector']
197
+ }
197
198
  }
198
- }
199
- },
200
- {
201
- type: 'function',
202
- function: {
203
- name: 'switch_to_page',
204
- description:
205
- "Switch agent focus between known tabs/pages (e.g., back to 'main' or to the latest popup). Use a pageId previously returned by click_and_follow_popup to target a specific tab.",
206
- parameters: {
207
- type: 'object',
208
- properties: {
209
- target: {
210
- type: 'string',
211
- description: "One of: 'main', 'latest', or a pageId returned by click_and_follow_popup"
212
- }
213
- },
214
- required: ['target']
199
+ },
200
+ {
201
+ type: 'function',
202
+ function: {
203
+ name: 'switch_to_page',
204
+ description:
205
+ "Switch agent focus between known tabs/pages (e.g., back to 'main' or to the latest popup). Use a pageId previously returned by click_and_follow_popup to target a specific tab.",
206
+ parameters: {
207
+ type: 'object',
208
+ properties: {
209
+ target: {
210
+ type: 'string',
211
+ description: "One of: 'main', 'latest', or a pageId returned by click_and_follow_popup"
212
+ }
213
+ },
214
+ required: ['target']
215
+ }
216
+ }
217
+ },
218
+ {
219
+ type: 'function',
220
+ function: {
221
+ name: 'close_current_page',
222
+ description:
223
+ 'Close the current tab/page and automatically switch back to another open page if available.',
224
+ parameters: { type: 'object', properties: {} }
225
+ }
226
+ },
227
+ {
228
+ type: 'function',
229
+ function: {
230
+ name: 'upload_file',
231
+ description:
232
+ 'Upload a file into an <input type="file"> or via a file chooser. Defaults to missions/files as the file source.',
233
+ parameters: {
234
+ type: 'object',
235
+ properties: {
236
+ selector: {
237
+ type: 'string',
238
+ description: 'CSS selector for the file input or a button that opens a file chooser'
239
+ },
240
+ fileName: {
241
+ type: 'string',
242
+ description: 'File name located in missions/files (e.g., "sample.pdf")'
243
+ },
244
+ useChooser: {
245
+ type: 'boolean',
246
+ default: false,
247
+ description: 'If true, click the selector and fulfill the file chooser instead of setInputFiles'
248
+ },
249
+ timeoutMs: {
250
+ type: 'number',
251
+ default: 15000
252
+ }
253
+ },
254
+ required: ['selector', 'fileName']
255
+ }
256
+ }
257
+ },
258
+ {
259
+ type: 'function',
260
+ function: {
261
+ name: 'download_file',
262
+ description:
263
+ 'Download a file to missions/files. Either click a selector that triggers a download or fetch a direct URL.',
264
+ parameters: {
265
+ type: 'object',
266
+ properties: {
267
+ selector: {
268
+ type: 'string',
269
+ description: 'An element to click that triggers a download'
270
+ },
271
+ url: {
272
+ type: 'string',
273
+ description: 'Direct URL to download without clicking the page'
274
+ },
275
+ fileName: {
276
+ type: 'string',
277
+ description: 'Optional override for the saved filename'
278
+ },
279
+ timeoutMs: {
280
+ type: 'number',
281
+ default: 30000
282
+ }
283
+ },
284
+ // At least one of selector or url should be provided (enforced in code)
285
+ required: []
286
+ }
215
287
  }
216
288
  }
217
- },
218
- {
219
- type: 'function',
220
- function: {
221
- name: 'close_current_page',
222
- description:
223
- 'Close the current tab/page and automatically switch back to another open page if available.',
224
- parameters: { type: 'object', properties: {} }
225
- }
226
- }
227
-
228
-
229
-
230
289
  ];
231
290
 
232
291
  export default toolsSchema;