testronaut 1.7.1 → 1.8.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/README.md CHANGED
@@ -143,6 +143,25 @@ testronaut --init
143
143
  TESTRONAUT_PROVIDER=gemini TESTRONAUT_MODEL=gemini-2.5-pro testronaut
144
144
  ```
145
145
 
146
+ ### Token-rate override
147
+
148
+ `TESTRONAUT_TOKENS_PER_MIN` is an emergency/manual hard override. A positive
149
+ numeric value takes priority over configured fallbacks and limits learned from
150
+ provider response headers. Testronaut prints a warning whenever it is active.
151
+
152
+ Clear it permanently from the current shell and remove the corresponding line
153
+ from `.env` or your shell profile:
154
+
155
+ ```bash
156
+ unset TESTRONAUT_TOKENS_PER_MIN
157
+ ```
158
+
159
+ To bypass a numeric value inherited from `.env` for one run:
160
+
161
+ ```bash
162
+ TESTRONAUT_TOKENS_PER_MIN=auto testronaut login.mission.js
163
+ ```
164
+
146
165
  Current supported providers:
147
166
 
148
167
  | Provider | Example Models |
package/bin/init.js CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  } from './initHelpers.js';
39
39
  import { DEFAULT_OPENAI_MODEL, OPENAI_MODELS } from '../llm/openAI/models.js';
40
40
  import { ANTHROPIC_MODELS, DEFAULT_ANTHROPIC_MODEL } from '../llm/anthropic/models.js';
41
+ import { defaultRateLimitConfig } from '../tools/rateLimitDefaults.js';
41
42
 
42
43
  export async function initializeTestronautProject() {
43
44
  // ─────────────────────────────────────────────
@@ -159,6 +160,7 @@ export async function initializeTestronautProject() {
159
160
  config.dom = { ...defaults.dom, ...(config.dom || {}) };
160
161
  config.resourceGuard = { ...defaults.resourceGuard, ...(config.resourceGuard || {}) };
161
162
  config.humanInput = { ...defaults.humanInput, ...(config.humanInput || {}) };
163
+ config.rateLimits = config.rateLimits || defaultRateLimitConfig(config.provider, config.model);
162
164
 
163
165
  // ─────────────────────────────────────────────
164
166
  // STEP 4: Persist config and scaffold .env (first run only)
package/core/turnLoop.js CHANGED
@@ -42,6 +42,8 @@ import {
42
42
  recordTokenUsage,
43
43
  pruneOldTokenUsage,
44
44
  updateLimitsFromHeaders,
45
+ updateLimitsFromError,
46
+ configureTokenControl,
45
47
  warnIfContextNearLimit
46
48
  } from '../tools/tokenControl.js';
47
49
  import { resolveProviderModel } from '../llm/modelResolver.js';
@@ -62,6 +64,13 @@ import {
62
64
  const { provider: PROVIDER_ID, model: MODEL_ID } = resolveProviderModel();
63
65
  console.log(`🧠 Using LLM → provider: ${PROVIDER_ID}, model: ${MODEL_ID}`);
64
66
 
67
+ let rateLimitConfig;
68
+ try {
69
+ const config = JSON.parse(fs.readFileSync('testronaut-config.json', 'utf8'));
70
+ rateLimitConfig = config.rateLimits;
71
+ } catch { /* startup fallbacks remain active when config is absent or invalid */ }
72
+ configureTokenControl({ provider: PROVIDER_ID, rateLimits: rateLimitConfig });
73
+
65
74
  const llm = getLLM(PROVIDER_ID);
66
75
 
67
76
  // Track resource/download coverage across turns (guard against partial loops).
@@ -353,21 +362,36 @@ export const turnLoop = async (
353
362
  // ─────────────────────────────────────────────
354
363
  // STEP 2: Request next reasoning turn from model
355
364
  // ─────────────────────────────────────────────
356
- await warnIfContextNearLimit(MODEL_ID, { messages, tools: activeToolsSchema });
365
+ const contextCheck = await warnIfContextNearLimit(MODEL_ID, { messages, tools: activeToolsSchema });
366
+ const projectedTokens = contextCheck.estimatedTokens
367
+ ?? await tokenEstimate(MODEL_ID, { messages, tools: activeToolsSchema });
368
+ ({ totalTokensUsed, turnTimestamps, shouldBackoff } = await tokenUseCoolOff(
369
+ totalTokensUsed,
370
+ turnTimestamps,
371
+ MODEL_ID,
372
+ projectedTokens
373
+ ));
374
+ if (shouldBackoff) {
375
+ recordStep(step);
376
+ turn -= 1;
377
+ continue;
378
+ }
357
379
  const { message, usage, headers } = await llm.chat({
358
380
  model: MODEL_ID,
359
381
  messages,
360
382
  tools: activeToolsSchema,
361
383
  });
362
384
  response = { message, usage, headers };
385
+ updateLimitsFromHeaders(MODEL_ID, headers);
363
386
 
364
387
  } catch (err) {
365
388
  // ─────────────────────────────────────────────
366
389
  // STEP 3: Error and rate-limit handling
367
390
  // ─────────────────────────────────────────────
368
391
  if (err.status === 429) {
369
- try { updateLimitsFromHeaders(MODEL_ID, err.headers || err.response?.headers || {}); } catch {}
370
- const delay = Math.min(60000, 2 ** retryCount * 2000);
392
+ let learned = {};
393
+ try { learned = updateLimitsFromError(MODEL_ID, err); } catch {}
394
+ const delay = Math.min(60000, learned.retryAfterMs ?? 2 ** retryCount * 2000);
371
395
  console.warn(`⚠️ Rate limited. Retrying in ${delay / 1000}s... (retry ${retryCount + 1})`);
372
396
  step.events.push(`⏳ Rate limit: waiting ${Math.round(delay/1000)}s (retry ${retryCount + 1})`);
373
397
  recordStep(step); // ✅ persist this step before sleeping
@@ -48,7 +48,16 @@ export class OpenAIProvider {
48
48
  request.reasoning_effort = chatToolReasoningEffort;
49
49
  }
50
50
 
51
- const res = await this.client.chat.completions.create(request);
51
+ const pending = this.client.chat.completions.create(request);
52
+ let res;
53
+ let headers;
54
+ if (typeof pending?.withResponse === 'function') {
55
+ const wrapped = await pending.withResponse();
56
+ res = wrapped.data;
57
+ headers = wrapped.response?.headers;
58
+ } else {
59
+ res = await pending;
60
+ }
52
61
 
53
62
  // OpenAI already returns an OpenAI-like message and usage structure.
54
63
  const message = res.choices?.[0]?.message ?? { role: 'assistant', content: '' };
@@ -58,7 +67,7 @@ export class OpenAIProvider {
58
67
  };
59
68
 
60
69
  // Some SDK versions surface headers on `res.headers`, others on `res.response.headers`.
61
- const headers = res.headers || res.response?.headers;
70
+ headers = headers || res.headers || res.response?.headers;
62
71
 
63
72
  return { message, usage, headers };
64
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testronaut",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "bin/cli.js"
@@ -84,6 +84,12 @@ describe('initializeTestronautProject', () => {
84
84
  expect(cfg.resourceGuard?.enabled).toBe(true);
85
85
  expect(cfg.humanInput?.enabled).toBe(true);
86
86
  expect(cfg.humanInput?.timeoutSeconds).toBe(60);
87
+ expect(cfg.rateLimits).toMatchObject({
88
+ tier: 'unknown',
89
+ region: 'global',
90
+ safetyMargin: 0.9,
91
+ models: { 'gpt-4o': { fallbackTPM: 450000 } },
92
+ });
87
93
 
88
94
  // .env
89
95
  const envPath = path.join(temp, '.env');
@@ -125,6 +131,9 @@ describe('initializeTestronautProject', () => {
125
131
  expect(cfg.resourceGuard?.enabled).toBe(true);
126
132
  expect(cfg.humanInput?.enabled).toBe(true);
127
133
  expect(cfg.humanInput?.timeoutSeconds).toBe(60);
134
+ expect(cfg.rateLimits.models).toEqual({
135
+ 'gemini-2.5-flash': { fallbackTPM: 300000 },
136
+ });
128
137
 
129
138
  const envTxt = fs.readFileSync(path.join(temp, '.env'), 'utf8');
130
139
  expect(envTxt).toMatch(/GEMINI_API_KEY=AIza/);
@@ -149,6 +158,9 @@ describe('initializeTestronautProject', () => {
149
158
  const cfg = readJson(path.join(temp, 'testronaut-config.json'));
150
159
  expect(cfg.provider).toBe('anthropic');
151
160
  expect(cfg.model).toBe('claude-sonnet-5');
161
+ expect(cfg.rateLimits.models).toEqual({
162
+ 'claude-sonnet-5': { fallbackTPM: 80000 },
163
+ });
152
164
  expect(fs.readFileSync(path.join(temp, '.env'), 'utf8')).toMatch(/ANTHROPIC_API_KEY=sk-ant-/);
153
165
  expect(shared.calls).toBe(2);
154
166
  });
@@ -67,6 +67,8 @@ vi.mock('../../tools/tokenControl.js', () => ({
67
67
  totalTokensUsed: turnTimestamps.reduce((a, [, t]) => a + t, 0),
68
68
  })),
69
69
  updateLimitsFromHeaders: vi.fn(() => {}),
70
+ updateLimitsFromError: vi.fn(() => ({})),
71
+ configureTokenControl: vi.fn(() => {}),
70
72
  warnIfContextNearLimit: vi.fn(async () => ({ warned: false })),
71
73
  }));
72
74
 
@@ -18,7 +18,7 @@ const { shared } = vi.hoisted(() => ({
18
18
  // Mock the OpenAI SDK surface we use
19
19
  vi.mock('openai', () => {
20
20
  class FakeChatCompletions {
21
- async create(args) {
21
+ create(args) {
22
22
  shared.lastCreateArgs = args;
23
23
  return shared.responseFactory();
24
24
  }
@@ -121,6 +121,25 @@ describe('OpenAIProvider', () => {
121
121
  expect(headers).toEqual({ 'x-ratelimit-limit-tokens': '12345' });
122
122
  });
123
123
 
124
+ it('uses the SDK raw response wrapper when withResponse is available', async () => {
125
+ const rawHeaders = new Headers({ 'x-ratelimit-limit-tokens': '54321' });
126
+ shared.responseFactory = () => ({
127
+ withResponse: async () => ({
128
+ data: {
129
+ choices: [{ message: { role: 'assistant', content: 'wrapped' } }],
130
+ usage: { total_tokens: 12 },
131
+ },
132
+ response: { headers: rawHeaders },
133
+ }),
134
+ });
135
+
136
+ const prov = new OpenAIProvider({ apiKey: 'sk-abc' });
137
+ const result = await prov.chat({ model: 'gpt-4o', messages: [] });
138
+ expect(result.message.content).toBe('wrapped');
139
+ expect(result.usage.total_tokens).toBe(12);
140
+ expect(result.headers).toBe(rawHeaders);
141
+ });
142
+
124
143
  it('provides a default assistant message when choices are empty', async () => {
125
144
  shared.responseFactory = () => ({
126
145
  choices: [],
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { defaultRateLimitConfig, getFallbackTPM } from '../../tools/rateLimitDefaults.js';
3
+
4
+ describe('rateLimitDefaults', () => {
5
+ it.each([
6
+ ['openai', 'gpt-5.6', 500000],
7
+ ['openai', 'gpt-5.6-luna', 500000],
8
+ ['openai', 'gpt-5.4-nano', 200000],
9
+ ['openai', 'gpt-5-mini', 240000],
10
+ ['gemini', 'gemini-2.5-pro', 120000],
11
+ ['gemini', 'gemini-2.5-flash', 300000],
12
+ ['anthropic', 'claude-sonnet-5', 80000],
13
+ ['claude', 'claude-haiku-4-5', 100000],
14
+ ])('resolves %s/%s to %i TPM', (provider, model, expected) => {
15
+ expect(getFallbackTPM(provider, model)).toBe(expected);
16
+ });
17
+
18
+ it('does not match a model family belonging to a different provider', () => {
19
+ expect(getFallbackTPM('gemini', 'gpt-4o')).toBe(150000);
20
+ });
21
+
22
+ it('uses a conservative fallback for unknown models', () => {
23
+ expect(getFallbackTPM('custom', 'future-model')).toBe(150000);
24
+ });
25
+
26
+ it('generates config for only the selected model', () => {
27
+ expect(defaultRateLimitConfig('openai', 'gpt-4o')).toEqual({
28
+ tier: 'unknown',
29
+ region: 'global',
30
+ safetyMargin: 0.9,
31
+ models: { 'gpt-4o': { fallbackTPM: 450000 } },
32
+ });
33
+ });
34
+ });
@@ -30,12 +30,15 @@ import {
30
30
  tokenEstimate,
31
31
  getCurrentTokenLimit,
32
32
  updateLimitsFromHeaders,
33
+ updateLimitsFromError,
34
+ configureTokenControl,
33
35
  tokenUseCoolOff,
34
36
  recordTokenUsage,
35
37
  pruneOldTokenUsage,
36
38
  warnIfContextNearLimit,
37
39
  __resetTokenControlForTests,
38
40
  } from '../../tools/tokenControl.js';
41
+ import { wait } from '../../tools/turnLoopUtils.js';
39
42
 
40
43
  describe('tokenControl', () => {
41
44
  beforeEach(() => {
@@ -46,6 +49,7 @@ describe('tokenControl', () => {
46
49
  tiktokenMocks.get_encoding_impl.mockReset().mockReturnValue(encMock);
47
50
  encMock.encode.mockClear();
48
51
  encMock.free.mockClear();
52
+ wait.mockClear();
49
53
  });
50
54
 
51
55
  afterEach(() => {
@@ -103,6 +107,58 @@ describe('tokenControl', () => {
103
107
  expect(anyModel.source).toBe('env');
104
108
  });
105
109
 
110
+ it('warns once when a numeric environment override is configured', () => {
111
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
112
+ process.env.TESTRONAUT_TOKENS_PER_MIN = '999';
113
+ configureTokenControl({ provider: 'openai' });
114
+ configureTokenControl({ provider: 'openai' });
115
+ expect(warn).toHaveBeenCalledOnce();
116
+ expect(warn.mock.calls[0][0]).toContain('TPM override active');
117
+ expect(warn.mock.calls[0][0]).toContain('unset TESTRONAUT_TOKENS_PER_MIN');
118
+ expect(warn.mock.calls[0][0]).toContain('TESTRONAUT_TOKENS_PER_MIN=auto');
119
+ warn.mockRestore();
120
+ });
121
+
122
+ it('treats auto as a one-run bypass for an inherited override', () => {
123
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
124
+ process.env.TESTRONAUT_TOKENS_PER_MIN = 'auto';
125
+ configureTokenControl({
126
+ provider: 'openai',
127
+ rateLimits: { models: { 'gpt-4o': { fallbackTPM: 777 } } },
128
+ });
129
+ expect(getCurrentTokenLimit('gpt-4o')).toEqual({ tpm: 777, source: 'config-fallback' });
130
+ expect(warn).not.toHaveBeenCalled();
131
+ warn.mockRestore();
132
+ });
133
+
134
+ it('gives the environment override priority over config and learned headers', () => {
135
+ configureTokenControl({
136
+ provider: 'openai',
137
+ rateLimits: { models: { 'gpt-4o': { tpm: 700 } } },
138
+ });
139
+ updateLimitsFromHeaders('gpt-4o', { 'x-ratelimit-limit-tokens': '800' });
140
+ process.env.TESTRONAUT_TOKENS_PER_MIN = '900';
141
+ expect(getCurrentTokenLimit('gpt-4o')).toEqual({ tpm: 900, source: 'env' });
142
+ });
143
+
144
+ it('gives an explicit model config override priority over learned headers', () => {
145
+ configureTokenControl({
146
+ provider: 'openai',
147
+ rateLimits: { models: { 'gpt-4o': { tpm: 700, fallbackTPM: 600 } } },
148
+ });
149
+ updateLimitsFromHeaders('gpt-4o', { 'x-ratelimit-limit-tokens': '800' });
150
+ expect(getCurrentTokenLimit('gpt-4o')).toEqual({ tpm: 700, source: 'config' });
151
+ });
152
+
153
+ it('ignores invalid environment and config values', () => {
154
+ process.env.TESTRONAUT_TOKENS_PER_MIN = '-4';
155
+ configureTokenControl({
156
+ provider: 'openai',
157
+ rateLimits: { models: { 'future-model': { tpm: 'nope', fallbackTPM: 0 } } },
158
+ });
159
+ expect(getCurrentTokenLimit('future-model')).toEqual({ tpm: 150000, source: 'default' });
160
+ });
161
+
106
162
  it('updates limits from headers (header wins)', () => {
107
163
  const before = getCurrentTokenLimit('gpt-4o');
108
164
  updateLimitsFromHeaders('gpt-4o', { 'x-ratelimit-limit-tokens': '1234' });
@@ -113,6 +169,71 @@ describe('tokenControl', () => {
113
169
  if (before.tpm !== 1234) expect(after.tpm).not.toBe(before.tpm);
114
170
  });
115
171
 
172
+ it('uses selected-model config fallback but lets learned headers supersede it', () => {
173
+ configureTokenControl({
174
+ provider: 'openai',
175
+ rateLimits: { safetyMargin: 0.9, models: { 'gpt-4o': { fallbackTPM: 777 } } },
176
+ });
177
+ expect(getCurrentTokenLimit('gpt-4o')).toMatchObject({ tpm: 777, source: 'config-fallback' });
178
+ updateLimitsFromHeaders('gpt-4o', { 'x-ratelimit-limit-tokens': '888' });
179
+ expect(getCurrentTokenLimit('gpt-4o')).toMatchObject({ tpm: 888, source: 'header' });
180
+ });
181
+
182
+ it('reads Headers objects and captures remaining/reset/request metadata', () => {
183
+ const headers = new Headers({
184
+ 'x-ratelimit-limit-tokens': '1234',
185
+ 'x-ratelimit-remaining-tokens': '1000',
186
+ 'x-ratelimit-reset-tokens': '2s',
187
+ 'x-ratelimit-limit-requests': '50',
188
+ });
189
+ updateLimitsFromHeaders('gpt-4o', headers);
190
+ expect(getCurrentTokenLimit('gpt-4o')).toMatchObject({
191
+ tpm: 1234,
192
+ remainingTokens: 1000,
193
+ resetTokens: '2s',
194
+ rpm: 50,
195
+ });
196
+ });
197
+
198
+ it('accepts alternate token-limit header names case-insensitively', () => {
199
+ updateLimitsFromHeaders('gpt-4o', { 'X-RateLimit-Limit-TPM': '4321' });
200
+ expect(getCurrentTokenLimit('gpt-4o')).toMatchObject({ tpm: 4321, source: 'header' });
201
+ });
202
+
203
+ it('ignores missing models and invalid learned limits', () => {
204
+ updateLimitsFromHeaders('', { 'x-ratelimit-limit-tokens': '999' });
205
+ updateLimitsFromHeaders('gpt-4o', { 'x-ratelimit-limit-tokens': 'not-a-number' });
206
+ expect(getCurrentTokenLimit('gpt-4o')).toMatchObject({ tpm: 450000, source: 'default' });
207
+ });
208
+
209
+ it('learns retry timing from a structured rate-limit error message', () => {
210
+ expect(updateLimitsFromError('gemini-2.5-pro', {
211
+ message: 'Quota exhausted. Please retry in 11.5s',
212
+ })).toEqual({ retryAfterMs: 11500 });
213
+ });
214
+
215
+ it('prefers Retry-After from a Headers object', () => {
216
+ expect(updateLimitsFromError('gpt-4o', {
217
+ headers: new Headers({
218
+ 'retry-after': '3.25',
219
+ 'x-ratelimit-limit-tokens': '5000',
220
+ }),
221
+ message: 'retry in 20s',
222
+ })).toEqual({ retryAfterMs: 3250 });
223
+ expect(getCurrentTokenLimit('gpt-4o')).toMatchObject({ tpm: 5000, source: 'header' });
224
+ });
225
+
226
+ it('returns no retry duration when an error provides none', () => {
227
+ expect(updateLimitsFromError('gpt-4o', { message: 'rate limited' }))
228
+ .toEqual({ retryAfterMs: undefined });
229
+ });
230
+
231
+ it('clears learned state when runtime context is reconfigured', () => {
232
+ updateLimitsFromHeaders('gpt-4o', { 'x-ratelimit-limit-tokens': '1234' });
233
+ configureTokenControl({ provider: 'openai' });
234
+ expect(getCurrentTokenLimit('gpt-4o')).toMatchObject({ tpm: 450000, source: 'default' });
235
+ });
236
+
116
237
  it('uses tier-1 defaults without letting generic GPT-5 rules shadow variants', () => {
117
238
  expect(getCurrentTokenLimit('gpt-5.6').tpm).toBe(500000);
118
239
  expect(getCurrentTokenLimit('gpt-5.6-luna').tpm).toBe(500000);
@@ -153,7 +274,7 @@ describe('tokenControl', () => {
153
274
 
154
275
  const result = await tokenUseCoolOff(60, entries, 'any-model');
155
276
  expect(result.shouldBackoff).toBe(true);
156
- expect(result.totalTokensUsed).toBe(0);
277
+ expect(result.totalTokensUsed).toBeGreaterThanOrEqual(0);
157
278
  expect(Array.isArray(result.turnTimestamps)).toBe(true);
158
279
  });
159
280
 
@@ -165,5 +286,43 @@ describe('tokenControl', () => {
165
286
  expect(result.shouldBackoff).toBe(false);
166
287
  expect(result.totalTokensUsed).toBe(60);
167
288
  });
289
+
290
+ it('reserves the projected next request against the safety margin', async () => {
291
+ process.env.TESTRONAUT_TOKENS_PER_MIN = '100';
292
+ const entries = [];
293
+ recordTokenUsage(entries, 80);
294
+ const result = await tokenUseCoolOff(80, entries, 'any-model', 15);
295
+ expect(result.shouldBackoff).toBe(true);
296
+ });
297
+
298
+ it('honors a configured safety margin', async () => {
299
+ configureTokenControl({
300
+ provider: 'openai',
301
+ rateLimits: {
302
+ safetyMargin: 0.5,
303
+ models: { 'gpt-4o': { tpm: 100 } },
304
+ },
305
+ });
306
+ const entries = [];
307
+ recordTokenUsage(entries, 45);
308
+ expect((await tokenUseCoolOff(45, entries, 'gpt-4o', 6)).shouldBackoff).toBe(true);
309
+ });
310
+
311
+ it('does not wait forever when one projected request alone exceeds the limit', async () => {
312
+ process.env.TESTRONAUT_TOKENS_PER_MIN = '10';
313
+ const result = await tokenUseCoolOff(0, [], 'any-model', 100);
314
+ expect(result.shouldBackoff).toBe(false);
315
+ expect(wait).not.toHaveBeenCalled();
316
+ });
317
+
318
+ it('waits until the oldest necessary rolling entry expires', async () => {
319
+ process.env.TESTRONAUT_TOKENS_PER_MIN = '100';
320
+ const now = 1_000_000;
321
+ const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now);
322
+ const entries = [[now - 50000, 40], [now - 10000, 40]];
323
+ await tokenUseCoolOff(80, entries, 'any-model', 15);
324
+ expect(wait).toHaveBeenCalledWith(10000);
325
+ nowSpy.mockRestore();
326
+ });
168
327
  });
169
328
  });
package/todo.md CHANGED
@@ -15,3 +15,11 @@
15
15
  - Learn effective OpenAI TPM/RPM limits from successful response headers and
16
16
  rate-limit errors. Prefer observed project/model limits over inferring a named
17
17
  account tier; retain the environment override and conservative startup values.
18
+ - Persist provider-observed rate limits in a separate, expiring local cache keyed
19
+ by provider, API project/account, endpoint/region, model, and traffic type.
20
+ Never rewrite user config or infer a named usage tier from a TPM value; expose
21
+ configured, cached, and live sources distinctly and discard stale observations.
22
+ - After dynamic rate limits are established, make DOM injection quota-aware.
23
+ Budget DOM tokens against both remaining TPM and the model context window,
24
+ preserve mission instructions and actionable controls, and fall back to
25
+ semantic sections plus on-demand DOM chunks instead of silent truncation.
@@ -0,0 +1,40 @@
1
+ const MODEL_LIMITS = [
2
+ { provider: 'openai', test: /^gpt-5\.6(-|$)/i, tpm: 500000 },
3
+ { provider: 'openai', test: /^gpt-5\.5(-|$)/i, tpm: 500000 },
4
+ { provider: 'openai', test: /^gpt-5\.4-nano(-|$)/i, tpm: 200000 },
5
+ { provider: 'openai', test: /^gpt-5\.4(-|$)/i, tpm: 500000 },
6
+ { provider: 'openai', test: /^gpt-5\.2(-|$)/i, tpm: 500000 },
7
+ { provider: 'openai', test: /^gpt-5\.1(-|$)/i, tpm: 120000 },
8
+ { provider: 'openai', test: /^gpt-5-mini(-|$)/i, tpm: 240000 },
9
+ { provider: 'openai', test: /^gpt-5-nano(-|$)/i, tpm: 600000 },
10
+ { provider: 'openai', test: /^gpt-5(-|$)/i, tpm: 90000 },
11
+ { provider: 'openai', test: /^gpt-4o(-|$)/i, tpm: 450000 },
12
+ { provider: 'openai', test: /^gpt-4\.1(-|$)/i, tpm: 1000000 },
13
+ { provider: 'openai', test: /^o3(-|$)/i, tpm: 300000 },
14
+ { provider: 'openai', test: /^o4-mini(-|$)/i, tpm: 600000 },
15
+ { provider: 'openai', test: /^gpt-4(-|$)/i, tpm: 150000 },
16
+ { provider: 'openai', test: /^gpt-3\.5(-|$)/i, tpm: 600000 },
17
+ { provider: 'gemini', test: /^gemini-2\.5-pro(-|$)/i, tpm: 120000 },
18
+ { provider: 'gemini', test: /^gemini-2\.5-flash(-|$)/i, tpm: 300000 },
19
+ { provider: 'anthropic', test: /^claude-(opus|sonnet)-/i, tpm: 80000 },
20
+ { provider: 'anthropic', test: /^claude-haiku-/i, tpm: 100000 },
21
+ ];
22
+
23
+ export function getFallbackTPM(provider, model) {
24
+ const normalizedProvider = provider === 'claude' ? 'anthropic' : provider;
25
+ const hit = MODEL_LIMITS.find(entry =>
26
+ (!normalizedProvider || entry.provider === normalizedProvider) && entry.test.test(model || '')
27
+ );
28
+ return hit?.tpm ?? 150000;
29
+ }
30
+
31
+ export function defaultRateLimitConfig(provider, model) {
32
+ return {
33
+ tier: 'unknown',
34
+ region: 'global',
35
+ safetyMargin: 0.9,
36
+ models: {
37
+ [model]: { fallbackTPM: getFallbackTPM(provider, model) },
38
+ },
39
+ };
40
+ }
@@ -23,6 +23,7 @@
23
23
  import { encoding_for_model, get_encoding } from '@dqbd/tiktoken';
24
24
  import { wait } from './turnLoopUtils.js';
25
25
  import { getOpenAIModel } from '../llm/openAI/models.js';
26
+ import { getFallbackTPM } from './rateLimitDefaults.js';
26
27
 
27
28
  /**
28
29
  * Dynamic token-per-minute limits by model family.
@@ -34,48 +35,36 @@ import { getOpenAIModel } from '../llm/openAI/models.js';
34
35
  * NOTE: These are conservative defaults meant for backoff heuristics, not hard truths.
35
36
  * Providers may change limits; env/header learning will supersede these.
36
37
  */
37
- const DEFAULT_LIMITS = [
38
- // ── OpenAI (newer first) ────────────────────────────────────────────────
39
- { test: /^gpt-5\.6-luna(-|$)/i, tpm: 500000 },
40
- { test: /^gpt-5\.6(-|$)/i, tpm: 500000 },
41
- { test: /^gpt-5\.5(-|$)/i, tpm: 500000 },
42
- { test: /^gpt-5\.4-nano(-|$)/i, tpm: 200000 },
43
- { test: /^gpt-5\.4(-|$)/i, tpm: 500000 },
44
- { test: /^gpt-5\.2(-|$)/i, tpm: 500000 },
45
- { test: /^gpt-5.1(-|$)/i, tpm: 120000 },
46
- { test: /^gpt-5-mini(-|$)/i, tpm: 240000 },
47
- { test: /^gpt-5-nano(-|$)/i, tpm: 600000 },
48
- { test: /^gpt-5(-|$)/i, tpm: 90000 },
49
-
50
- { test: /^gpt-4o(-|$)/i, tpm: 450000 },
51
- { test: /^gpt-4\.1(-|$)/i, tpm:1000000 },
52
-
53
- { test: /^o3(-|$)/i, tpm: 300000 },
54
- { test: /^o4-mini(-|$)/i, tpm: 600000 },
55
-
56
- // Older / fallback (OpenAI)
57
- { test: /^gpt-4(-|$)/i, tpm: 150000 },
58
- { test: /^gpt-3\.5(-|$)/i, tpm: 600000 },
59
-
60
- // ── Gemini (approximate, conservative) ─────────────────────────────────
61
- // These are heuristic defaults to guide local throttling only.
62
- { test: /^gemini-2\.5-pro(-|$)/i, tpm: 120000 },
63
- { test: /^gemini-2\.5-flash-8b(-|$)/i, tpm: 300000 },
64
- { test: /^gemini-2\.5-flash(-|$)/i, tpm: 300000 },
65
-
66
- // Anthropic limits vary by usage tier; this conservative fallback is only for local pacing.
67
- { test: /^claude-(opus|sonnet)-/i, tpm: 80000 },
68
- { test: /^claude-haiku-/i, tpm: 100000 },
69
-
70
- // Ultimate fallback for anything else
71
- { test: /.*/, tpm: 150000 },
72
- ];
73
-
38
+ /* Legacy model table moved to rateLimitDefaults.js so init and runtime share it. */
74
39
  // Live, mutable limits (can be updated by headers at runtime)
75
40
  const liveLimits = new Map(); // modelId -> { tpm, source: 'default'|'env'|'header' }
76
41
 
77
42
  // One-time warning tracking for tokenizer fallback
78
43
  const warnedModels = new Set();
44
+ let runtimeContext = { provider: undefined, rateLimits: undefined };
45
+ let overrideNoticeShown = false;
46
+
47
+ function getEnvironmentTPMOverride() {
48
+ const raw = String(process.env.TESTRONAUT_TOKENS_PER_MIN ?? '').trim();
49
+ if (!raw || raw.toLowerCase() === 'auto') return null;
50
+ const value = Number(raw);
51
+ return Number.isFinite(value) && value > 0 ? value : null;
52
+ }
53
+
54
+ export function configureTokenControl({ provider, rateLimits } = {}) {
55
+ runtimeContext = { provider, rateLimits };
56
+ liveLimits.clear();
57
+ const envTPM = getEnvironmentTPMOverride();
58
+ if (envTPM && !overrideNoticeShown) {
59
+ console.warn(
60
+ `⚠️ TPM override active: TESTRONAUT_TOKENS_PER_MIN=${envTPM}. ` +
61
+ 'This value overrides config and provider-advertised limits.\n' +
62
+ ' Clear it permanently with `unset TESTRONAUT_TOKENS_PER_MIN` and remove it from `.env` or your shell profile.\n' +
63
+ ' Bypass it for one run with `TESTRONAUT_TOKENS_PER_MIN=auto testronaut <mission>`.'
64
+ );
65
+ overrideNoticeShown = true;
66
+ }
67
+ }
79
68
 
80
69
  /* ---------------- Tokenizer helpers ----------------
81
70
  * We prefer tiktoken's per-model encoding when available.
@@ -185,15 +174,33 @@ export async function warnIfContextNearLimit(model, payload, threshold = 0.9) {
185
174
  * @returns {{tpm:number, source:'default'|'env'}}
186
175
  */
187
176
  function resolveDefaultLimitForModel(model) {
188
- const envTPM = process.env.TESTRONAUT_TOKENS_PER_MIN
189
- ? Number(process.env.TESTRONAUT_TOKENS_PER_MIN)
190
- : undefined;
191
- if (envTPM && Number.isFinite(envTPM) && envTPM > 0) {
177
+ const envTPM = getEnvironmentTPMOverride();
178
+ if (envTPM) {
192
179
  return { tpm: envTPM, source: 'env' };
193
180
  }
194
181
 
195
- const hit = DEFAULT_LIMITS.find(entry => entry.test.test(model || ''));
196
- return { tpm: hit?.tpm ?? 150000, source: 'default' };
182
+ const modelConfig = runtimeContext.rateLimits?.models?.[model] || {};
183
+ const configuredTPM = Number(modelConfig.tpm);
184
+ if (Number.isFinite(configuredTPM) && configuredTPM > 0) {
185
+ return { tpm: configuredTPM, source: 'config' };
186
+ }
187
+ const fallbackTPM = Number(modelConfig.fallbackTPM);
188
+ return {
189
+ tpm: Number.isFinite(fallbackTPM) && fallbackTPM > 0
190
+ ? fallbackTPM
191
+ : getFallbackTPM(runtimeContext.provider, model),
192
+ source: Number.isFinite(fallbackTPM) && fallbackTPM > 0 ? 'config-fallback' : 'default',
193
+ };
194
+ }
195
+
196
+ function resolveExplicitLimitForModel(model) {
197
+ const envTPM = getEnvironmentTPMOverride();
198
+ if (envTPM) return { tpm: envTPM, source: 'env' };
199
+ const configuredTPM = Number(runtimeContext.rateLimits?.models?.[model]?.tpm);
200
+ if (Number.isFinite(configuredTPM) && configuredTPM > 0) {
201
+ return { tpm: configuredTPM, source: 'config' };
202
+ }
203
+ return null;
197
204
  }
198
205
 
199
206
  /**
@@ -205,6 +212,8 @@ function resolveDefaultLimitForModel(model) {
205
212
  */
206
213
  export function getCurrentTokenLimit(model) {
207
214
  const m = (model || '').trim() || 'unknown';
215
+ const explicit = resolveExplicitLimitForModel(m);
216
+ if (explicit) return explicit;
208
217
  const live = liveLimits.get(m);
209
218
  if (live?.tpm) return live;
210
219
 
@@ -225,7 +234,11 @@ export function updateLimitsFromHeaders(model, headers = {}) {
225
234
 
226
235
  // Normalize header keys to lowercase
227
236
  const lower = {};
228
- for (const k of Object.keys(headers || {})) lower[k.toLowerCase()] = headers[k];
237
+ if (typeof headers?.forEach === 'function') {
238
+ headers.forEach((value, key) => { lower[String(key).toLowerCase()] = value; });
239
+ } else {
240
+ for (const k of Object.keys(headers || {})) lower[k.toLowerCase()] = headers[k];
241
+ }
229
242
 
230
243
  const tokenCap =
231
244
  Number(lower['x-ratelimit-limit-tokens']) ||
@@ -236,12 +249,38 @@ export function updateLimitsFromHeaders(model, headers = {}) {
236
249
  if (tokenCap && Number.isFinite(tokenCap) && tokenCap > 0) {
237
250
  const cur = getCurrentTokenLimit(model);
238
251
  if (cur.tpm !== tokenCap || cur.source !== 'header') {
239
- liveLimits.set(model, { tpm: tokenCap, source: 'header' });
252
+ liveLimits.set(model, {
253
+ tpm: tokenCap,
254
+ remainingTokens: numericHeader(lower['x-ratelimit-remaining-tokens']),
255
+ resetTokens: lower['x-ratelimit-reset-tokens'],
256
+ rpm: numericHeader(lower['x-ratelimit-limit-requests']),
257
+ remainingRequests: numericHeader(lower['x-ratelimit-remaining-requests']),
258
+ resetRequests: lower['x-ratelimit-reset-requests'],
259
+ source: 'header',
260
+ observedAt: Date.now(),
261
+ });
240
262
  console.log(`📏 Updated TPM for ${model}: ${tokenCap} (from headers)`);
241
263
  }
242
264
  }
243
265
  }
244
266
 
267
+ function numericHeader(value) {
268
+ const parsed = Number(value);
269
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
270
+ }
271
+
272
+ export function updateLimitsFromError(model, error = {}) {
273
+ const headers = error.headers || error.response?.headers || {};
274
+ updateLimitsFromHeaders(model, headers);
275
+ const retryAfter = typeof headers?.get === 'function'
276
+ ? headers.get('retry-after')
277
+ : headers?.['retry-after'];
278
+ const message = String(error.message || error.error?.message || '');
279
+ const retryMatch = message.match(/retry(?: in| after)?\s+([0-9.]+)s/i);
280
+ const seconds = Number(retryAfter ?? retryMatch?.[1]);
281
+ return { retryAfterMs: Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : undefined };
282
+ }
283
+
245
284
  /* ---------------- Cooloff / backoff logic ---------------- */
246
285
 
247
286
  /**
@@ -252,14 +291,18 @@ export function updateLimitsFromHeaders(model, headers = {}) {
252
291
  * @param {string} model - model id for TPM lookup
253
292
  * @returns {Promise<{shouldBackoff:boolean,totalTokensUsed:number,turnTimestamps:Array}>}
254
293
  */
255
- export const tokenUseCoolOff = async (totalTokensUsed, turnTimestamps, model) => {
294
+ export const tokenUseCoolOff = async (totalTokensUsed, turnTimestamps, model, projectedTokens = 0) => {
256
295
  const { tpm } = getCurrentTokenLimit(model);
257
- if (totalTokensUsed > tpm) {
258
- const msToWait = await getDynamicBackoffMs(turnTimestamps, tpm);
259
- console.warn(`⚠️ Token throttle risk (${totalTokensUsed}/${tpm}) → Waiting ${Math.ceil((msToWait || 1000)/1000)}s...`);
296
+ const margin = Number(runtimeContext.rateLimits?.safetyMargin);
297
+ const effectiveLimit = tpm * (Number.isFinite(margin) && margin > 0 && margin <= 1 ? margin : 0.9);
298
+ const projectedTotal = totalTokensUsed + Math.max(0, Number(projectedTokens) || 0);
299
+ if (projectedTotal > effectiveLimit && turnTimestamps.length) {
300
+ const msToWait = await getDynamicBackoffMs(turnTimestamps, Math.max(0, effectiveLimit - projectedTokens));
301
+ console.warn(`⚠️ Token throttle risk (${Math.ceil(projectedTotal)}/${Math.floor(effectiveLimit)}) → Waiting ${Math.ceil((msToWait || 1000)/1000)}s...`);
260
302
  await wait(msToWait || 1000);
261
303
  console.log('✅ Backoff complete, resuming...');
262
- return { shouldBackoff: true, totalTokensUsed: 0, turnTimestamps: [] };
304
+ const refreshed = pruneOldTokenUsage(turnTimestamps);
305
+ return { shouldBackoff: true, ...refreshed };
263
306
  }
264
307
  return { shouldBackoff: false, totalTokensUsed, turnTimestamps };
265
308
  };
@@ -298,14 +341,13 @@ export const pruneOldTokenUsage = (turnTimestamps, windowMs = 60000) => {
298
341
  */
299
342
  const getDynamicBackoffMs = async (turnTimestamps, tokenLimit) => {
300
343
  const now = Date.now();
301
- let runningTotal = 0;
302
-
303
344
  const sorted = [...turnTimestamps].sort((a, b) => a[0] - b[0]);
345
+ let remainingTotal = sorted.reduce((sum, [, tokens]) => sum + tokens, 0);
304
346
  for (let i = 0; i < sorted.length; i++) {
305
- runningTotal += sorted[i][1];
306
- if (runningTotal > tokenLimit) {
307
- const [timestampOfExcess] = sorted[i];
308
- const msUntilSafe = 60000 - (now - timestampOfExcess);
347
+ remainingTotal -= sorted[i][1];
348
+ if (remainingTotal <= tokenLimit) {
349
+ const [timestampToExpire] = sorted[i];
350
+ const msUntilSafe = 60000 - (now - timestampToExpire);
309
351
  return Math.max(msUntilSafe, 1000); // at least 1s
310
352
  }
311
353
  }
@@ -320,4 +362,6 @@ const getDynamicBackoffMs = async (turnTimestamps, tokenLimit) => {
320
362
  export function __resetTokenControlForTests() {
321
363
  liveLimits.clear();
322
364
  warnedModels.clear();
365
+ runtimeContext = { provider: undefined, rateLimits: undefined };
366
+ overrideNoticeShown = false;
323
367
  }