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 +46 -0
- package/bin/init.js +54 -11
- package/core/turnLoop.js +14 -5
- package/openAI/modelResolver.js +33 -0
- package/package.json +1 -1
- package/tools/generateHtmlReport.js +3 -1
- package/tools/tokenControl.js +156 -29
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
|
-
//
|
|
21
|
-
const
|
|
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
|
|
32
|
+
{ title: 'OpenAI (GPT family)', value: 'openai' },
|
|
27
33
|
// Add others like Anthropic, Mistral, etc. later
|
|
28
34
|
]
|
|
29
35
|
});
|
|
30
36
|
|
|
31
|
-
config.
|
|
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 (
|
|
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
|
|
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
|
|
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(
|
|
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:
|
|
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(
|
|
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
|
@@ -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">
|
package/tools/tokenControl.js
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
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
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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];
|
|
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); //
|
|
185
|
+
return Math.max(msUntilSafe, 1000); // at least 1s
|
|
58
186
|
}
|
|
59
187
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
188
|
+
return 0;
|
|
189
|
+
};
|