tokenfirewall 1.0.2 → 2.0.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 +219 -474
- package/dist/core/pricingRegistry.js +56 -5
- package/dist/index.d.ts +32 -0
- package/dist/index.js +66 -12
- package/dist/interceptors/fetchInterceptor.d.ts +5 -0
- package/dist/interceptors/fetchInterceptor.js +278 -27
- package/dist/introspection/contextRegistry.d.ts +5 -0
- package/dist/introspection/contextRegistry.js +58 -6
- package/dist/logger.d.ts +5 -0
- package/dist/logger.js +10 -0
- package/dist/router/errorDetector.d.ts +45 -0
- package/dist/router/errorDetector.js +170 -0
- package/dist/router/modelRouter.d.ts +33 -0
- package/dist/router/modelRouter.js +111 -0
- package/dist/router/routingStrategies.d.ts +16 -0
- package/dist/router/routingStrategies.js +243 -0
- package/dist/router/types.d.ts +65 -0
- package/dist/router/types.js +5 -0
- package/package.json +1 -1
|
@@ -13,16 +13,64 @@ class PricingRegistry {
|
|
|
13
13
|
* Initialize default pricing for supported providers
|
|
14
14
|
*/
|
|
15
15
|
initializeDefaultPricing() {
|
|
16
|
+
// =========================
|
|
16
17
|
// OpenAI pricing (per 1M tokens)
|
|
18
|
+
// =========================
|
|
19
|
+
// ===== GPT-5 (Latest Flagship) =====
|
|
20
|
+
this.register("openai", "gpt-5", { input: 5.0, output: 15.0 });
|
|
21
|
+
this.register("openai", "gpt-5-mini", { input: 1.5, output: 5.0 });
|
|
22
|
+
// ===== GPT-4.1 Series =====
|
|
23
|
+
this.register("openai", "gpt-4.1", { input: 3.0, output: 12.0 });
|
|
24
|
+
this.register("openai", "gpt-4.1-mini", { input: 0.8, output: 3.0 });
|
|
25
|
+
// ===== GPT-4o (Balanced Multimodal) =====
|
|
17
26
|
this.register("openai", "gpt-4o", { input: 2.5, output: 10.0 });
|
|
18
27
|
this.register("openai", "gpt-4o-mini", { input: 0.15, output: 0.6 });
|
|
28
|
+
// ===== Reasoning Models =====
|
|
29
|
+
this.register("openai", "o1", { input: 6.0, output: 18.0 });
|
|
30
|
+
this.register("openai", "o1-mini", { input: 2.0, output: 6.0 });
|
|
31
|
+
// ===== Image Generation =====
|
|
32
|
+
this.register("openai", "gpt-image-1", { input: 5.0, output: 0.0 });
|
|
33
|
+
// ===== Legacy Models =====
|
|
19
34
|
this.register("openai", "gpt-4-turbo", { input: 10.0, output: 30.0 });
|
|
20
35
|
this.register("openai", "gpt-3.5-turbo", { input: 0.5, output: 1.5 });
|
|
36
|
+
// =========================
|
|
21
37
|
// Anthropic pricing (per 1M tokens)
|
|
38
|
+
// =========================
|
|
39
|
+
// ===== Claude 4.5 (Newer Improved) =====
|
|
40
|
+
this.register("anthropic", "claude-opus-4.5", { input: 17.0, output: 85.0 });
|
|
41
|
+
this.register("anthropic", "claude-sonnet-4.5", { input: 4.0, output: 20.0 });
|
|
42
|
+
this.register("anthropic", "claude-haiku-4.5", { input: 1.2, output: 6.0 });
|
|
43
|
+
// ===== Classic Claude 4 =====
|
|
44
|
+
this.register("anthropic", "claude-4-opus", { input: 15.0, output: 75.0 });
|
|
45
|
+
this.register("anthropic", "claude-sonnet-4", { input: 3.0, output: 15.0 });
|
|
46
|
+
this.register("anthropic", "claude-haiku-4", { input: 1.0, output: 5.0 });
|
|
47
|
+
// ===== Stable Claude 3.5 Fallback =====
|
|
48
|
+
this.register("anthropic", "claude-3-5-sonnet-latest", { input: 3.0, output: 15.0 });
|
|
49
|
+
this.register("anthropic", "claude-3-5-haiku-latest", { input: 0.8, output: 4.0 });
|
|
50
|
+
// ===== Legacy Models =====
|
|
22
51
|
this.register("anthropic", "claude-3-5-sonnet-20241022", { input: 3.0, output: 15.0 });
|
|
23
52
|
this.register("anthropic", "claude-3-5-haiku-20241022", { input: 0.8, output: 4.0 });
|
|
24
53
|
this.register("anthropic", "claude-3-opus-20240229", { input: 15.0, output: 75.0 });
|
|
54
|
+
// =========================
|
|
25
55
|
// Google Gemini pricing (per 1M tokens)
|
|
56
|
+
// =========================
|
|
57
|
+
// ===== Gemini 3 (Latest Generation) =====
|
|
58
|
+
this.register("gemini", "gemini-3-pro", { input: 3.5, output: 14.0 });
|
|
59
|
+
this.register("gemini", "gemini-3.1-pro", { input: 4.0, output: 16.0 });
|
|
60
|
+
this.register("gemini", "gemini-3-flash", { input: 0.35, output: 1.5 });
|
|
61
|
+
this.register("gemini", "gemini-3-flash-lite", { input: 0.15, output: 0.6 });
|
|
62
|
+
// ===== Image Models (Nano Banana Series) =====
|
|
63
|
+
this.register("gemini", "gemini-3-pro-image", { input: 5.0, output: 0.0 });
|
|
64
|
+
this.register("gemini", "gemini-3.1-flash-image", { input: 0.75, output: 0.0 });
|
|
65
|
+
// ===== Gemini 2.5 (Stable Production Tier) =====
|
|
66
|
+
this.register("gemini", "gemini-2.5-pro", { input: 2.5, output: 10.0 });
|
|
67
|
+
this.register("gemini", "gemini-2.5-flash", { input: 0.30, output: 1.2 });
|
|
68
|
+
this.register("gemini", "gemini-2.5-flash-lite", { input: 0.10, output: 0.4 });
|
|
69
|
+
// Image-capable 2.5
|
|
70
|
+
this.register("gemini", "gemini-2.5-flash-image", { input: 0.5, output: 0.0 });
|
|
71
|
+
// ===== Ultra-light / Experimental =====
|
|
72
|
+
this.register("gemini", "gemini-nano-banana", { input: 0.05, output: 0.2 });
|
|
73
|
+
// ===== Legacy Models =====
|
|
26
74
|
this.register("gemini", "gemini-2.0-flash-exp", { input: 0.0, output: 0.0 });
|
|
27
75
|
this.register("gemini", "gemini-1.5-pro", { input: 1.25, output: 5.0 });
|
|
28
76
|
this.register("gemini", "gemini-1.5-flash", { input: 0.075, output: 0.3 });
|
|
@@ -45,17 +93,19 @@ class PricingRegistry {
|
|
|
45
93
|
* Register pricing for a provider and model
|
|
46
94
|
*/
|
|
47
95
|
register(provider, model, pricing) {
|
|
48
|
-
|
|
49
|
-
|
|
96
|
+
const normalizedProvider = provider.toLowerCase();
|
|
97
|
+
if (!this.pricing.has(normalizedProvider)) {
|
|
98
|
+
this.pricing.set(normalizedProvider, new Map());
|
|
50
99
|
}
|
|
51
|
-
this.pricing.get(
|
|
100
|
+
this.pricing.get(normalizedProvider).set(model, pricing);
|
|
52
101
|
}
|
|
53
102
|
/**
|
|
54
103
|
* Get pricing for a specific provider and model
|
|
55
104
|
* @throws Error if pricing not found
|
|
56
105
|
*/
|
|
57
106
|
getPricing(provider, model) {
|
|
58
|
-
const
|
|
107
|
+
const normalizedProvider = provider.toLowerCase();
|
|
108
|
+
const providerPricing = this.pricing.get(normalizedProvider);
|
|
59
109
|
if (!providerPricing) {
|
|
60
110
|
throw new Error(`TokenFirewall: No pricing found for provider "${provider}"`);
|
|
61
111
|
}
|
|
@@ -69,7 +119,8 @@ class PricingRegistry {
|
|
|
69
119
|
* Check if pricing exists for a provider and model
|
|
70
120
|
*/
|
|
71
121
|
hasPricing(provider, model) {
|
|
72
|
-
|
|
122
|
+
const normalizedProvider = provider.toLowerCase();
|
|
123
|
+
return this.pricing.get(normalizedProvider)?.has(model) ?? false;
|
|
73
124
|
}
|
|
74
125
|
}
|
|
75
126
|
exports.pricingRegistry = new PricingRegistry();
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { BudgetManager } from "./core/budgetManager";
|
|
|
3
3
|
import { patchGlobalFetch } from "./interceptors/fetchInterceptor";
|
|
4
4
|
import { patchProvider } from "./interceptors/sdkInterceptor";
|
|
5
5
|
import { listAvailableModels, ModelInfo, ListModelsOptions } from "./introspection/modelLister";
|
|
6
|
+
import { ModelRouter } from "./router/modelRouter";
|
|
7
|
+
import { ModelRouterOptions } from "./router/types";
|
|
6
8
|
/**
|
|
7
9
|
* Create and configure a budget guard
|
|
8
10
|
* @param options - Budget configuration options
|
|
@@ -37,6 +39,17 @@ export declare function registerPricing(provider: string, model: string, pricing
|
|
|
37
39
|
* @param contextLimit - Context window size in tokens
|
|
38
40
|
*/
|
|
39
41
|
export declare function registerContextLimit(provider: string, model: string, contextLimit: number): void;
|
|
42
|
+
/**
|
|
43
|
+
* Register multiple models for a provider at once
|
|
44
|
+
* Useful for dynamic model discovery or custom provider setup
|
|
45
|
+
* @param provider - Provider name
|
|
46
|
+
* @param models - Array of model configurations
|
|
47
|
+
*/
|
|
48
|
+
export declare function registerModels(provider: string, models: Array<{
|
|
49
|
+
name: string;
|
|
50
|
+
contextLimit?: number;
|
|
51
|
+
pricing?: ModelPricing;
|
|
52
|
+
}>): void;
|
|
40
53
|
/**
|
|
41
54
|
* Get current budget status
|
|
42
55
|
* @returns Budget status or null if no budget guard is active
|
|
@@ -60,6 +73,16 @@ export declare function exportBudgetState(): {
|
|
|
60
73
|
export declare function importBudgetState(state: {
|
|
61
74
|
totalSpent: number;
|
|
62
75
|
}): void;
|
|
76
|
+
/**
|
|
77
|
+
* Create and configure a model router for automatic retries and fallbacks
|
|
78
|
+
* @param options - Router configuration options
|
|
79
|
+
* @returns Model router instance
|
|
80
|
+
*/
|
|
81
|
+
export declare function createModelRouter(options: ModelRouterOptions): ModelRouter;
|
|
82
|
+
/**
|
|
83
|
+
* Disable model router
|
|
84
|
+
*/
|
|
85
|
+
export declare function disableModelRouter(): void;
|
|
63
86
|
/**
|
|
64
87
|
* List available models for a provider with context limits and budget usage
|
|
65
88
|
* @param options - Provider configuration and options
|
|
@@ -69,3 +92,12 @@ export declare function listModels(options: Omit<ListModelsOptions, 'budgetManag
|
|
|
69
92
|
export { listAvailableModels };
|
|
70
93
|
export type { BudgetGuardOptions, ProviderAdapter, ModelPricing, NormalizedUsage, CostBreakdown, BudgetStatus, ModelInfo, ListModelsOptions, } from "./core/types";
|
|
71
94
|
export type { ModelInfo as ModelInfoType, ListModelsOptions as ListModelsOptionsType } from "./introspection/modelLister";
|
|
95
|
+
export type { ModelRouterOptions, RoutingStrategy, FailureType, FailureContext, RoutingDecision, RouterEvent } from "./router/types";
|
|
96
|
+
/**
|
|
97
|
+
* Model configuration for bulk registration
|
|
98
|
+
*/
|
|
99
|
+
export interface ModelConfig {
|
|
100
|
+
name: string;
|
|
101
|
+
contextLimit?: number;
|
|
102
|
+
pricing?: ModelPricing;
|
|
103
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -5,10 +5,13 @@ exports.createBudgetGuard = createBudgetGuard;
|
|
|
5
5
|
exports.registerAdapter = registerAdapter;
|
|
6
6
|
exports.registerPricing = registerPricing;
|
|
7
7
|
exports.registerContextLimit = registerContextLimit;
|
|
8
|
+
exports.registerModels = registerModels;
|
|
8
9
|
exports.getBudgetStatus = getBudgetStatus;
|
|
9
10
|
exports.resetBudget = resetBudget;
|
|
10
11
|
exports.exportBudgetState = exportBudgetState;
|
|
11
12
|
exports.importBudgetState = importBudgetState;
|
|
13
|
+
exports.createModelRouter = createModelRouter;
|
|
14
|
+
exports.disableModelRouter = disableModelRouter;
|
|
12
15
|
exports.listModels = listModels;
|
|
13
16
|
const budgetManager_1 = require("./core/budgetManager");
|
|
14
17
|
const pricingRegistry_1 = require("./core/pricingRegistry");
|
|
@@ -20,7 +23,9 @@ Object.defineProperty(exports, "patchProvider", { enumerable: true, get: functio
|
|
|
20
23
|
const modelLister_1 = require("./introspection/modelLister");
|
|
21
24
|
Object.defineProperty(exports, "listAvailableModels", { enumerable: true, get: function () { return modelLister_1.listAvailableModels; } });
|
|
22
25
|
const contextRegistry_1 = require("./introspection/contextRegistry");
|
|
26
|
+
const modelRouter_1 = require("./router/modelRouter");
|
|
23
27
|
let globalBudgetManager = null;
|
|
28
|
+
let globalModelRouter = null;
|
|
24
29
|
/**
|
|
25
30
|
* Create and configure a budget guard
|
|
26
31
|
* @param options - Budget configuration options
|
|
@@ -51,22 +56,22 @@ function registerAdapter(adapter) {
|
|
|
51
56
|
*/
|
|
52
57
|
function registerPricing(provider, model, pricing) {
|
|
53
58
|
// Validate inputs
|
|
54
|
-
if (!provider || typeof provider !== 'string') {
|
|
59
|
+
if (!provider || typeof provider !== 'string' || provider.trim() === '') {
|
|
55
60
|
throw new Error('TokenFirewall: Provider must be a non-empty string');
|
|
56
61
|
}
|
|
57
|
-
if (!model || typeof model !== 'string') {
|
|
62
|
+
if (!model || typeof model !== 'string' || model.trim() === '') {
|
|
58
63
|
throw new Error('TokenFirewall: Model must be a non-empty string');
|
|
59
64
|
}
|
|
60
65
|
if (!pricing || typeof pricing !== 'object') {
|
|
61
66
|
throw new Error('TokenFirewall: Pricing must be an object');
|
|
62
67
|
}
|
|
63
|
-
if (typeof pricing.input !== 'number' || pricing.input < 0 || !isFinite(pricing.input)) {
|
|
64
|
-
throw new Error('TokenFirewall: Pricing input must be a non-negative number');
|
|
68
|
+
if (typeof pricing.input !== 'number' || pricing.input < 0 || !isFinite(pricing.input) || isNaN(pricing.input)) {
|
|
69
|
+
throw new Error('TokenFirewall: Pricing input must be a non-negative finite number');
|
|
65
70
|
}
|
|
66
|
-
if (typeof pricing.output !== 'number' || pricing.output < 0 || !isFinite(pricing.output)) {
|
|
67
|
-
throw new Error('TokenFirewall: Pricing output must be a non-negative number');
|
|
71
|
+
if (typeof pricing.output !== 'number' || pricing.output < 0 || !isFinite(pricing.output) || isNaN(pricing.output)) {
|
|
72
|
+
throw new Error('TokenFirewall: Pricing output must be a non-negative finite number');
|
|
68
73
|
}
|
|
69
|
-
pricingRegistry_1.pricingRegistry.register(provider, model, pricing);
|
|
74
|
+
pricingRegistry_1.pricingRegistry.register(provider.toLowerCase(), model, pricing);
|
|
70
75
|
}
|
|
71
76
|
/**
|
|
72
77
|
* Register custom context limit for a provider and model
|
|
@@ -76,16 +81,47 @@ function registerPricing(provider, model, pricing) {
|
|
|
76
81
|
*/
|
|
77
82
|
function registerContextLimit(provider, model, contextLimit) {
|
|
78
83
|
// Validate inputs
|
|
79
|
-
if (!provider || typeof provider !== 'string') {
|
|
84
|
+
if (!provider || typeof provider !== 'string' || provider.trim() === '') {
|
|
80
85
|
throw new Error('TokenFirewall: Provider must be a non-empty string');
|
|
81
86
|
}
|
|
82
|
-
if (!model || typeof model !== 'string') {
|
|
87
|
+
if (!model || typeof model !== 'string' || model.trim() === '') {
|
|
83
88
|
throw new Error('TokenFirewall: Model must be a non-empty string');
|
|
84
89
|
}
|
|
85
|
-
if (typeof contextLimit !== 'number' || contextLimit <= 0 || !isFinite(contextLimit)) {
|
|
86
|
-
throw new Error('TokenFirewall: Context limit must be a positive number');
|
|
90
|
+
if (typeof contextLimit !== 'number' || contextLimit <= 0 || !isFinite(contextLimit) || isNaN(contextLimit)) {
|
|
91
|
+
throw new Error('TokenFirewall: Context limit must be a positive finite number');
|
|
92
|
+
}
|
|
93
|
+
contextRegistry_1.contextRegistry.register(provider.toLowerCase(), model, { tokens: contextLimit });
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Register multiple models for a provider at once
|
|
97
|
+
* Useful for dynamic model discovery or custom provider setup
|
|
98
|
+
* @param provider - Provider name
|
|
99
|
+
* @param models - Array of model configurations
|
|
100
|
+
*/
|
|
101
|
+
function registerModels(provider, models) {
|
|
102
|
+
// Validate inputs
|
|
103
|
+
if (!provider || typeof provider !== 'string' || provider.trim() === '') {
|
|
104
|
+
throw new Error('TokenFirewall: Provider must be a non-empty string');
|
|
105
|
+
}
|
|
106
|
+
if (!Array.isArray(models) || models.length === 0) {
|
|
107
|
+
throw new Error('TokenFirewall: Models must be a non-empty array');
|
|
108
|
+
}
|
|
109
|
+
// Normalize provider name to lowercase
|
|
110
|
+
const normalizedProvider = provider.toLowerCase();
|
|
111
|
+
// Register each model
|
|
112
|
+
for (const model of models) {
|
|
113
|
+
if (!model.name || typeof model.name !== 'string' || model.name.trim() === '') {
|
|
114
|
+
throw new Error('TokenFirewall: Each model must have a valid name');
|
|
115
|
+
}
|
|
116
|
+
// Register context limit if provided
|
|
117
|
+
if (model.contextLimit !== undefined) {
|
|
118
|
+
registerContextLimit(normalizedProvider, model.name, model.contextLimit);
|
|
119
|
+
}
|
|
120
|
+
// Register pricing if provided
|
|
121
|
+
if (model.pricing !== undefined) {
|
|
122
|
+
registerPricing(normalizedProvider, model.name, model.pricing);
|
|
123
|
+
}
|
|
87
124
|
}
|
|
88
|
-
contextRegistry_1.contextRegistry.register(provider, model, { tokens: contextLimit });
|
|
89
125
|
}
|
|
90
126
|
/**
|
|
91
127
|
* Get current budget status
|
|
@@ -120,6 +156,24 @@ function importBudgetState(state) {
|
|
|
120
156
|
}
|
|
121
157
|
globalBudgetManager.importState(state);
|
|
122
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Create and configure a model router for automatic retries and fallbacks
|
|
161
|
+
* @param options - Router configuration options
|
|
162
|
+
* @returns Model router instance
|
|
163
|
+
*/
|
|
164
|
+
function createModelRouter(options) {
|
|
165
|
+
const router = new modelRouter_1.ModelRouter(options);
|
|
166
|
+
globalModelRouter = router;
|
|
167
|
+
(0, fetchInterceptor_1.setModelRouter)(router);
|
|
168
|
+
return router;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Disable model router
|
|
172
|
+
*/
|
|
173
|
+
function disableModelRouter() {
|
|
174
|
+
globalModelRouter = null;
|
|
175
|
+
(0, fetchInterceptor_1.setModelRouter)(null);
|
|
176
|
+
}
|
|
123
177
|
/**
|
|
124
178
|
* List available models for a provider with context limits and budget usage
|
|
125
179
|
* @param options - Provider configuration and options
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { BudgetManager } from "../core/budgetManager";
|
|
2
|
+
import { ModelRouter } from "../router/modelRouter";
|
|
2
3
|
/**
|
|
3
4
|
* Set the budget manager for fetch interception
|
|
4
5
|
*/
|
|
5
6
|
export declare function setBudgetManager(manager: BudgetManager | null): void;
|
|
7
|
+
/**
|
|
8
|
+
* Set the model router for fetch interception
|
|
9
|
+
*/
|
|
10
|
+
export declare function setModelRouter(router: ModelRouter | null): void;
|
|
6
11
|
/**
|
|
7
12
|
* Patch global fetch to intercept LLM API calls
|
|
8
13
|
*/
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.setBudgetManager = setBudgetManager;
|
|
4
|
+
exports.setModelRouter = setModelRouter;
|
|
4
5
|
exports.patchGlobalFetch = patchGlobalFetch;
|
|
5
6
|
exports.unpatchGlobalFetch = unpatchGlobalFetch;
|
|
6
7
|
const registry_1 = require("../registry");
|
|
@@ -8,6 +9,7 @@ const costEngine_1 = require("../core/costEngine");
|
|
|
8
9
|
const logger_1 = require("../logger");
|
|
9
10
|
let isPatched = false;
|
|
10
11
|
let budgetManager = null;
|
|
12
|
+
let modelRouter = null;
|
|
11
13
|
const originalFetch = globalThis.fetch;
|
|
12
14
|
/**
|
|
13
15
|
* Set the budget manager for fetch interception
|
|
@@ -15,6 +17,12 @@ const originalFetch = globalThis.fetch;
|
|
|
15
17
|
function setBudgetManager(manager) {
|
|
16
18
|
budgetManager = manager;
|
|
17
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Set the model router for fetch interception
|
|
22
|
+
*/
|
|
23
|
+
function setModelRouter(router) {
|
|
24
|
+
modelRouter = router;
|
|
25
|
+
}
|
|
18
26
|
/**
|
|
19
27
|
* Patch global fetch to intercept LLM API calls
|
|
20
28
|
*/
|
|
@@ -23,44 +31,287 @@ function patchGlobalFetch() {
|
|
|
23
31
|
return;
|
|
24
32
|
}
|
|
25
33
|
const interceptedFetch = async function (input, init) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
34
|
+
// If router is enabled, wrap request with retry logic
|
|
35
|
+
if (modelRouter) {
|
|
36
|
+
return await fetchWithRetry(input, init);
|
|
37
|
+
}
|
|
38
|
+
// Otherwise, use standard fetch with tracking
|
|
39
|
+
return await standardFetch(input, init);
|
|
40
|
+
};
|
|
41
|
+
globalThis.fetch = interceptedFetch;
|
|
42
|
+
isPatched = true;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Standard fetch with cost tracking (no retry)
|
|
46
|
+
*/
|
|
47
|
+
async function standardFetch(input, init) {
|
|
48
|
+
const response = await originalFetch(input, init);
|
|
49
|
+
// Try to clone response for tracking (may fail for some responses)
|
|
50
|
+
let clonedResponse;
|
|
51
|
+
try {
|
|
52
|
+
clonedResponse = response.clone();
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
// If cloning fails, just return original response without tracking
|
|
56
|
+
console.warn('TokenFirewall: Failed to clone response for tracking');
|
|
57
|
+
return response;
|
|
58
|
+
}
|
|
59
|
+
// Process response and track budget BEFORE returning
|
|
60
|
+
try {
|
|
61
|
+
const responseData = await clonedResponse.json();
|
|
62
|
+
// Try to process with adapter registry
|
|
63
|
+
const normalizedUsage = registry_1.adapterRegistry.process(responseData);
|
|
64
|
+
if (normalizedUsage) {
|
|
65
|
+
// Calculate cost
|
|
66
|
+
const cost = (0, costEngine_1.calculateCost)(normalizedUsage);
|
|
67
|
+
// Track budget if manager exists - MUST await to enforce blocking
|
|
68
|
+
if (budgetManager) {
|
|
69
|
+
await budgetManager.track(cost.totalCost);
|
|
70
|
+
}
|
|
71
|
+
// Log usage
|
|
72
|
+
logger_1.logger.logUsage(normalizedUsage, cost);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
// If it's a budget error, re-throw it
|
|
77
|
+
if (error instanceof Error && error.message.includes('TokenFirewall: Budget exceeded')) {
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
// Otherwise, not JSON or not an LLM response - ignore silently
|
|
81
|
+
}
|
|
82
|
+
return response;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Fetch with automatic retry and model switching
|
|
86
|
+
*/
|
|
87
|
+
async function fetchWithRetry(input, init) {
|
|
88
|
+
// Check if streaming is enabled - router doesn't support streaming yet
|
|
89
|
+
if (init?.body) {
|
|
29
90
|
try {
|
|
30
|
-
|
|
91
|
+
const body = JSON.parse(init.body);
|
|
92
|
+
if (body.stream === true) {
|
|
93
|
+
console.warn('TokenFirewall Router: Streaming requests are not supported by the router. ' +
|
|
94
|
+
'Falling back to standard fetch without retry logic.');
|
|
95
|
+
return await standardFetch(input, init);
|
|
96
|
+
}
|
|
31
97
|
}
|
|
32
|
-
catch
|
|
33
|
-
//
|
|
34
|
-
console.warn('TokenFirewall: Failed to clone response for tracking');
|
|
35
|
-
return response;
|
|
98
|
+
catch {
|
|
99
|
+
// Body is not JSON, continue normally
|
|
36
100
|
}
|
|
37
|
-
|
|
101
|
+
}
|
|
102
|
+
const attemptedModels = [];
|
|
103
|
+
let retryCount = 0;
|
|
104
|
+
let lastError;
|
|
105
|
+
let currentInit = init;
|
|
106
|
+
let currentInput = input;
|
|
107
|
+
// Extract original model and provider from request
|
|
108
|
+
const { originalModel, provider } = extractModelInfo(input, init);
|
|
109
|
+
if (originalModel) {
|
|
110
|
+
attemptedModels.push(originalModel);
|
|
111
|
+
}
|
|
112
|
+
while (retryCount <= (modelRouter?.getMaxRetries() || 0)) {
|
|
38
113
|
try {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
await
|
|
114
|
+
// Make the request
|
|
115
|
+
const response = await standardFetch(currentInput, currentInit);
|
|
116
|
+
// Check if response indicates an error
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
// Clone and read error response safely
|
|
119
|
+
let errorData = {};
|
|
120
|
+
try {
|
|
121
|
+
const clonedResponse = response.clone();
|
|
122
|
+
errorData = await clonedResponse.json();
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// Response is not JSON or already consumed
|
|
48
126
|
}
|
|
49
|
-
//
|
|
50
|
-
|
|
127
|
+
// Throw proper Error instance with structured data
|
|
128
|
+
const errorObj = {
|
|
129
|
+
status: response.status,
|
|
130
|
+
response: { data: errorData }
|
|
131
|
+
};
|
|
132
|
+
throw new Error(JSON.stringify(errorObj));
|
|
51
133
|
}
|
|
134
|
+
return response;
|
|
52
135
|
}
|
|
53
136
|
catch (error) {
|
|
54
|
-
|
|
55
|
-
|
|
137
|
+
lastError = error;
|
|
138
|
+
// If no router or no model info, throw immediately
|
|
139
|
+
if (!modelRouter || !originalModel || !provider) {
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
// Parse error if it's a JSON string
|
|
143
|
+
let parsedError = error;
|
|
144
|
+
if (error instanceof Error && error.message.startsWith('{')) {
|
|
145
|
+
try {
|
|
146
|
+
parsedError = JSON.parse(error.message);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// Keep original error
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Get routing decision
|
|
153
|
+
const decision = modelRouter.handleFailure({
|
|
154
|
+
error: parsedError,
|
|
155
|
+
originalModel,
|
|
156
|
+
requestBody: currentInit?.body ? (() => {
|
|
157
|
+
try {
|
|
158
|
+
return JSON.parse(currentInit.body);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return {};
|
|
162
|
+
}
|
|
163
|
+
})() : {},
|
|
164
|
+
provider,
|
|
165
|
+
retryCount,
|
|
166
|
+
attemptedModels
|
|
167
|
+
});
|
|
168
|
+
// If no retry, throw the error
|
|
169
|
+
if (!decision.retry || !decision.nextModel) {
|
|
56
170
|
throw error;
|
|
57
171
|
}
|
|
58
|
-
//
|
|
172
|
+
// Log the routing event
|
|
173
|
+
logger_1.logger.logRouterEvent({
|
|
174
|
+
originalModel,
|
|
175
|
+
nextModel: decision.nextModel,
|
|
176
|
+
reason: decision.reason,
|
|
177
|
+
attempt: retryCount + 1,
|
|
178
|
+
maxRetries: modelRouter.getMaxRetries()
|
|
179
|
+
});
|
|
180
|
+
// Update request with new model
|
|
181
|
+
attemptedModels.push(decision.nextModel);
|
|
182
|
+
const updated = updateRequestModel(currentInput, currentInit, decision.nextModel, provider);
|
|
183
|
+
currentInput = updated.input;
|
|
184
|
+
currentInit = updated.init;
|
|
185
|
+
retryCount++;
|
|
59
186
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
187
|
+
}
|
|
188
|
+
// Max retries exceeded
|
|
189
|
+
throw new Error(`TokenFirewall: Max routing retries exceeded. Last error: ${lastError}`);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Extract model and provider information from request
|
|
193
|
+
*/
|
|
194
|
+
function extractModelInfo(input, init) {
|
|
195
|
+
try {
|
|
196
|
+
// Parse URL to detect provider
|
|
197
|
+
const url = typeof input === 'string' ? input : (input instanceof Request ? input.url : String(input));
|
|
198
|
+
let provider = null;
|
|
199
|
+
let model = null;
|
|
200
|
+
if (url.includes('api.openai.com')) {
|
|
201
|
+
provider = 'openai';
|
|
202
|
+
}
|
|
203
|
+
else if (url.includes('api.anthropic.com')) {
|
|
204
|
+
provider = 'anthropic';
|
|
205
|
+
}
|
|
206
|
+
else if (url.includes('generativelanguage.googleapis.com')) {
|
|
207
|
+
provider = 'gemini';
|
|
208
|
+
// Gemini model is in URL: /models/{model}:generateContent
|
|
209
|
+
const match = url.match(/\/models\/([^:]+):/);
|
|
210
|
+
if (match) {
|
|
211
|
+
model = match[1];
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
else if (url.includes('api.x.ai')) {
|
|
215
|
+
provider = 'grok';
|
|
216
|
+
}
|
|
217
|
+
else if (url.includes('api.moonshot.cn')) {
|
|
218
|
+
provider = 'kimi';
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
// Unknown provider - try to extract from URL hostname
|
|
222
|
+
try {
|
|
223
|
+
const urlObj = new URL(url);
|
|
224
|
+
const hostname = urlObj.hostname;
|
|
225
|
+
// Use first part of hostname as provider (e.g., api.example.com -> example)
|
|
226
|
+
const parts = hostname.split('.');
|
|
227
|
+
if (parts.length >= 2) {
|
|
228
|
+
provider = parts[parts.length - 2]; // Get domain name
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
provider = 'unknown';
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Extract model from request body (for non-Gemini providers)
|
|
236
|
+
if (!model) {
|
|
237
|
+
let body = null;
|
|
238
|
+
// Get body from init or Request object
|
|
239
|
+
if (init?.body) {
|
|
240
|
+
body = typeof init.body === 'string' ? init.body : null;
|
|
241
|
+
}
|
|
242
|
+
else if (input instanceof Request && input.body) {
|
|
243
|
+
// Note: Request.body is a ReadableStream, we can't read it here without consuming it
|
|
244
|
+
// So we skip body parsing for Request objects without explicit init.body
|
|
245
|
+
body = null;
|
|
246
|
+
}
|
|
247
|
+
if (body) {
|
|
248
|
+
try {
|
|
249
|
+
const bodyObj = JSON.parse(body);
|
|
250
|
+
model = bodyObj.model || null;
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// Body is not JSON or doesn't have model field
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return { originalModel: model, provider };
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return { originalModel: null, provider: null };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Update request with new model (handles both URL and body)
|
|
265
|
+
*/
|
|
266
|
+
function updateRequestModel(input, init, newModel, provider) {
|
|
267
|
+
let newInput = input;
|
|
268
|
+
let newInit = init;
|
|
269
|
+
// Get the URL string
|
|
270
|
+
const url = typeof input === 'string' ? input : (input instanceof Request ? input.url : String(input));
|
|
271
|
+
// Update URL for Gemini (model is in URL path)
|
|
272
|
+
if (provider === 'gemini') {
|
|
273
|
+
const newUrl = url.replace(/\/models\/[^:]+:/, `/models/${newModel}:`);
|
|
274
|
+
// If input was a Request object, we need to create a new Request with updated URL
|
|
275
|
+
if (input instanceof Request) {
|
|
276
|
+
// Clone the request with new URL
|
|
277
|
+
newInput = new Request(newUrl, {
|
|
278
|
+
method: input.method,
|
|
279
|
+
headers: input.headers,
|
|
280
|
+
body: init?.body || null,
|
|
281
|
+
mode: input.mode,
|
|
282
|
+
credentials: input.credentials,
|
|
283
|
+
cache: input.cache,
|
|
284
|
+
redirect: input.redirect,
|
|
285
|
+
referrer: input.referrer,
|
|
286
|
+
integrity: input.integrity
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
newInput = newUrl;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// Update body for other providers (model is in request body)
|
|
294
|
+
if (init?.body) {
|
|
295
|
+
try {
|
|
296
|
+
const body = JSON.parse(init.body);
|
|
297
|
+
body.model = newModel;
|
|
298
|
+
newInit = {
|
|
299
|
+
...init,
|
|
300
|
+
body: JSON.stringify(body)
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
// Body is not JSON, log warning
|
|
305
|
+
console.warn(`TokenFirewall Router: Cannot update model in non-JSON request body. ` +
|
|
306
|
+
`Model switching may not work correctly.`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
else if (input instanceof Request && provider !== 'gemini') {
|
|
310
|
+
// Request object without explicit init.body - we can't modify it
|
|
311
|
+
console.warn(`TokenFirewall Router: Cannot update model in Request object without explicit body. ` +
|
|
312
|
+
`Model switching may not work correctly.`);
|
|
313
|
+
}
|
|
314
|
+
return { input: newInput, init: newInit };
|
|
64
315
|
}
|
|
65
316
|
/**
|
|
66
317
|
* Restore original fetch
|
|
@@ -25,6 +25,11 @@ declare class ContextRegistry {
|
|
|
25
25
|
* Check if provider is supported
|
|
26
26
|
*/
|
|
27
27
|
isProviderSupported(provider: string): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Get all models for a provider
|
|
30
|
+
* Returns array of model names
|
|
31
|
+
*/
|
|
32
|
+
getModelsForProvider(provider: string): string[];
|
|
28
33
|
}
|
|
29
34
|
export declare const contextRegistry: ContextRegistry;
|
|
30
35
|
export {};
|