testronaut 1.1.2 → 1.2.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.
@@ -1,3 +1,25 @@
1
+ /**
2
+ * tokenControl.js
3
+ * ----------------
4
+ * Purpose:
5
+ * Centralized token counting and throttling utilities for Testronaut.
6
+ * Provides per-model token estimation, dynamic token-per-minute (TPM)
7
+ * limits, adaptive backoff, and header-learned limit updates.
8
+ *
9
+ * Responsibilities:
10
+ * - Estimate token counts (prefer model encodings; fallback to bytes/4 heuristic).
11
+ * - Determine current TPM limit (defaults → ENV override → header-learned).
12
+ * - Track rolling token usage and apply cooldowns to avoid rate limits.
13
+ * - Accept provider headers (e.g., OpenAI) to update live TPM caps.
14
+ *
15
+ * Related tests:
16
+ * Located in `tests/toolsTests/`
17
+ *
18
+ * Used by:
19
+ * - core/turnLoop.js (rate limiting & accounting)
20
+ * - tools that need token estimation before pushing large DOM payloads
21
+ */
22
+
1
23
  import { encoding_for_model, get_encoding } from '@dqbd/tiktoken';
2
24
  import { wait } from './turnLoopUtils.js';
3
25
 
@@ -7,23 +29,32 @@ import { wait } from './turnLoopUtils.js';
7
29
  * - ENV override: TESTRONAUT_TOKENS_PER_MIN forces a single limit for everything.
8
30
  * - At runtime, you can call `updateLimitsFromHeaders(model, headers)` after a 429
9
31
  * to adopt server-advertised limits (if present).
32
+ *
33
+ * NOTE: These are conservative defaults meant for backoff heuristics, not hard truths.
34
+ * Providers may change limits; env/header learning will supersede these.
10
35
  */
11
36
  const DEFAULT_LIMITS = [
12
- // Newer families first (checked in order)
13
- { test: /^gpt-5(-|$)/i, tpm: 90000 },
37
+ // ── OpenAI (newer first) ────────────────────────────────────────────────
38
+ { test: /^gpt-5(-|$)/i, tpm: 90000 },
14
39
  { test: /^gpt-5-mini(-|$)/i, tpm: 240000 },
15
40
  { test: /^gpt-5-nano(-|$)/i, tpm: 600000 },
16
41
 
17
42
  { test: /^gpt-4o(-|$)/i, tpm: 450000 },
18
- { test: /^gpt-4\.1(-|$)/i, tpm: 1000000 },
43
+ { test: /^gpt-4\.1(-|$)/i, tpm:1000000 },
19
44
 
20
45
  { test: /^o3(-|$)/i, tpm: 300000 },
21
46
  { test: /^o4-mini(-|$)/i, tpm: 600000 },
22
47
 
23
- // Older / fallback
48
+ // Older / fallback (OpenAI)
24
49
  { test: /^gpt-4(-|$)/i, tpm: 150000 },
25
50
  { test: /^gpt-3\.5(-|$)/i, tpm: 600000 },
26
51
 
52
+ // ── Gemini (approximate, conservative) ─────────────────────────────────
53
+ // These are heuristic defaults to guide local throttling only.
54
+ { test: /^gemini-2\.5-pro(-|$)/i, tpm: 120000 },
55
+ { test: /^gemini-2\.5-flash-8b(-|$)/i, tpm: 300000 },
56
+ { test: /^gemini-2\.5-flash(-|$)/i, tpm: 300000 },
57
+
27
58
  // Ultimate fallback for anything else
28
59
  { test: /.*/, tpm: 150000 },
29
60
  ];
@@ -34,14 +65,24 @@ const liveLimits = new Map(); // modelId -> { tpm, source: 'default'|'env'|'head
34
65
  // One-time warning tracking for tokenizer fallback
35
66
  const warnedModels = new Set();
36
67
 
37
- /* ---------------- Tokenizer helpers (same idea as before) ---------------- */
38
-
68
+ /* ---------------- Tokenizer helpers ----------------
69
+ * We prefer tiktoken's per-model encoding when available.
70
+ * For unknown models, we pick a close base:
71
+ * - o200k_base for modern, long-context families (OpenAI 4o/4.1/5, Gemini 2.5, O*)
72
+ * - cl100k_base as a broad fallback
73
+ */
39
74
  function getTokenizer(model) {
40
75
  try {
41
76
  return encoding_for_model(model);
42
77
  } catch (_) {
43
- const m = (model || '').toLowerCase();
78
+ const m = String(model || '').toLowerCase();
79
+
80
+ // Treat Gemini 2.5 like modern long-context models
81
+ const isGemini25 = /^gemini-2\.5/.test(m);
82
+
83
+ // OpenAI modern families also map well to o200k_base
44
84
  const useO200k =
85
+ isGemini25 ||
45
86
  m.startsWith('gpt-5') ||
46
87
  m.startsWith('gpt-4o') ||
47
88
  m.startsWith('gpt-4.1') ||
@@ -60,6 +101,14 @@ function getTokenizer(model) {
60
101
  }
61
102
  }
62
103
 
104
+ /**
105
+ * Estimate tokens for a given text under a model family.
106
+ * Prefers a real tokenizer; falls back to bytes/4 heuristic.
107
+ *
108
+ * @param {string} model - Model identifier (e.g., 'gpt-4o', 'gemini-2.5-flash')
109
+ * @param {string|any} text - Text or JSON-like payload to estimate
110
+ * @returns {Promise<number>} estimated token count
111
+ */
63
112
  export const tokenEstimate = async (model, text) => {
64
113
  const str = typeof text === 'string' ? text : JSON.stringify(text ?? '');
65
114
  const enc = getTokenizer(model);
@@ -88,8 +137,14 @@ export const tokenEstimate = async (model, text) => {
88
137
 
89
138
  /* ---------------- Dynamic limit resolution ---------------- */
90
139
 
140
+ /**
141
+ * Resolve a default TPM for a model:
142
+ * - ENV override takes precedence (TESTRONAUT_TOKENS_PER_MIN)
143
+ * - Otherwise match regex patterns in DEFAULT_LIMITS
144
+ * @param {string} model
145
+ * @returns {{tpm:number, source:'default'|'env'}}
146
+ */
91
147
  function resolveDefaultLimitForModel(model) {
92
- // Highest priority: ENV override (forces a single limit for all models)
93
148
  const envTPM = process.env.TESTRONAUT_TOKENS_PER_MIN
94
149
  ? Number(process.env.TESTRONAUT_TOKENS_PER_MIN)
95
150
  : undefined;
@@ -97,14 +152,16 @@ function resolveDefaultLimitForModel(model) {
97
152
  return { tpm: envTPM, source: 'env' };
98
153
  }
99
154
 
100
- // Otherwise find best-matching default
101
155
  const hit = DEFAULT_LIMITS.find(entry => entry.test.test(model || ''));
102
156
  return { tpm: hit?.tpm ?? 150000, source: 'default' };
103
157
  }
104
158
 
105
159
  /**
106
- * Public: get the current token-per-minute limit for a model.
107
- * Checks liveOverrides first (header-learned), then ENV, then defaults.
160
+ * Get the current token-per-minute limit for a model.
161
+ * Priority: header-learned (live) → ENV → defaults.
162
+ *
163
+ * @param {string} model
164
+ * @returns {{tpm:number, source:'default'|'env'|'header'}}
108
165
  */
109
166
  export function getCurrentTokenLimit(model) {
110
167
  const m = (model || '').trim() || 'unknown';
@@ -117,11 +174,11 @@ export function getCurrentTokenLimit(model) {
117
174
  }
118
175
 
119
176
  /**
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').
177
+ * Update TPM from HTTP response headers (e.g., after 429).
178
+ * Looks for common provider headers (OpenAI/Azure style). No-op if absent.
179
+ *
180
+ * @param {string} model
181
+ * @param {Record<string, string|number>} headers
125
182
  */
126
183
  export function updateLimitsFromHeaders(model, headers = {}) {
127
184
  if (!model) return;
@@ -130,7 +187,6 @@ export function updateLimitsFromHeaders(model, headers = {}) {
130
187
  const lower = {};
131
188
  for (const k of Object.keys(headers || {})) lower[k.toLowerCase()] = headers[k];
132
189
 
133
- // Common header names OpenAI/Azure may set (varies!)
134
190
  const tokenCap =
135
191
  Number(lower['x-ratelimit-limit-tokens']) ||
136
192
  Number(lower['x-ratelimit-limit-tpm']) ||
@@ -148,6 +204,14 @@ export function updateLimitsFromHeaders(model, headers = {}) {
148
204
 
149
205
  /* ---------------- Cooloff / backoff logic ---------------- */
150
206
 
207
+ /**
208
+ * If usage exceeds TPM, wait until safe and reset rolling counters.
209
+ *
210
+ * @param {number} totalTokensUsed - rolling 60s total
211
+ * @param {Array<[number,number]>} turnTimestamps - [[tsMs, tokens], ...]
212
+ * @param {string} model - model id for TPM lookup
213
+ * @returns {Promise<{shouldBackoff:boolean,totalTokensUsed:number,turnTimestamps:Array}>}
214
+ */
151
215
  export const tokenUseCoolOff = async (totalTokensUsed, turnTimestamps, model) => {
152
216
  const { tpm } = getCurrentTokenLimit(model);
153
217
  if (totalTokensUsed > tpm) {
@@ -160,11 +224,22 @@ export const tokenUseCoolOff = async (totalTokensUsed, turnTimestamps, model) =>
160
224
  return { shouldBackoff: false, totalTokensUsed, turnTimestamps };
161
225
  };
162
226
 
227
+ /**
228
+ * Record tokens for the current turn in the rolling window.
229
+ * @param {Array<[number,number]>} turnTimestamps
230
+ * @param {number} tokensUsed
231
+ */
163
232
  export const recordTokenUsage = (turnTimestamps, tokensUsed) => {
164
233
  const now = Date.now();
165
234
  turnTimestamps.push([now, tokensUsed]);
166
235
  };
167
236
 
237
+ /**
238
+ * Remove entries older than `windowMs` from the rolling window.
239
+ * @param {Array<[number,number]>} turnTimestamps
240
+ * @param {number} windowMs
241
+ * @returns {{turnTimestamps:Array<[number,number]>, totalTokensUsed:number}}
242
+ */
168
243
  export const pruneOldTokenUsage = (turnTimestamps, windowMs = 60000) => {
169
244
  const cutoff = Date.now() - windowMs;
170
245
  const recentEntries = turnTimestamps.filter(([timestamp]) => timestamp > cutoff);
@@ -172,6 +247,14 @@ export const pruneOldTokenUsage = (turnTimestamps, windowMs = 60000) => {
172
247
  return { turnTimestamps: recentEntries, totalTokensUsed };
173
248
  };
174
249
 
250
+ /**
251
+ * Compute milliseconds to wait until under TPM again.
252
+ * Walks the sorted window and finds when usage first exceeds TPM.
253
+ *
254
+ * @param {Array<[number,number]>} turnTimestamps
255
+ * @param {number} tokenLimit
256
+ * @returns {Promise<number>} ms to wait (>= 0)
257
+ */
175
258
  const getDynamicBackoffMs = async (turnTimestamps, tokenLimit) => {
176
259
  const now = Date.now();
177
260
  let runningTotal = 0;
@@ -187,3 +270,10 @@ const getDynamicBackoffMs = async (turnTimestamps, tokenLimit) => {
187
270
  }
188
271
  return 0;
189
272
  };
273
+
274
+
275
+ // Test-only helper to clear internal state between tests
276
+ export function __resetTokenControlForTests() {
277
+ liveLimits.clear();
278
+ warnedModels.clear();
279
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ export default defineConfig({
3
+ test: {
4
+ environment: 'node',
5
+ include: ['tests/**/*.{test,spec}.js'],
6
+ coverage: { reporter: ['text', 'lcov'] }
7
+ }
8
+ });
@@ -1,33 +0,0 @@
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
-