Skip to content

Commit 8c4d60a

Browse files
test: fix tests related to static fn and default models value (#6)
1 parent a00bbbc commit 8c4d60a

13 files changed

+2389
-3059
lines changed

CircuitBreaker.js

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* Circuit Breaker implementation with configurable failure thresholds and cooldown periods
3+
*/
4+
class CircuitBreaker {
5+
static #instances = new Map(); // bucketId -> instance
6+
7+
constructor({
8+
failureThreshold = 5,
9+
cooldownPeriod = 30000,
10+
name = 'default'
11+
}) {
12+
this.failureThreshold = failureThreshold;
13+
this.cooldownPeriod = cooldownPeriod;
14+
this.name = name;
15+
16+
// State
17+
this.failCount = 0;
18+
this.isOpen = false;
19+
this.openedAt = null;
20+
this.lastFailureTime = null;
21+
}
22+
23+
/**
24+
* Get or create a circuit breaker instance for the given bucketId
25+
* @param {string} bucketId - The service identifier
26+
* @param {Object} config - Circuit breaker configuration
27+
* @returns {CircuitBreaker} - The circuit breaker instance
28+
*/
29+
static getInstance(bucketId, config) {
30+
if (!this.#instances.has(bucketId)) {
31+
this.#instances.set(bucketId, new CircuitBreaker({
32+
...config,
33+
name: `CircuitBreaker-${bucketId}`
34+
}));
35+
}
36+
return this.#instances.get(bucketId);
37+
}
38+
39+
/**
40+
* Clear a circuit breaker instance for the given bucketId
41+
* @param {string} bucketId - The service identifier
42+
*/
43+
static clear(bucketId) {
44+
this.#instances.delete(bucketId);
45+
}
46+
47+
/**
48+
* Check if the circuit breaker is open, reset if cooldown period has expired
49+
* @returns {boolean} - True if circuit is open, false if closed
50+
*/
51+
isCircuitOpen() {
52+
if (!this.isOpen) return false;
53+
54+
// Check if cooldown period has expired
55+
if (Date.now() - this.openedAt > this.cooldownPeriod) {
56+
this._reset();
57+
return false;
58+
}
59+
60+
return true;
61+
}
62+
63+
/**
64+
* Record a successful operation
65+
*/
66+
recordSuccess() {
67+
this._reset();
68+
}
69+
70+
/**
71+
* Record a failed operation
72+
*/
73+
recordFailure() {
74+
this.failCount++;
75+
this.lastFailureTime = Date.now();
76+
77+
if (this.failCount >= this.failureThreshold) {
78+
this._open();
79+
}
80+
}
81+
82+
/**
83+
* Get current circuit breaker status
84+
* @returns {Object} - Status information
85+
*/
86+
getStatus() {
87+
return {
88+
isOpen: this.isCircuitOpen(),
89+
failCount: this.failCount,
90+
failureThreshold: this.failureThreshold,
91+
cooldownRemaining: this.isOpen ?
92+
Math.max(0, this.cooldownPeriod - (Date.now() - this.openedAt)) : 0,
93+
lastFailureTime: this.lastFailureTime,
94+
name: this.name
95+
};
96+
}
97+
98+
/**
99+
* Manually open the circuit breaker
100+
*/
101+
forceOpen() {
102+
this._open();
103+
}
104+
105+
/**
106+
* Manually close the circuit breaker
107+
*/
108+
forceClose() {
109+
this._reset();
110+
}
111+
112+
/**
113+
* Reset circuit breaker to closed state
114+
* @private
115+
*/
116+
_reset() {
117+
this.isOpen = false;
118+
this.failCount = 0;
119+
this.openedAt = null;
120+
}
121+
122+
/**
123+
* Open the circuit breaker
124+
* @private
125+
*/
126+
_open() {
127+
this.isOpen = true;
128+
this.openedAt = Date.now();
129+
}
130+
}
131+
132+
export default CircuitBreaker;

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ResilientLLM
22

3-
A robust LLM integration layer designed to ensure reliable, seamless interactions across multiple APIs by intelligently handling failures and rate limits.
3+
A simple but robust LLM integration layer designed to ensure reliable, seamless interactions across multiple APIs by intelligently handling failures and rate limits.
44

55
## Motivation
66

@@ -34,7 +34,9 @@ const llm = new ResilientLLM({
3434
rateLimitConfig: {
3535
requestsPerMinute: 60, // Limit to 60 requests per minute
3636
llmTokensPerMinute: 90000 // Limit to 90,000 LLM tokens per minute
37-
}
37+
},
38+
retries: 3,
39+
backoffFactor: 2
3840
});
3941
4042
const conversationHistory = [

RateLimitManager.js

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import TokenBucket from './TokenBucket.js';
2+
import { sleep } from './Utility.js';
23

34
class RateLimitManager {
5+
static #instances = new Map(); // bucketId -> instance
6+
47
/**
58
* @param {Object} config
69
* @param {number} config.requestsPerMinute - Max requests per minute
@@ -12,15 +15,52 @@ class RateLimitManager {
1215
// llmTokenBucket: limits number of LLM text tokens per minute
1316
this.llmTokenBucket = new TokenBucket(llmTokensPerMinute, llmTokensPerMinute / 60); // refill per second
1417
}
18+
19+
/**
20+
* Get or create a rate limit manager instance for the given bucketId
21+
* @param {string} bucketId - The service identifier
22+
* @param {Object} config - Rate limit configuration
23+
* @returns {RateLimitManager} - The rate limit manager instance
24+
*/
25+
static getInstance(bucketId, config) {
26+
if (!this.#instances.has(bucketId)) {
27+
this.#instances.set(bucketId, new RateLimitManager(config));
28+
}
29+
return this.#instances.get(bucketId);
30+
}
31+
32+
/**
33+
* Clear a rate limit manager instance for the given bucketId
34+
* @param {string} bucketId - The service identifier
35+
*/
36+
static clear(bucketId) {
37+
this.#instances.delete(bucketId);
38+
}
1539

1640
/**
1741
* Attempt to acquire a request slot and the required number of LLM tokens.
1842
* Waits until both are available.
1943
* @param {number} llmTokenCount
2044
*/
21-
async acquire(llmTokenCount = 1) {
22-
while (!(this.requestBucket.tryRemoveToken() && this.llmTokenBucket.tryRemoveToken(llmTokenCount))) {
23-
await this._sleep(100);
45+
async acquire(llmTokenCount = 1, abortSignal) {
46+
// Check abort signal before entering loop
47+
if (abortSignal?.aborted) {
48+
const error = new Error(abortSignal.reason || 'Operation was aborted');
49+
error.name = 'AbortError';
50+
throw error;
51+
}
52+
53+
console.log('Awaiting rate limit...');
54+
while (!abortSignal?.aborted && !(this.requestBucket.tryRemoveToken() && this.llmTokenBucket.tryRemoveToken(llmTokenCount))) {
55+
await sleep(100, abortSignal);
56+
}
57+
console.log('Wait for rate limit complete...');
58+
59+
// Final check after loop - if aborted during sleep, throw error
60+
if (abortSignal?.aborted) {
61+
const error = new Error(abortSignal.reason || 'Operation was aborted');
62+
error.name = 'AbortError';
63+
throw error;
2464
}
2565
}
2666

@@ -55,11 +95,7 @@ class RateLimitManager {
5595
if (info.llmTokensPerMinute) {
5696
this.llmTokenBucket.update({ capacity: info.llmTokensPerMinute, refillRate: info.llmTokensPerMinute / 60 });
5797
}
58-
}
59-
60-
_sleep(ms) {
61-
return new Promise(resolve => setTimeout(resolve, ms));
62-
}
98+
}
6399
}
64100

65101
export default RateLimitManager;

ResilientLLM.js

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
* const llm = new LLM({ aiService: "anthropic", model: "claude-3-5-sonnet-20240620", maxTokens: 2048, temperature: 0 });
55
* const response = await llm.chat([{ role: "user", content: "Hello, world!" }]);
66
* console.log(response);
7+
* // You may cancel all llm operations (for the given instance) by calling abort() method on the ResilientLLM instance
8+
* llm.abort();
79
*/
810
import { Tiktoken } from "js-tiktoken/lite";
911
import o200k_base from "js-tiktoken/ranks/o200k_base";
@@ -12,10 +14,10 @@ import ResilientOperation from "./ResilientOperation.js";
1214
class ResilientLLM {
1315
static encoder;
1416
static DEFAULT_MODELS = {
15-
anthropic: "claude-3-5-sonnet-20240620",
17+
anthropic: "claude-3-5-sonnet-20240620",
1618
openai: "gpt-4o-mini",
1719
gemini: "gemini-2.0-flash",
18-
ollama: "openai"
20+
ollama: "llama3.1:8b"
1921
}
2022

2123
constructor(options) {
@@ -29,16 +31,11 @@ class ResilientLLM {
2931
this.topP = options?.topP || process.env.AI_TOP_P || 0.95;
3032
// Add rate limit config options if provided
3133
this.rateLimitConfig = options?.rateLimitConfig || { requestsPerMinute: 10, llmTokensPerMinute: 150000 };
32-
// Instantiate ResilientOperation for LLM calls
33-
this.resilientOperation = new ResilientOperation({
34-
bucketId: this.aiService,
35-
rateLimitConfig: this.rateLimitConfig,
36-
retries: options?.retries || 3,
37-
timeout: this.timeout,
38-
backoffFactor: options?.backoffFactor || 2,
39-
onRateLimitUpdate: options?.onRateLimitUpdate,
40-
cacheStore: this.cacheStore
41-
});
34+
this.retries = options?.retries || 3;
35+
this.backoffFactor = options?.backoffFactor || 2;
36+
this.onRateLimitUpdate = options?.onRateLimitUpdate;
37+
this._abortController = null;
38+
this.resilientOperations = {}; // Store resilient operation instances for observability
4239
}
4340

4441
getApiUrl(aiService) {
@@ -159,11 +156,25 @@ class ResilientLLM {
159156
throw new Error('Invalid provider specified. Use "anthropic" or "openai" or "gemini" or "ollama".');
160157
}
161158
try{
159+
// Instantiate ResilientOperation for LLM calls
160+
const resilientOperation = new ResilientOperation({
161+
bucketId: this.aiService,
162+
rateLimitConfig: this.rateLimitConfig,
163+
retries: this.retries,
164+
timeout: this.timeout,
165+
backoffFactor: this.backoffFactor,
166+
onRateLimitUpdate: this.onRateLimitUpdate,
167+
cacheStore: this.cacheStore
168+
});
169+
// Use single instance of abort controller for all operations
170+
this._abortController = this._abortController || new AbortController();
171+
this.resilientOperations[resilientOperation.id] = resilientOperation;
162172
// Wrap the LLM API call in ResilientOperation for rate limiting, retries, etc.
163-
const { data, statusCode } = await this.resilientOperation
173+
const { data, statusCode } = await resilientOperation
164174
.withTokens(estimatedLLMTokens)
165175
.withCache()
166-
.execute(this._makeHttpRequest, apiUrl, requestBody, headers);
176+
.withAbortControl(this._abortController)
177+
.execute(this._makeHttpRequest, apiUrl, requestBody, headers, this._abortController.signal);
167178
/**
168179
* OpenAI chat completion response
169180
* {
@@ -223,6 +234,7 @@ class ResilientLLM {
223234
content = this.parseOllamaChatCompletion(data, llmOptions?.tools);
224235
break;
225236
}
237+
delete this.resilientOperations[resilientOperation.id];
226238
return content;
227239
} catch (error) {
228240
console.error(`Error calling ${aiService} API:`, error);
@@ -256,6 +268,8 @@ class ResilientLLM {
256268
* @returns {Promise<{data: any, statusCode: number}>}
257269
*/
258270
async _makeHttpRequest(apiUrl, requestBody, headers, abortSignal) {
271+
console.log("Making HTTP request to:", apiUrl);
272+
console.log("You may cancel it by calling abort() method on the ResilientLLM instance");
259273
const startTime = Date.now();
260274

261275
try {
@@ -291,7 +305,8 @@ class ResilientLLM {
291305

292306
/**
293307
* Parse errors from various LLM APIs to create uniform error communication
294-
* @param {*} error
308+
* @param {number|null} statusCode - HTTP status code or null for general errors
309+
* @param {Error|Object|null} error - Error object
295310
* @reference https://platform.openai.com/docs/guides/error-codes/api-error-codes
296311
* @reference https://docs.anthropic.com/en/api/errors
297312
*/
@@ -305,8 +320,6 @@ class ResilientLLM {
305320
throw new Error(error?.message || "Invalid API Key");
306321
case 403:
307322
throw new Error(error?.message || "You are not authorized to access this resource");
308-
case 400:
309-
throw new Error(error?.message || "Bad request");
310323
case 429:
311324
throw new Error(error?.message || "Rate limit exceeded");
312325
case 404:
@@ -380,7 +393,12 @@ class ResilientLLM {
380393
return data?.choices?.[0]?.message?.content;
381394
}
382395

383-
396+
abort(){
397+
this._abortController?.abort();
398+
this._abortController = null;
399+
this.resilientOperations = {};
400+
this._abortController = null;
401+
}
384402

385403
/**
386404
* Estimate the number of tokens in a text

0 commit comments

Comments
 (0)