testronaut 1.0.27 → 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
 
@@ -52,7 +59,7 @@ const pushDOMAssistant = async (browser, messages, agentMemory, { skipIfLastTool
52
59
  });
53
60
 
54
61
  const domHtml = await CHROME_TOOL_MAP.get_dom(browser, { limit: 100000, exclude: true }, agentMemory);
55
- await tokenEstimate('gpt-4o', domHtml);
62
+ await tokenEstimate( MODEL_ID, domHtml);
56
63
  // fs.writeFileSync(`missions/mission_reports/debug-expanded-${Date.now()}.html`, domHtml);
57
64
 
58
65
  messages.push({
@@ -89,7 +96,7 @@ export const turnLoop = async (
89
96
  ({ turnTimestamps, totalTokensUsed } = pruneOldTokenUsage(turnTimestamps));
90
97
 
91
98
  // Cool off check
92
- ({ totalTokensUsed, turnTimestamps, shouldBackoff } = await tokenUseCoolOff(totalTokensUsed, turnTimestamps));
99
+ ({ totalTokensUsed, turnTimestamps, shouldBackoff } = await tokenUseCoolOff(totalTokensUsed, turnTimestamps, MODEL_ID));
93
100
  if(shouldBackoff) return await turnLoop(browser, messages, maxTurns, turn, currentStep);
94
101
 
95
102
  //Tool Validator
@@ -123,12 +130,14 @@ export const turnLoop = async (
123
130
 
124
131
 
125
132
  response = await openai.chat.completions.create({
126
- model: 'gpt-4o',
133
+ model: MODEL_ID,
127
134
  messages,
128
135
  tools: toolsSchema,
129
136
  });
130
137
  } catch (err) {
131
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 {}
132
141
  const delay = Math.min(60000, 2 ** retryCount * 2000);
133
142
  console.warn(`⚠️ Rate limited. Retrying in ${delay / 1000}s... (retry ${retryCount + 1})`);
134
143
  await wait(delay);
@@ -248,7 +257,7 @@ export const turnLoop = async (
248
257
  exclude: true,
249
258
  focus: [],
250
259
  }, agentMemory);
251
- await tokenEstimate('gpt-4o', domHtml);
260
+ await tokenEstimate(MODEL_ID, domHtml);
252
261
  await pushDOMAssistant(browser, messages, agentMemory, {
253
262
  skipIfLastTool: ['get_dom', 'check_text']
254
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.27",
3
+ "version": "1.1.0",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "./bin/cli.js"
@@ -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
+ };