testronaut 1.1.2 → 1.2.1
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/CONTRIBUTING.md +194 -0
- package/bin/cli.js +1 -1
- package/bin/init.js +141 -57
- package/bin/initHelpers.js +140 -0
- package/core/agent.js +1 -1
- package/core/redaction.js +161 -33
- package/core/turnLoop.js +170 -107
- package/llm/gemini/geminiProvider.js +188 -0
- package/llm/llmFactory.js +50 -0
- package/llm/modelResolver.js +65 -0
- package/llm/openAI/openaiProvider.js +54 -0
- package/package.json +9 -3
- package/tests/binTests/init.test.js +189 -0
- package/tests/binTests/initHelpersTests/defaultConfig.test.js +12 -0
- package/tests/binTests/initHelpersTests/makeEnvTemplate.test.js +14 -0
- package/tests/binTests/initHelpersTests/migrateLegacyConfig.test.js +19 -0
- package/tests/binTests/initHelpersTests/models.test.js +21 -0
- package/tests/coreTests/redation.test.js +118 -0
- package/tests/coreTests/turnLoop.test.js +177 -0
- package/tests/llmTests/geminiProvider.test.js +191 -0
- package/tests/llmTests/llmFactory.test.js +78 -0
- package/tests/llmTests/modelResolver.test.js +119 -0
- package/tests/llmTests/openAIProvider.test.js +117 -0
- package/tests/toolsTests/tokenControl.test.js +150 -0
- package/tools/tokenControl.js +107 -17
- package/vitest.config.js +8 -0
- package/openAI/modelResolver.js +0 -33
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// tests/tokenControlTests/tokenControl.test.js
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// Hoist env so we can safely mutate per test
|
|
5
|
+
const ORIGINAL_ENV = { ...process.env };
|
|
6
|
+
|
|
7
|
+
// Mock wait so tests are instant
|
|
8
|
+
vi.mock('../../tools/turnLoopUtils.js', () => ({
|
|
9
|
+
wait: vi.fn(async () => {}),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
// Mock tiktoken in a controllable way
|
|
13
|
+
const encMock = {
|
|
14
|
+
encode: vi.fn((s) => Array.from(String(s)).map(() => 1)), // 1 token per char
|
|
15
|
+
free: vi.fn(() => {}),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const tiktokenMocks = vi.hoisted(() => ({
|
|
19
|
+
encoding_for_model_impl: vi.fn(),
|
|
20
|
+
get_encoding_impl: vi.fn(() => encMock),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock('@dqbd/tiktoken', () => ({
|
|
24
|
+
encoding_for_model: (model) => tiktokenMocks.encoding_for_model_impl(model),
|
|
25
|
+
get_encoding: (name) => tiktokenMocks.get_encoding_impl(name),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// Import after mocks
|
|
29
|
+
import {
|
|
30
|
+
tokenEstimate,
|
|
31
|
+
getCurrentTokenLimit,
|
|
32
|
+
updateLimitsFromHeaders,
|
|
33
|
+
tokenUseCoolOff,
|
|
34
|
+
recordTokenUsage,
|
|
35
|
+
pruneOldTokenUsage,
|
|
36
|
+
__resetTokenControlForTests,
|
|
37
|
+
} from '../../tools/tokenControl.js';
|
|
38
|
+
|
|
39
|
+
describe('tokenControl', () => {
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
// reset env and mocks
|
|
42
|
+
process.env = { ...ORIGINAL_ENV };
|
|
43
|
+
__resetTokenControlForTests();
|
|
44
|
+
tiktokenMocks.encoding_for_model_impl.mockReset();
|
|
45
|
+
tiktokenMocks.get_encoding_impl.mockReset().mockReturnValue(encMock);
|
|
46
|
+
encMock.encode.mockClear();
|
|
47
|
+
encMock.free.mockClear();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
process.env = { ...ORIGINAL_ENV };
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('tokenEstimate', () => {
|
|
55
|
+
it('uses encoding_for_model when available', async () => {
|
|
56
|
+
tiktokenMocks.encoding_for_model_impl.mockReturnValue(encMock);
|
|
57
|
+
|
|
58
|
+
const n = await tokenEstimate('gpt-4o', 'abcd'); // 4 chars → 4 tokens via mock
|
|
59
|
+
expect(n).toBe(4);
|
|
60
|
+
expect(encMock.encode).toHaveBeenCalledOnce();
|
|
61
|
+
expect(encMock.free).toHaveBeenCalledOnce();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('falls back to get_encoding when encoding_for_model throws', async () => {
|
|
65
|
+
tiktokenMocks.encoding_for_model_impl.mockImplementation(() => { throw new Error('no direct encoding'); });
|
|
66
|
+
tiktokenMocks.get_encoding_impl.mockReturnValue(encMock);
|
|
67
|
+
|
|
68
|
+
const n = await tokenEstimate('gemini-2.5-flash', 'abc'); // 3
|
|
69
|
+
expect(n).toBe(3);
|
|
70
|
+
expect(encMock.encode).toHaveBeenCalledOnce();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('falls back to bytes/4 when both tokenizers fail', async () => {
|
|
74
|
+
tiktokenMocks.encoding_for_model_impl.mockImplementation(() => { throw new Error('no direct encoding'); });
|
|
75
|
+
tiktokenMocks.get_encoding_impl.mockImplementation(() => { throw new Error('no base encoding'); });
|
|
76
|
+
|
|
77
|
+
const n = await tokenEstimate('unknown-model', 'abcdefgh'); // 8 bytes → 8/4 = 2
|
|
78
|
+
expect(n).toBe(2);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('stringifies non-string input', async () => {
|
|
82
|
+
tiktokenMocks.encoding_for_model_impl.mockReturnValue(encMock);
|
|
83
|
+
const n = await tokenEstimate('gpt-4o', { a: 1, b: 2 });
|
|
84
|
+
expect(encMock.encode).toHaveBeenCalled();
|
|
85
|
+
expect(typeof n).toBe('number');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('getCurrentTokenLimit / updateLimitsFromHeaders', () => {
|
|
90
|
+
it('uses defaults by model pattern', () => {
|
|
91
|
+
const openai = getCurrentTokenLimit('gpt-4o');
|
|
92
|
+
const gemini = getCurrentTokenLimit('gemini-2.5-flash');
|
|
93
|
+
expect(openai.tpm).toBeGreaterThan(0);
|
|
94
|
+
expect(gemini.tpm).toBeGreaterThan(0);
|
|
95
|
+
expect(['default', 'env', 'header']).toContain(openai.source);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('honors ENV override TESTRONAUT_TOKENS_PER_MIN', () => {
|
|
99
|
+
process.env.TESTRONAUT_TOKENS_PER_MIN = '999';
|
|
100
|
+
const anyModel = getCurrentTokenLimit('what-ever-model');
|
|
101
|
+
expect(anyModel.tpm).toBe(999);
|
|
102
|
+
expect(anyModel.source).toBe('env');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('updates limits from headers (header wins)', () => {
|
|
106
|
+
const before = getCurrentTokenLimit('gpt-4o');
|
|
107
|
+
updateLimitsFromHeaders('gpt-4o', { 'x-ratelimit-limit-tokens': '1234' });
|
|
108
|
+
const after = getCurrentTokenLimit('gpt-4o');
|
|
109
|
+
expect(after.tpm).toBe(1234);
|
|
110
|
+
expect(after.source).toBe('header');
|
|
111
|
+
// sanity check it actually changed (unless defaults already 1234)
|
|
112
|
+
if (before.tpm !== 1234) expect(after.tpm).not.toBe(before.tpm);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('rolling window + cooldown', () => {
|
|
117
|
+
it('prunes old entries outside the window', () => {
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
const entries = [
|
|
120
|
+
[now - 70000, 100], // old → pruned
|
|
121
|
+
[now - 10000, 50], // kept
|
|
122
|
+
[now - 5000, 30], // kept
|
|
123
|
+
];
|
|
124
|
+
const { turnTimestamps, totalTokensUsed } = pruneOldTokenUsage(entries, 60000);
|
|
125
|
+
expect(turnTimestamps.length).toBe(2);
|
|
126
|
+
expect(totalTokensUsed).toBe(80);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('triggers backoff when usage exceeds TPM', async () => {
|
|
130
|
+
// small ENV cap to force backoff
|
|
131
|
+
process.env.TESTRONAUT_TOKENS_PER_MIN = '50';
|
|
132
|
+
const entries = [];
|
|
133
|
+
recordTokenUsage(entries, 60);
|
|
134
|
+
|
|
135
|
+
const result = await tokenUseCoolOff(60, entries, 'any-model');
|
|
136
|
+
expect(result.shouldBackoff).toBe(true);
|
|
137
|
+
expect(result.totalTokensUsed).toBe(0);
|
|
138
|
+
expect(Array.isArray(result.turnTimestamps)).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('does not backoff when under TPM', async () => {
|
|
142
|
+
process.env.TESTRONAUT_TOKENS_PER_MIN = '100';
|
|
143
|
+
const entries = [];
|
|
144
|
+
recordTokenUsage(entries, 60);
|
|
145
|
+
const result = await tokenUseCoolOff(60, entries, 'any-model');
|
|
146
|
+
expect(result.shouldBackoff).toBe(false);
|
|
147
|
+
expect(result.totalTokensUsed).toBe(60);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
});
|
package/tools/tokenControl.js
CHANGED
|
@@ -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
|
-
//
|
|
13
|
-
{ test: /^gpt-5(-|$)/i, tpm:
|
|
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:
|
|
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
|
|
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
|
-
*
|
|
107
|
-
*
|
|
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
|
-
*
|
|
121
|
-
* Looks for
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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
|
+
}
|
package/vitest.config.js
ADDED
package/openAI/modelResolver.js
DELETED
|
@@ -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
|
-
|