@soleri/forge 5.1.3 → 5.4.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/dist/index.js +0 -0
- package/dist/scaffolder.js +150 -2
- package/dist/scaffolder.js.map +1 -1
- package/dist/templates/brain.d.ts +6 -0
- package/dist/templates/brain.js +478 -0
- package/dist/templates/brain.js.map +1 -0
- package/dist/templates/claude-md-template.js +90 -1
- package/dist/templates/claude-md-template.js.map +1 -1
- package/dist/templates/core-facade.d.ts +6 -0
- package/dist/templates/core-facade.js +564 -0
- package/dist/templates/core-facade.js.map +1 -0
- package/dist/templates/domain-facade.d.ts +4 -0
- package/dist/templates/domain-facade.js +4 -0
- package/dist/templates/domain-facade.js.map +1 -1
- package/dist/templates/entry-point.js +32 -0
- package/dist/templates/entry-point.js.map +1 -1
- package/dist/templates/facade-factory.d.ts +1 -0
- package/dist/templates/facade-factory.js +63 -0
- package/dist/templates/facade-factory.js.map +1 -0
- package/dist/templates/facade-types.d.ts +1 -0
- package/dist/templates/facade-types.js +46 -0
- package/dist/templates/facade-types.js.map +1 -0
- package/dist/templates/intelligence-loader.d.ts +1 -0
- package/dist/templates/intelligence-loader.js +43 -0
- package/dist/templates/intelligence-loader.js.map +1 -0
- package/dist/templates/intelligence-types.d.ts +1 -0
- package/dist/templates/intelligence-types.js +24 -0
- package/dist/templates/intelligence-types.js.map +1 -0
- package/dist/templates/llm-client.d.ts +7 -0
- package/dist/templates/llm-client.js +300 -0
- package/dist/templates/llm-client.js.map +1 -0
- package/dist/templates/llm-key-pool.d.ts +7 -0
- package/dist/templates/llm-key-pool.js +211 -0
- package/dist/templates/llm-key-pool.js.map +1 -0
- package/dist/templates/llm-types.d.ts +5 -0
- package/dist/templates/llm-types.js +161 -0
- package/dist/templates/llm-types.js.map +1 -0
- package/dist/templates/llm-utils.d.ts +5 -0
- package/dist/templates/llm-utils.js +260 -0
- package/dist/templates/llm-utils.js.map +1 -0
- package/dist/templates/planner.d.ts +5 -0
- package/dist/templates/planner.js +150 -0
- package/dist/templates/planner.js.map +1 -0
- package/dist/templates/setup-script.js +26 -1
- package/dist/templates/setup-script.js.map +1 -1
- package/dist/templates/test-brain.d.ts +6 -0
- package/dist/templates/test-brain.js +474 -0
- package/dist/templates/test-brain.js.map +1 -0
- package/dist/templates/test-facades.js +173 -3
- package/dist/templates/test-facades.js.map +1 -1
- package/dist/templates/test-llm.d.ts +7 -0
- package/dist/templates/test-llm.js +574 -0
- package/dist/templates/test-llm.js.map +1 -0
- package/dist/templates/test-loader.d.ts +5 -0
- package/dist/templates/test-loader.js +146 -0
- package/dist/templates/test-loader.js.map +1 -0
- package/dist/templates/test-planner.d.ts +5 -0
- package/dist/templates/test-planner.js +271 -0
- package/dist/templates/test-planner.js.map +1 -0
- package/dist/templates/test-vault.d.ts +5 -0
- package/dist/templates/test-vault.js +380 -0
- package/dist/templates/test-vault.js.map +1 -0
- package/dist/templates/vault.d.ts +5 -0
- package/dist/templates/vault.js +263 -0
- package/dist/templates/vault.js.map +1 -0
- package/dist/types.d.ts +4 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/scaffolder.test.ts +2 -2
- package/src/scaffolder.ts +153 -2
- package/src/templates/claude-md-template.ts +181 -0
- package/src/templates/domain-facade.ts +4 -0
- package/src/templates/entry-point.ts +32 -0
- package/src/templates/setup-script.ts +28 -1
- package/src/templates/test-facades.ts +173 -3
- package/src/types.ts +2 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate the LLM key pool file for a new agent.
|
|
3
|
+
* KeyPool manages multiple API keys with per-key circuit breakers.
|
|
4
|
+
* Uses config.id to resolve ~/.{agentId}/keys.json.
|
|
5
|
+
*/
|
|
6
|
+
export function generateLLMKeyPool(config) {
|
|
7
|
+
return `/**
|
|
8
|
+
* Key Pool — Multi-key management with auto-rotation
|
|
9
|
+
* Generated by Soleri — do not edit manually.
|
|
10
|
+
*
|
|
11
|
+
* Key loading priority:
|
|
12
|
+
* 1. ~/.${config.id}/keys.json → { "openai": ["sk-..."], "anthropic": ["sk-ant-..."] }
|
|
13
|
+
* 2. Fallback: OPENAI_API_KEY / ANTHROPIC_API_KEY env vars
|
|
14
|
+
* 3. Empty pool → graceful degradation
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as fs from 'node:fs';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { SecretString } from './types.js';
|
|
21
|
+
import type { KeyPoolConfig, KeyStatus } from './types.js';
|
|
22
|
+
import { CircuitBreaker } from './utils.js';
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// CONSTANTS
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
const DEFAULT_PREEMPTIVE_THRESHOLD = 50;
|
|
29
|
+
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// KEY POOL
|
|
32
|
+
// =============================================================================
|
|
33
|
+
|
|
34
|
+
class RateLimitRotationError extends Error {
|
|
35
|
+
retryable = true;
|
|
36
|
+
constructor() {
|
|
37
|
+
super('Rate limit rotation');
|
|
38
|
+
this.name = 'RateLimitRotationError';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class KeyPool {
|
|
43
|
+
private keys: SecretString[];
|
|
44
|
+
private activeIndex: number = 0;
|
|
45
|
+
private keyBreakers: Map<number, CircuitBreaker> = new Map();
|
|
46
|
+
private remainingQuota: Map<number, number> = new Map();
|
|
47
|
+
private readonly preemptiveThreshold: number;
|
|
48
|
+
|
|
49
|
+
constructor(config: KeyPoolConfig) {
|
|
50
|
+
this.keys = config.keys
|
|
51
|
+
.filter((k) => k.length > 0)
|
|
52
|
+
.map((k) => new SecretString(k));
|
|
53
|
+
this.preemptiveThreshold =
|
|
54
|
+
config.preemptiveThreshold ?? DEFAULT_PREEMPTIVE_THRESHOLD;
|
|
55
|
+
|
|
56
|
+
for (let i = 0; i < this.keys.length; i++) {
|
|
57
|
+
this.keyBreakers.set(
|
|
58
|
+
i,
|
|
59
|
+
new CircuitBreaker({
|
|
60
|
+
name: \`key-pool-\${i}\`,
|
|
61
|
+
failureThreshold: 3,
|
|
62
|
+
resetTimeoutMs: 30_000,
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (this.keys.length > 1) {
|
|
68
|
+
console.error(\`[LLM] KeyPool initialized with \${this.keys.length} keys\`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get hasKeys(): boolean {
|
|
73
|
+
return this.keys.length > 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
getActiveKey(): SecretString {
|
|
77
|
+
return this.keys[this.activeIndex];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get activeKeyIndex(): number {
|
|
81
|
+
return this.activeIndex;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get poolSize(): number {
|
|
85
|
+
return this.keys.length;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
get exhausted(): boolean {
|
|
89
|
+
if (this.keys.length === 0) return true;
|
|
90
|
+
for (let i = 0; i < this.keys.length; i++) {
|
|
91
|
+
const breaker = this.keyBreakers.get(i)!;
|
|
92
|
+
if (!breaker.isOpen()) return false;
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
rotateOnError(): SecretString | null {
|
|
98
|
+
const breaker = this.keyBreakers.get(this.activeIndex);
|
|
99
|
+
if (breaker) {
|
|
100
|
+
breaker.recordFailure();
|
|
101
|
+
}
|
|
102
|
+
return this.findNextHealthyKey();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
rotatePreemptive(): boolean {
|
|
106
|
+
const previousIndex = this.activeIndex;
|
|
107
|
+
const remaining = this.remainingQuota.get(previousIndex);
|
|
108
|
+
if (remaining !== undefined && remaining < this.preemptiveThreshold) {
|
|
109
|
+
const next = this.findNextHealthyKey();
|
|
110
|
+
if (next) {
|
|
111
|
+
console.error(
|
|
112
|
+
\`[LLM] KeyPool preemptive rotation: key \${previousIndex} remaining=\${remaining} < threshold=\${this.preemptiveThreshold}\`,
|
|
113
|
+
);
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
updateQuota(keyIndex: number, remaining: number): void {
|
|
121
|
+
this.remainingQuota.set(keyIndex, remaining);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
getStatus(): {
|
|
125
|
+
poolSize: number;
|
|
126
|
+
activeKeyIndex: number;
|
|
127
|
+
exhausted: boolean;
|
|
128
|
+
perKeyStatus: KeyStatus[];
|
|
129
|
+
} {
|
|
130
|
+
const perKeyStatus: KeyStatus[] = [];
|
|
131
|
+
for (let i = 0; i < this.keys.length; i++) {
|
|
132
|
+
perKeyStatus.push({
|
|
133
|
+
index: i,
|
|
134
|
+
circuitState: this.keyBreakers.get(i)!.getState(),
|
|
135
|
+
remainingQuota: this.remainingQuota.get(i) ?? null,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
poolSize: this.poolSize,
|
|
140
|
+
activeKeyIndex: this.activeIndex,
|
|
141
|
+
exhausted: this.exhausted,
|
|
142
|
+
perKeyStatus,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private findNextHealthyKey(): SecretString | null {
|
|
147
|
+
const startIndex = this.activeIndex;
|
|
148
|
+
for (let offset = 1; offset <= this.keys.length; offset++) {
|
|
149
|
+
const candidateIndex = (startIndex + offset) % this.keys.length;
|
|
150
|
+
const breaker = this.keyBreakers.get(candidateIndex)!;
|
|
151
|
+
if (!breaker.isOpen()) {
|
|
152
|
+
this.activeIndex = candidateIndex;
|
|
153
|
+
return this.keys[candidateIndex];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
console.error('[LLM] KeyPool: all keys exhausted — no healthy key available');
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// =============================================================================
|
|
162
|
+
// KEY LOADING
|
|
163
|
+
// =============================================================================
|
|
164
|
+
|
|
165
|
+
export interface KeyPoolFiles {
|
|
166
|
+
openai: KeyPoolConfig;
|
|
167
|
+
anthropic: KeyPoolConfig;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function loadKeyPoolConfig(): KeyPoolFiles {
|
|
171
|
+
const keysFilePath = path.join(
|
|
172
|
+
homedir(),
|
|
173
|
+
'.${config.id}',
|
|
174
|
+
'keys.json',
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
let openaiKeys: string[] = [];
|
|
178
|
+
let anthropicKeys: string[] = [];
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
if (fs.existsSync(keysFilePath)) {
|
|
182
|
+
const data = JSON.parse(fs.readFileSync(keysFilePath, 'utf-8'));
|
|
183
|
+
if (data?.openai && Array.isArray(data.openai)) {
|
|
184
|
+
openaiKeys = data.openai;
|
|
185
|
+
}
|
|
186
|
+
if (data?.anthropic && Array.isArray(data.anthropic)) {
|
|
187
|
+
anthropicKeys = data.anthropic;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
console.error('[LLM] Could not read keys.json, falling back to env vars');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Fallback: environment variables
|
|
195
|
+
if (openaiKeys.length === 0) {
|
|
196
|
+
const envKey = process.env.OPENAI_API_KEY || '';
|
|
197
|
+
if (envKey) openaiKeys = [envKey];
|
|
198
|
+
}
|
|
199
|
+
if (anthropicKeys.length === 0) {
|
|
200
|
+
const envKey = process.env.ANTHROPIC_API_KEY || '';
|
|
201
|
+
if (envKey) anthropicKeys = [envKey];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
openai: { keys: openaiKeys },
|
|
206
|
+
anthropic: { keys: anthropicKeys },
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
`;
|
|
210
|
+
}
|
|
211
|
+
//# sourceMappingURL=llm-key-pool.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"llm-key-pool.js","sourceRoot":"","sources":["../../src/templates/llm-key-pool.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,MAAM,UAAU,kBAAkB,CAAC,MAAmB;IACpD,OAAO;;;;;WAKE,MAAM,CAAC,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;QAiKZ,MAAM,CAAC,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoChB,CAAC;AACF,CAAC"}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate the LLM types file for a new agent.
|
|
3
|
+
* Contains all types, LLMError class, and SecretString class.
|
|
4
|
+
*/
|
|
5
|
+
export function generateLLMTypes() {
|
|
6
|
+
return `/**
|
|
7
|
+
* LLM Module Types
|
|
8
|
+
* Generated by Soleri — do not edit manually.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// SECRET STRING
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
const REDACTED = '[REDACTED]';
|
|
16
|
+
|
|
17
|
+
export class SecretString {
|
|
18
|
+
#value: string;
|
|
19
|
+
|
|
20
|
+
constructor(value: string) {
|
|
21
|
+
this.#value = value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
expose(): string {
|
|
25
|
+
return this.#value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get isSet(): boolean {
|
|
29
|
+
return this.#value.length > 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
toString(): string {
|
|
33
|
+
return REDACTED;
|
|
34
|
+
}
|
|
35
|
+
toJSON(): string {
|
|
36
|
+
return REDACTED;
|
|
37
|
+
}
|
|
38
|
+
[Symbol.toPrimitive](): string {
|
|
39
|
+
return REDACTED;
|
|
40
|
+
}
|
|
41
|
+
[Symbol.for('nodejs.util.inspect.custom')](): string {
|
|
42
|
+
return REDACTED;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// =============================================================================
|
|
47
|
+
// LLM ERROR
|
|
48
|
+
// =============================================================================
|
|
49
|
+
|
|
50
|
+
export class LLMError extends Error {
|
|
51
|
+
retryable: boolean;
|
|
52
|
+
statusCode?: number;
|
|
53
|
+
|
|
54
|
+
constructor(message: string, options?: { retryable?: boolean; statusCode?: number }) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = 'LLMError';
|
|
57
|
+
this.retryable = options?.retryable ?? false;
|
|
58
|
+
this.statusCode = options?.statusCode;
|
|
59
|
+
Object.setPrototypeOf(this, LLMError.prototype);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// =============================================================================
|
|
64
|
+
// LLM CALL TYPES
|
|
65
|
+
// =============================================================================
|
|
66
|
+
|
|
67
|
+
export interface LLMCallOptions {
|
|
68
|
+
provider: 'openai' | 'anthropic';
|
|
69
|
+
model: string;
|
|
70
|
+
systemPrompt: string;
|
|
71
|
+
userPrompt: string;
|
|
72
|
+
temperature?: number;
|
|
73
|
+
maxTokens?: number;
|
|
74
|
+
caller: string;
|
|
75
|
+
task?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface LLMCallResult {
|
|
79
|
+
text: string;
|
|
80
|
+
model: string;
|
|
81
|
+
provider: 'openai' | 'anthropic';
|
|
82
|
+
inputTokens?: number;
|
|
83
|
+
outputTokens?: number;
|
|
84
|
+
durationMs: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// =============================================================================
|
|
88
|
+
// CIRCUIT BREAKER TYPES
|
|
89
|
+
// =============================================================================
|
|
90
|
+
|
|
91
|
+
export type CircuitState = 'closed' | 'open' | 'half-open';
|
|
92
|
+
|
|
93
|
+
export interface CircuitBreakerConfig {
|
|
94
|
+
failureThreshold: number;
|
|
95
|
+
resetTimeoutMs: number;
|
|
96
|
+
name: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface CircuitBreakerSnapshot {
|
|
100
|
+
state: CircuitState;
|
|
101
|
+
failureCount: number;
|
|
102
|
+
lastFailureAt: number | null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// =============================================================================
|
|
106
|
+
// KEY POOL TYPES
|
|
107
|
+
// =============================================================================
|
|
108
|
+
|
|
109
|
+
export interface KeyPoolConfig {
|
|
110
|
+
keys: string[];
|
|
111
|
+
preemptiveThreshold?: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface KeyStatus {
|
|
115
|
+
index: number;
|
|
116
|
+
circuitState: CircuitBreakerSnapshot;
|
|
117
|
+
remainingQuota: number | null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// =============================================================================
|
|
121
|
+
// ROUTING TYPES
|
|
122
|
+
// =============================================================================
|
|
123
|
+
|
|
124
|
+
export interface RouteEntry {
|
|
125
|
+
caller: string;
|
|
126
|
+
task?: string;
|
|
127
|
+
model: string;
|
|
128
|
+
provider: 'openai' | 'anthropic';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface RoutingConfig {
|
|
132
|
+
routes: RouteEntry[];
|
|
133
|
+
defaultOpenAIModel: string;
|
|
134
|
+
defaultAnthropicModel: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// =============================================================================
|
|
138
|
+
// RATE LIMIT TYPES
|
|
139
|
+
// =============================================================================
|
|
140
|
+
|
|
141
|
+
export interface RateLimitInfo {
|
|
142
|
+
remaining: number | null;
|
|
143
|
+
resetMs: number | null;
|
|
144
|
+
retryAfterMs: number | null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// =============================================================================
|
|
148
|
+
// RETRY TYPES
|
|
149
|
+
// =============================================================================
|
|
150
|
+
|
|
151
|
+
export interface RetryConfig {
|
|
152
|
+
maxAttempts: number;
|
|
153
|
+
baseDelayMs: number;
|
|
154
|
+
maxDelayMs: number;
|
|
155
|
+
jitter: number;
|
|
156
|
+
shouldRetry?: (error: unknown) => boolean;
|
|
157
|
+
onRetry?: (error: unknown, attempt: number, delayMs: number) => void;
|
|
158
|
+
}
|
|
159
|
+
`;
|
|
160
|
+
}
|
|
161
|
+
//# sourceMappingURL=llm-types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"llm-types.js","sourceRoot":"","sources":["../../src/templates/llm-types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,UAAU,gBAAgB;IAC9B,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyJR,CAAC;AACF,CAAC"}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate the LLM utils file for a new agent.
|
|
3
|
+
* Contains CircuitBreaker, retry with backoff+jitter, and rate limit header parser.
|
|
4
|
+
*/
|
|
5
|
+
export function generateLLMUtils() {
|
|
6
|
+
return `/**
|
|
7
|
+
* LLM Utilities — Circuit Breaker, Retry, Rate Limit Parser
|
|
8
|
+
* Generated by Soleri — do not edit manually.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
CircuitBreakerConfig,
|
|
13
|
+
CircuitBreakerSnapshot,
|
|
14
|
+
CircuitState,
|
|
15
|
+
LLMError,
|
|
16
|
+
RateLimitInfo,
|
|
17
|
+
RetryConfig,
|
|
18
|
+
} from './types.js';
|
|
19
|
+
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// HELPERS
|
|
22
|
+
// =============================================================================
|
|
23
|
+
|
|
24
|
+
function isRetryable(error: unknown): boolean {
|
|
25
|
+
if (error && typeof error === 'object' && 'retryable' in error) {
|
|
26
|
+
return (error as { retryable: boolean }).retryable === true;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sleep(ms: number): Promise<void> {
|
|
32
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// =============================================================================
|
|
36
|
+
// CIRCUIT BREAKER
|
|
37
|
+
// =============================================================================
|
|
38
|
+
|
|
39
|
+
const DEFAULT_CB_CONFIG: CircuitBreakerConfig = {
|
|
40
|
+
failureThreshold: 5,
|
|
41
|
+
resetTimeoutMs: 60_000,
|
|
42
|
+
name: 'default',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export class CircuitOpenError extends Error {
|
|
46
|
+
retryable = false;
|
|
47
|
+
constructor(name: string) {
|
|
48
|
+
super(\`\${name} circuit breaker is open — calls are being rejected\`);
|
|
49
|
+
this.name = 'CircuitOpenError';
|
|
50
|
+
Object.setPrototypeOf(this, CircuitOpenError.prototype);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class CircuitBreaker {
|
|
55
|
+
private state: CircuitState = 'closed';
|
|
56
|
+
private failureCount = 0;
|
|
57
|
+
private lastFailureAt: number | null = null;
|
|
58
|
+
private readonly config: CircuitBreakerConfig;
|
|
59
|
+
|
|
60
|
+
constructor(config?: Partial<CircuitBreakerConfig>) {
|
|
61
|
+
this.config = { ...DEFAULT_CB_CONFIG, ...config };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async call<T>(fn: () => Promise<T>): Promise<T> {
|
|
65
|
+
if (this.state === 'open') {
|
|
66
|
+
if (this.shouldProbe()) {
|
|
67
|
+
this.transitionTo('half-open');
|
|
68
|
+
} else {
|
|
69
|
+
throw new CircuitOpenError(this.config.name);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const result = await fn();
|
|
75
|
+
this.onSuccess();
|
|
76
|
+
return result;
|
|
77
|
+
} catch (error) {
|
|
78
|
+
this.onFailure(error);
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
getState(): CircuitBreakerSnapshot {
|
|
84
|
+
if (this.state === 'open' && this.shouldProbe()) {
|
|
85
|
+
this.transitionTo('half-open');
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
state: this.state,
|
|
89
|
+
failureCount: this.failureCount,
|
|
90
|
+
lastFailureAt: this.lastFailureAt,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
isOpen(): boolean {
|
|
95
|
+
return this.state === 'open' && !this.shouldProbe();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
reset(): void {
|
|
99
|
+
this.transitionTo('closed');
|
|
100
|
+
this.failureCount = 0;
|
|
101
|
+
this.lastFailureAt = null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Synchronously record a failure (used by KeyPool to trip breaker without async) */
|
|
105
|
+
recordFailure(): void {
|
|
106
|
+
this.failureCount++;
|
|
107
|
+
this.lastFailureAt = Date.now();
|
|
108
|
+
if (this.state === 'half-open') {
|
|
109
|
+
this.transitionTo('open');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (this.failureCount >= this.config.failureThreshold) {
|
|
113
|
+
console.error(\`[LLM] CircuitBreaker:\${this.config.name} threshold reached (\${this.failureCount}/\${this.config.failureThreshold}) — opening\`);
|
|
114
|
+
this.transitionTo('open');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private onSuccess(): void {
|
|
119
|
+
this.failureCount = 0;
|
|
120
|
+
this.transitionTo('closed');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private onFailure(error: unknown): void {
|
|
124
|
+
if (!isRetryable(error)) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
this.failureCount++;
|
|
129
|
+
this.lastFailureAt = Date.now();
|
|
130
|
+
|
|
131
|
+
if (this.state === 'half-open') {
|
|
132
|
+
this.transitionTo('open');
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (this.failureCount >= this.config.failureThreshold) {
|
|
137
|
+
console.error(\`[LLM] CircuitBreaker:\${this.config.name} threshold reached (\${this.failureCount}/\${this.config.failureThreshold}) — opening\`);
|
|
138
|
+
this.transitionTo('open');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private shouldProbe(): boolean {
|
|
143
|
+
if (this.lastFailureAt === null) return false;
|
|
144
|
+
return Date.now() - this.lastFailureAt >= this.config.resetTimeoutMs;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private transitionTo(newState: CircuitState): void {
|
|
148
|
+
if (this.state !== newState) {
|
|
149
|
+
this.state = newState;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// =============================================================================
|
|
155
|
+
// RETRY
|
|
156
|
+
// =============================================================================
|
|
157
|
+
|
|
158
|
+
const DEFAULT_RETRY_CONFIG: RetryConfig = {
|
|
159
|
+
maxAttempts: 3,
|
|
160
|
+
baseDelayMs: 1000,
|
|
161
|
+
maxDelayMs: 30000,
|
|
162
|
+
jitter: 0.1,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
export function computeDelay(
|
|
166
|
+
error: unknown,
|
|
167
|
+
attempt: number,
|
|
168
|
+
config: RetryConfig,
|
|
169
|
+
): number {
|
|
170
|
+
const retryAfterMs = (error as Partial<LLMError> & { retryAfterMs?: number })?.retryAfterMs;
|
|
171
|
+
if (retryAfterMs !== undefined && retryAfterMs > 0) {
|
|
172
|
+
return Math.min(retryAfterMs, config.maxDelayMs);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const exponential = config.baseDelayMs * Math.pow(2, attempt);
|
|
176
|
+
const capped = Math.min(exponential, config.maxDelayMs);
|
|
177
|
+
|
|
178
|
+
const jitterRange = capped * config.jitter;
|
|
179
|
+
const jitterOffset = (Math.random() * 2 - 1) * jitterRange;
|
|
180
|
+
|
|
181
|
+
return Math.max(0, Math.round(capped + jitterOffset));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function retry<T>(
|
|
185
|
+
fn: () => Promise<T>,
|
|
186
|
+
config?: Partial<RetryConfig>,
|
|
187
|
+
): Promise<T> {
|
|
188
|
+
const resolved: RetryConfig = { ...DEFAULT_RETRY_CONFIG, ...config };
|
|
189
|
+
const shouldRetry = resolved.shouldRetry ?? isRetryable;
|
|
190
|
+
|
|
191
|
+
let lastError: unknown;
|
|
192
|
+
|
|
193
|
+
for (let attempt = 0; attempt < resolved.maxAttempts; attempt++) {
|
|
194
|
+
try {
|
|
195
|
+
return await fn();
|
|
196
|
+
} catch (error) {
|
|
197
|
+
lastError = error;
|
|
198
|
+
|
|
199
|
+
if (attempt >= resolved.maxAttempts - 1 || !shouldRetry(error)) {
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const delay = computeDelay(error, attempt, resolved);
|
|
204
|
+
resolved.onRetry?.(error, attempt + 1, delay);
|
|
205
|
+
|
|
206
|
+
await sleep(delay);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
throw lastError;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// =============================================================================
|
|
214
|
+
// RATE LIMIT HEADER PARSER
|
|
215
|
+
// =============================================================================
|
|
216
|
+
|
|
217
|
+
export function parseRateLimitHeaders(headers: Headers): RateLimitInfo {
|
|
218
|
+
const remaining = headers.get('x-ratelimit-remaining-requests');
|
|
219
|
+
const reset = headers.get('x-ratelimit-reset-requests');
|
|
220
|
+
const retryAfter = headers.get('retry-after');
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
remaining: remaining !== null ? parseInt(remaining, 10) : null,
|
|
224
|
+
resetMs: reset !== null ? parseResetDuration(reset) : null,
|
|
225
|
+
retryAfterMs: retryAfter !== null ? parseRetryAfter(retryAfter) : null,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function parseResetDuration(value: string): number | null {
|
|
230
|
+
let totalMs = 0;
|
|
231
|
+
let matched = false;
|
|
232
|
+
|
|
233
|
+
const minMatch = value.match(/(\\d+)m(?!\\s*s)/);
|
|
234
|
+
if (minMatch) {
|
|
235
|
+
totalMs += parseInt(minMatch[1], 10) * 60_000;
|
|
236
|
+
matched = true;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const msMatch = value.match(/(\\d+)ms/);
|
|
240
|
+
if (msMatch) {
|
|
241
|
+
totalMs += parseInt(msMatch[1], 10);
|
|
242
|
+
matched = true;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const secMatch = value.match(/(\\d+)(?<!m)s/);
|
|
246
|
+
if (secMatch) {
|
|
247
|
+
totalMs += parseInt(secMatch[1], 10) * 1000;
|
|
248
|
+
matched = true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return matched ? totalMs : null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function parseRetryAfter(value: string): number | null {
|
|
255
|
+
const seconds = parseFloat(value);
|
|
256
|
+
return isNaN(seconds) ? null : Math.ceil(seconds * 1000);
|
|
257
|
+
}
|
|
258
|
+
`;
|
|
259
|
+
}
|
|
260
|
+
//# sourceMappingURL=llm-utils.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"llm-utils.js","sourceRoot":"","sources":["../../src/templates/llm-utils.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,UAAU,gBAAgB;IAC9B,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4PR,CAAC;AACF,CAAC"}
|