@omnicross/core 0.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/LICENSE +21 -0
- package/NOTICE +57 -0
- package/README.md +15 -0
- package/dist/ApiKeyPoolService-BmMkau07.d.cts +170 -0
- package/dist/ApiKeyPoolService-BmMkau07.d.ts +170 -0
- package/dist/ProviderProxy-f_8ziIhW.d.cts +120 -0
- package/dist/ProviderProxy-vjt8sQQk.d.ts +120 -0
- package/dist/SubscriptionAuthSource-Cr4fVEYY.d.cts +264 -0
- package/dist/SubscriptionAuthSource-D89zmiSS.d.ts +264 -0
- package/dist/auth/GeminiCodeAssistProjectResolver.cjs +218 -0
- package/dist/auth/GeminiCodeAssistProjectResolver.d.cts +68 -0
- package/dist/auth/GeminiCodeAssistProjectResolver.d.ts +68 -0
- package/dist/auth/GeminiCodeAssistProjectResolver.js +189 -0
- package/dist/completion/ApiKeyPoolService.cjs +331 -0
- package/dist/completion/ApiKeyPoolService.d.cts +2 -0
- package/dist/completion/ApiKeyPoolService.d.ts +2 -0
- package/dist/completion/ApiKeyPoolService.js +306 -0
- package/dist/completion.cjs +4027 -0
- package/dist/completion.d.cts +17 -0
- package/dist/completion.d.ts +17 -0
- package/dist/completion.js +3983 -0
- package/dist/index-BTSmc9Sm.d.ts +645 -0
- package/dist/index-DXazdTzZ.d.cts +645 -0
- package/dist/index.cjs +10428 -0
- package/dist/index.d.cts +128 -0
- package/dist/index.d.ts +128 -0
- package/dist/index.js +10339 -0
- package/dist/outbound-api/subscriptionRegistryPort.cjs +38 -0
- package/dist/outbound-api/subscriptionRegistryPort.d.cts +73 -0
- package/dist/outbound-api/subscriptionRegistryPort.d.ts +73 -0
- package/dist/outbound-api/subscriptionRegistryPort.js +12 -0
- package/dist/outbound-api.cjs +5264 -0
- package/dist/outbound-api.d.cts +320 -0
- package/dist/outbound-api.d.ts +320 -0
- package/dist/outbound-api.js +5218 -0
- package/dist/pipeline/SubscriptionAuthSource.cjs +131 -0
- package/dist/pipeline/SubscriptionAuthSource.d.cts +3 -0
- package/dist/pipeline/SubscriptionAuthSource.d.ts +3 -0
- package/dist/pipeline/SubscriptionAuthSource.js +103 -0
- package/dist/pipeline/SubscriptionAuthStrategy.cjs +18 -0
- package/dist/pipeline/SubscriptionAuthStrategy.d.cts +61 -0
- package/dist/pipeline/SubscriptionAuthStrategy.d.ts +61 -0
- package/dist/pipeline/SubscriptionAuthStrategy.js +0 -0
- package/dist/ports/gemini-code-assist-resolver.cjs +38 -0
- package/dist/ports/gemini-code-assist-resolver.d.cts +26 -0
- package/dist/ports/gemini-code-assist-resolver.d.ts +26 -0
- package/dist/ports/gemini-code-assist-resolver.js +12 -0
- package/dist/ports.cjs +18 -0
- package/dist/ports.d.cts +15 -0
- package/dist/ports.d.ts +15 -0
- package/dist/ports.js +0 -0
- package/dist/provider-proxy/ingress/providerProxyShared.cjs +2958 -0
- package/dist/provider-proxy/ingress/providerProxyShared.d.cts +77 -0
- package/dist/provider-proxy/ingress/providerProxyShared.d.ts +77 -0
- package/dist/provider-proxy/ingress/providerProxyShared.js +2925 -0
- package/dist/provider-proxy/matchText.cjs +73 -0
- package/dist/provider-proxy/matchText.d.cts +47 -0
- package/dist/provider-proxy/matchText.d.ts +47 -0
- package/dist/provider-proxy/matchText.js +45 -0
- package/dist/provider-proxy/types.cjs +18 -0
- package/dist/provider-proxy/types.d.cts +12 -0
- package/dist/provider-proxy/types.d.ts +12 -0
- package/dist/provider-proxy/types.js +0 -0
- package/dist/provider-proxy.cjs +4667 -0
- package/dist/provider-proxy.d.cts +69 -0
- package/dist/provider-proxy.d.ts +69 -0
- package/dist/provider-proxy.js +4636 -0
- package/dist/serializeError.cjs +82 -0
- package/dist/serializeError.d.cts +24 -0
- package/dist/serializeError.d.ts +24 -0
- package/dist/serializeError.js +57 -0
- package/dist/sse-parser.cjs +456 -0
- package/dist/sse-parser.d.cts +143 -0
- package/dist/sse-parser.d.ts +143 -0
- package/dist/sse-parser.js +430 -0
- package/dist/transformer/TransformerChainExecutor.cjs +321 -0
- package/dist/transformer/TransformerChainExecutor.d.cts +104 -0
- package/dist/transformer/TransformerChainExecutor.d.ts +104 -0
- package/dist/transformer/TransformerChainExecutor.js +294 -0
- package/dist/transformer/TransformerService.cjs +290 -0
- package/dist/transformer/TransformerService.d.cts +138 -0
- package/dist/transformer/TransformerService.d.ts +138 -0
- package/dist/transformer/TransformerService.js +265 -0
- package/dist/transformer/transformers/GeminiCodeAssistTransformer.cjs +1115 -0
- package/dist/transformer/transformers/GeminiCodeAssistTransformer.d.cts +102 -0
- package/dist/transformer/transformers/GeminiCodeAssistTransformer.d.ts +102 -0
- package/dist/transformer/transformers/GeminiCodeAssistTransformer.js +1085 -0
- package/dist/transformer/transformers/GeminiTransformer.cjs +1013 -0
- package/dist/transformer/transformers/GeminiTransformer.d.cts +70 -0
- package/dist/transformer/transformers/GeminiTransformer.d.ts +70 -0
- package/dist/transformer/transformers/GeminiTransformer.js +986 -0
- package/dist/transformer/transformers/OpenAIResponseTransformer.cjs +538 -0
- package/dist/transformer/transformers/OpenAIResponseTransformer.d.cts +53 -0
- package/dist/transformer/transformers/OpenAIResponseTransformer.d.ts +53 -0
- package/dist/transformer/transformers/OpenAIResponseTransformer.js +513 -0
- package/dist/transformer/transformers/OpenCodeGoTransformer.cjs +73 -0
- package/dist/transformer/transformers/OpenCodeGoTransformer.d.cts +51 -0
- package/dist/transformer/transformers/OpenCodeGoTransformer.d.ts +51 -0
- package/dist/transformer/transformers/OpenCodeGoTransformer.js +48 -0
- package/dist/transformer/types.cjs +18 -0
- package/dist/transformer/types.d.cts +405 -0
- package/dist/transformer/types.d.ts +405 -0
- package/dist/transformer/types.js +0 -0
- package/dist/transformer.cjs +3736 -0
- package/dist/transformer.d.cts +33 -0
- package/dist/transformer.d.ts +33 -0
- package/dist/transformer.js +3712 -0
- package/dist/types-CGGrKqC_.d.cts +142 -0
- package/dist/types-CbCN2NQP.d.ts +142 -0
- package/dist/types-DCzHkhJt.d.ts +467 -0
- package/dist/types-DZIQbgp0.d.cts +467 -0
- package/dist/usage-event-sink-BX7FE1NL.d.cts +59 -0
- package/dist/usage-event-sink-BX7FE1NL.d.ts +59 -0
- package/package.json +62 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/completion/ApiKeyPoolService.ts
|
|
21
|
+
var ApiKeyPoolService_exports = {};
|
|
22
|
+
__export(ApiKeyPoolService_exports, {
|
|
23
|
+
ApiKeyPoolService: () => ApiKeyPoolService
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(ApiKeyPoolService_exports);
|
|
26
|
+
var AUTH_FAILURE_CODES = /* @__PURE__ */ new Set([401, 403]);
|
|
27
|
+
var RATE_LIMIT_CODES = /* @__PURE__ */ new Set([429, 529]);
|
|
28
|
+
var ApiKeyPoolService = class {
|
|
29
|
+
constructor(loadKeys, resolveKey, logger, disableKey, markAutoDisabled) {
|
|
30
|
+
this.loadKeys = loadKeys;
|
|
31
|
+
this.resolveKey = resolveKey;
|
|
32
|
+
this.logger = logger;
|
|
33
|
+
this.disableKey = disableKey;
|
|
34
|
+
this.markAutoDisabled = markAutoDisabled;
|
|
35
|
+
this.cleanupTimer = setInterval(() => this.cleanupExpiredCooldowns(), 3e4);
|
|
36
|
+
}
|
|
37
|
+
loadKeys;
|
|
38
|
+
resolveKey;
|
|
39
|
+
logger;
|
|
40
|
+
disableKey;
|
|
41
|
+
markAutoDisabled;
|
|
42
|
+
/** Session 鈫?key binding (session affinity) */
|
|
43
|
+
sessionBindings = /* @__PURE__ */ new Map();
|
|
44
|
+
/** Provider 鈫?round-robin index */
|
|
45
|
+
rrIndex = /* @__PURE__ */ new Map();
|
|
46
|
+
/** Provider 鈫?cached key list */
|
|
47
|
+
keyCache = /* @__PURE__ */ new Map();
|
|
48
|
+
/** Key ID 鈫?cooldown state */
|
|
49
|
+
cooldowns = /* @__PURE__ */ new Map();
|
|
50
|
+
/** Cleanup interval handle */
|
|
51
|
+
cleanupTimer = null;
|
|
52
|
+
// Cooldown configuration
|
|
53
|
+
DEFAULT_COOLDOWN_MS = 6e4;
|
|
54
|
+
// 60 seconds
|
|
55
|
+
MAX_COOLDOWN_MS = 15 * 6e4;
|
|
56
|
+
// 15 minutes
|
|
57
|
+
COOLDOWN_MULTIPLIER = 2;
|
|
58
|
+
/**
|
|
59
|
+
* Get the API key for a session. Implements session affinity.
|
|
60
|
+
*
|
|
61
|
+
* First call for a session binds it to a key via weighted round-robin.
|
|
62
|
+
* Subsequent calls return the same key (preserves prompt cache).
|
|
63
|
+
*
|
|
64
|
+
* @returns Resolved API key string, or empty string if no keys available
|
|
65
|
+
*/
|
|
66
|
+
/**
|
|
67
|
+
* Read which key id is currently bound to the given session, if any.
|
|
68
|
+
* Returns null when the session has not yet been bound (first call hasn't
|
|
69
|
+
* happened) or when the binding is for a different provider.
|
|
70
|
+
*
|
|
71
|
+
* Used by the usage-recorder attribution path: after `getKeyForSession`
|
|
72
|
+
* completes the caller looks up the keyId so the recorded usage
|
|
73
|
+
* row can attribute spend to a specific pool key.
|
|
74
|
+
*/
|
|
75
|
+
getKeyIdForSession(providerId, sessionId) {
|
|
76
|
+
const binding = this.sessionBindings.get(sessionId);
|
|
77
|
+
if (!binding) return null;
|
|
78
|
+
if (binding.providerId !== providerId) return null;
|
|
79
|
+
return binding.keyId;
|
|
80
|
+
}
|
|
81
|
+
async getKeyForSession(providerId, sessionId) {
|
|
82
|
+
const binding = this.sessionBindings.get(sessionId);
|
|
83
|
+
if (binding && binding.providerId === providerId) {
|
|
84
|
+
const keys2 = await this.getAvailableKeys(providerId);
|
|
85
|
+
const boundKey = keys2.find((k) => k.id === binding.keyId);
|
|
86
|
+
if (boundKey) {
|
|
87
|
+
return this.resolveKey(boundKey.apiKey);
|
|
88
|
+
}
|
|
89
|
+
this.logger.info("Session key no longer available, re-binding", {
|
|
90
|
+
sessionId,
|
|
91
|
+
keyId: binding.keyId,
|
|
92
|
+
providerId
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
const keys = await this.getAvailableKeys(providerId);
|
|
96
|
+
if (keys.length === 0) return "";
|
|
97
|
+
const selected = this.selectWeightedRoundRobin(providerId, keys);
|
|
98
|
+
this.sessionBindings.set(sessionId, { keyId: selected.id, providerId });
|
|
99
|
+
this.logger.debug("Session bound to API key", {
|
|
100
|
+
sessionId,
|
|
101
|
+
keyId: selected.id,
|
|
102
|
+
label: selected.label,
|
|
103
|
+
providerId
|
|
104
|
+
});
|
|
105
|
+
return this.resolveKey(selected.apiKey);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Get a key without session affinity (for one-shot calls like testConnection).
|
|
109
|
+
*
|
|
110
|
+
* @returns Resolved API key string, or empty string if no keys available
|
|
111
|
+
*/
|
|
112
|
+
async getKey(providerId) {
|
|
113
|
+
const keys = await this.getAvailableKeys(providerId);
|
|
114
|
+
if (keys.length === 0) return "";
|
|
115
|
+
const selected = this.selectWeightedRoundRobin(providerId, keys);
|
|
116
|
+
return this.resolveKey(selected.apiKey);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Report an error for the current session's key.
|
|
120
|
+
*
|
|
121
|
+
* - 429/529 (rate limit / overload): puts key in cooldown with exponential backoff
|
|
122
|
+
* - 401/403 (auth failure): permanently disables the key in the database
|
|
123
|
+
*
|
|
124
|
+
* In both cases, re-binds the session to a different key if available.
|
|
125
|
+
*
|
|
126
|
+
* @param statusCode HTTP status code
|
|
127
|
+
* @returns New resolved API key if re-binding succeeded, null if no keys available
|
|
128
|
+
*/
|
|
129
|
+
async reportError(providerId, sessionId, statusCode) {
|
|
130
|
+
const isRateLimit = RATE_LIMIT_CODES.has(statusCode);
|
|
131
|
+
const isAuthFailure = AUTH_FAILURE_CODES.has(statusCode);
|
|
132
|
+
if (!isRateLimit && !isAuthFailure) return null;
|
|
133
|
+
const binding = this.sessionBindings.get(sessionId);
|
|
134
|
+
if (!binding) return null;
|
|
135
|
+
if (isAuthFailure) {
|
|
136
|
+
await this.handleAuthFailure(binding.keyId, providerId, statusCode);
|
|
137
|
+
} else {
|
|
138
|
+
this.applyCooldown(binding.keyId, providerId, statusCode);
|
|
139
|
+
}
|
|
140
|
+
const keys = await this.getAvailableKeys(providerId);
|
|
141
|
+
if (keys.length === 0) {
|
|
142
|
+
this.logger.warn("No available API keys after error", { providerId, statusCode });
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
const newKey = this.selectWeightedRoundRobin(providerId, keys);
|
|
146
|
+
this.sessionBindings.set(sessionId, { keyId: newKey.id, providerId });
|
|
147
|
+
this.logger.info("Session re-bound to new API key", {
|
|
148
|
+
sessionId,
|
|
149
|
+
newKeyId: newKey.id,
|
|
150
|
+
label: newKey.label
|
|
151
|
+
});
|
|
152
|
+
return this.resolveKey(newKey.apiKey);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Report a successful request — resets cooldown counter for the session's key.
|
|
156
|
+
*/
|
|
157
|
+
reportSuccess(sessionId) {
|
|
158
|
+
const binding = this.sessionBindings.get(sessionId);
|
|
159
|
+
if (!binding) return;
|
|
160
|
+
if (this.cooldowns.has(binding.keyId)) {
|
|
161
|
+
this.cooldowns.delete(binding.keyId);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Release session binding (call when session ends or is deleted).
|
|
166
|
+
*/
|
|
167
|
+
releaseSession(sessionId) {
|
|
168
|
+
this.sessionBindings.delete(sessionId);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Invalidate the key cache. Call after CRUD operations on API keys.
|
|
172
|
+
*/
|
|
173
|
+
invalidateCache(providerId) {
|
|
174
|
+
if (providerId) {
|
|
175
|
+
this.keyCache.delete(providerId);
|
|
176
|
+
} else {
|
|
177
|
+
this.keyCache.clear();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Check if a provider has any keys in the pool.
|
|
182
|
+
*/
|
|
183
|
+
async hasKeys(providerId) {
|
|
184
|
+
const keys = await this.getAllKeys(providerId);
|
|
185
|
+
return keys.length > 0;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Get the live (in-memory) rate-limit cooldown health for a provider's keys.
|
|
189
|
+
*
|
|
190
|
+
* Returns ONLY keys that are currently cooling down (cooldown `until` is in
|
|
191
|
+
* the future). A key absent from the returned map is not cooling. This is a
|
|
192
|
+
* pure read of the in-memory cooldown map — auth-failure auto-disable state
|
|
193
|
+
* is persisted on the key row itself (getApiKeys) and is NOT included here.
|
|
194
|
+
*/
|
|
195
|
+
async getKeyHealth(providerId) {
|
|
196
|
+
const keys = await this.getAllKeys(providerId);
|
|
197
|
+
const now = Date.now();
|
|
198
|
+
const health = {};
|
|
199
|
+
for (const key of keys) {
|
|
200
|
+
const cd = this.cooldowns.get(key.id);
|
|
201
|
+
if (cd && cd.until > now) {
|
|
202
|
+
health[key.id] = {
|
|
203
|
+
until: cd.until,
|
|
204
|
+
errors: cd.errors,
|
|
205
|
+
lastStatus: cd.lastStatus ?? null
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return health;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Dispose of the service (stop cleanup timer).
|
|
213
|
+
*/
|
|
214
|
+
dispose() {
|
|
215
|
+
if (this.cleanupTimer) {
|
|
216
|
+
clearInterval(this.cleanupTimer);
|
|
217
|
+
this.cleanupTimer = null;
|
|
218
|
+
}
|
|
219
|
+
this.sessionBindings.clear();
|
|
220
|
+
this.keyCache.clear();
|
|
221
|
+
this.cooldowns.clear();
|
|
222
|
+
}
|
|
223
|
+
// ==========================================================================
|
|
224
|
+
// Private methods
|
|
225
|
+
// ==========================================================================
|
|
226
|
+
/**
|
|
227
|
+
* Handle auth failure (401/403): disable the key in DB permanently.
|
|
228
|
+
*/
|
|
229
|
+
async handleAuthFailure(keyId, providerId, statusCode) {
|
|
230
|
+
this.logger.warn("API key auth failure \u2014 disabling permanently", {
|
|
231
|
+
keyId,
|
|
232
|
+
providerId,
|
|
233
|
+
statusCode
|
|
234
|
+
});
|
|
235
|
+
if (this.markAutoDisabled) {
|
|
236
|
+
try {
|
|
237
|
+
await this.markAutoDisabled(keyId, statusCode, Date.now());
|
|
238
|
+
} catch (err) {
|
|
239
|
+
this.logger.error("Failed to auto-disable API key in database", err instanceof Error ? err : void 0, { keyId });
|
|
240
|
+
}
|
|
241
|
+
} else if (this.disableKey) {
|
|
242
|
+
try {
|
|
243
|
+
await this.disableKey(keyId);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
this.logger.error("Failed to disable API key in database", err instanceof Error ? err : void 0, { keyId });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
this.keyCache.delete(providerId);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Apply cooldown with exponential backoff for rate-limit errors (429/529).
|
|
252
|
+
*/
|
|
253
|
+
applyCooldown(keyId, providerId, statusCode) {
|
|
254
|
+
const current = this.cooldowns.get(keyId);
|
|
255
|
+
const errors = (current?.errors ?? 0) + 1;
|
|
256
|
+
const cooldownMs = Math.min(
|
|
257
|
+
this.DEFAULT_COOLDOWN_MS * Math.pow(this.COOLDOWN_MULTIPLIER, errors - 1),
|
|
258
|
+
this.MAX_COOLDOWN_MS
|
|
259
|
+
);
|
|
260
|
+
this.cooldowns.set(keyId, {
|
|
261
|
+
until: Date.now() + cooldownMs,
|
|
262
|
+
errors,
|
|
263
|
+
lastStatus: statusCode
|
|
264
|
+
});
|
|
265
|
+
this.logger.info("API key put in cooldown", {
|
|
266
|
+
keyId,
|
|
267
|
+
providerId,
|
|
268
|
+
statusCode,
|
|
269
|
+
cooldownMs,
|
|
270
|
+
errors
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Get all keys for a provider (with caching).
|
|
275
|
+
*/
|
|
276
|
+
async getAllKeys(providerId) {
|
|
277
|
+
if (!this.keyCache.has(providerId)) {
|
|
278
|
+
const keys = await this.loadKeys(providerId);
|
|
279
|
+
this.keyCache.set(providerId, keys);
|
|
280
|
+
}
|
|
281
|
+
return this.keyCache.get(providerId);
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Get available keys: enabled AND not in cooldown.
|
|
285
|
+
*/
|
|
286
|
+
async getAvailableKeys(providerId) {
|
|
287
|
+
const all = await this.getAllKeys(providerId);
|
|
288
|
+
const now = Date.now();
|
|
289
|
+
return all.filter((k) => {
|
|
290
|
+
if (!k.enabled) return false;
|
|
291
|
+
const cd = this.cooldowns.get(k.id);
|
|
292
|
+
if (cd && cd.until > now) return false;
|
|
293
|
+
return true;
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Weighted round-robin selection.
|
|
298
|
+
*
|
|
299
|
+
* Each key's weight determines how many "slots" it occupies in the rotation.
|
|
300
|
+
* The round-robin index advances by 1 on each call per provider.
|
|
301
|
+
*/
|
|
302
|
+
selectWeightedRoundRobin(providerId, keys) {
|
|
303
|
+
if (keys.length === 1) return keys[0];
|
|
304
|
+
const totalWeight = keys.reduce((sum, k) => sum + k.weight, 0);
|
|
305
|
+
const idx = ((this.rrIndex.get(providerId) ?? -1) + 1) % totalWeight;
|
|
306
|
+
this.rrIndex.set(providerId, idx);
|
|
307
|
+
let accum = 0;
|
|
308
|
+
for (const key of keys) {
|
|
309
|
+
accum += key.weight;
|
|
310
|
+
if (idx < accum) {
|
|
311
|
+
return key;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return keys[0];
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Clean up expired cooldowns.
|
|
318
|
+
*/
|
|
319
|
+
cleanupExpiredCooldowns() {
|
|
320
|
+
const now = Date.now();
|
|
321
|
+
for (const [keyId, cd] of this.cooldowns) {
|
|
322
|
+
if (cd.until <= now) {
|
|
323
|
+
this.cooldowns.delete(keyId);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
329
|
+
0 && (module.exports = {
|
|
330
|
+
ApiKeyPoolService
|
|
331
|
+
});
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// src/completion/ApiKeyPoolService.ts
|
|
2
|
+
var AUTH_FAILURE_CODES = /* @__PURE__ */ new Set([401, 403]);
|
|
3
|
+
var RATE_LIMIT_CODES = /* @__PURE__ */ new Set([429, 529]);
|
|
4
|
+
var ApiKeyPoolService = class {
|
|
5
|
+
constructor(loadKeys, resolveKey, logger, disableKey, markAutoDisabled) {
|
|
6
|
+
this.loadKeys = loadKeys;
|
|
7
|
+
this.resolveKey = resolveKey;
|
|
8
|
+
this.logger = logger;
|
|
9
|
+
this.disableKey = disableKey;
|
|
10
|
+
this.markAutoDisabled = markAutoDisabled;
|
|
11
|
+
this.cleanupTimer = setInterval(() => this.cleanupExpiredCooldowns(), 3e4);
|
|
12
|
+
}
|
|
13
|
+
loadKeys;
|
|
14
|
+
resolveKey;
|
|
15
|
+
logger;
|
|
16
|
+
disableKey;
|
|
17
|
+
markAutoDisabled;
|
|
18
|
+
/** Session 鈫?key binding (session affinity) */
|
|
19
|
+
sessionBindings = /* @__PURE__ */ new Map();
|
|
20
|
+
/** Provider 鈫?round-robin index */
|
|
21
|
+
rrIndex = /* @__PURE__ */ new Map();
|
|
22
|
+
/** Provider 鈫?cached key list */
|
|
23
|
+
keyCache = /* @__PURE__ */ new Map();
|
|
24
|
+
/** Key ID 鈫?cooldown state */
|
|
25
|
+
cooldowns = /* @__PURE__ */ new Map();
|
|
26
|
+
/** Cleanup interval handle */
|
|
27
|
+
cleanupTimer = null;
|
|
28
|
+
// Cooldown configuration
|
|
29
|
+
DEFAULT_COOLDOWN_MS = 6e4;
|
|
30
|
+
// 60 seconds
|
|
31
|
+
MAX_COOLDOWN_MS = 15 * 6e4;
|
|
32
|
+
// 15 minutes
|
|
33
|
+
COOLDOWN_MULTIPLIER = 2;
|
|
34
|
+
/**
|
|
35
|
+
* Get the API key for a session. Implements session affinity.
|
|
36
|
+
*
|
|
37
|
+
* First call for a session binds it to a key via weighted round-robin.
|
|
38
|
+
* Subsequent calls return the same key (preserves prompt cache).
|
|
39
|
+
*
|
|
40
|
+
* @returns Resolved API key string, or empty string if no keys available
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* Read which key id is currently bound to the given session, if any.
|
|
44
|
+
* Returns null when the session has not yet been bound (first call hasn't
|
|
45
|
+
* happened) or when the binding is for a different provider.
|
|
46
|
+
*
|
|
47
|
+
* Used by the usage-recorder attribution path: after `getKeyForSession`
|
|
48
|
+
* completes the caller looks up the keyId so the recorded usage
|
|
49
|
+
* row can attribute spend to a specific pool key.
|
|
50
|
+
*/
|
|
51
|
+
getKeyIdForSession(providerId, sessionId) {
|
|
52
|
+
const binding = this.sessionBindings.get(sessionId);
|
|
53
|
+
if (!binding) return null;
|
|
54
|
+
if (binding.providerId !== providerId) return null;
|
|
55
|
+
return binding.keyId;
|
|
56
|
+
}
|
|
57
|
+
async getKeyForSession(providerId, sessionId) {
|
|
58
|
+
const binding = this.sessionBindings.get(sessionId);
|
|
59
|
+
if (binding && binding.providerId === providerId) {
|
|
60
|
+
const keys2 = await this.getAvailableKeys(providerId);
|
|
61
|
+
const boundKey = keys2.find((k) => k.id === binding.keyId);
|
|
62
|
+
if (boundKey) {
|
|
63
|
+
return this.resolveKey(boundKey.apiKey);
|
|
64
|
+
}
|
|
65
|
+
this.logger.info("Session key no longer available, re-binding", {
|
|
66
|
+
sessionId,
|
|
67
|
+
keyId: binding.keyId,
|
|
68
|
+
providerId
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const keys = await this.getAvailableKeys(providerId);
|
|
72
|
+
if (keys.length === 0) return "";
|
|
73
|
+
const selected = this.selectWeightedRoundRobin(providerId, keys);
|
|
74
|
+
this.sessionBindings.set(sessionId, { keyId: selected.id, providerId });
|
|
75
|
+
this.logger.debug("Session bound to API key", {
|
|
76
|
+
sessionId,
|
|
77
|
+
keyId: selected.id,
|
|
78
|
+
label: selected.label,
|
|
79
|
+
providerId
|
|
80
|
+
});
|
|
81
|
+
return this.resolveKey(selected.apiKey);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Get a key without session affinity (for one-shot calls like testConnection).
|
|
85
|
+
*
|
|
86
|
+
* @returns Resolved API key string, or empty string if no keys available
|
|
87
|
+
*/
|
|
88
|
+
async getKey(providerId) {
|
|
89
|
+
const keys = await this.getAvailableKeys(providerId);
|
|
90
|
+
if (keys.length === 0) return "";
|
|
91
|
+
const selected = this.selectWeightedRoundRobin(providerId, keys);
|
|
92
|
+
return this.resolveKey(selected.apiKey);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Report an error for the current session's key.
|
|
96
|
+
*
|
|
97
|
+
* - 429/529 (rate limit / overload): puts key in cooldown with exponential backoff
|
|
98
|
+
* - 401/403 (auth failure): permanently disables the key in the database
|
|
99
|
+
*
|
|
100
|
+
* In both cases, re-binds the session to a different key if available.
|
|
101
|
+
*
|
|
102
|
+
* @param statusCode HTTP status code
|
|
103
|
+
* @returns New resolved API key if re-binding succeeded, null if no keys available
|
|
104
|
+
*/
|
|
105
|
+
async reportError(providerId, sessionId, statusCode) {
|
|
106
|
+
const isRateLimit = RATE_LIMIT_CODES.has(statusCode);
|
|
107
|
+
const isAuthFailure = AUTH_FAILURE_CODES.has(statusCode);
|
|
108
|
+
if (!isRateLimit && !isAuthFailure) return null;
|
|
109
|
+
const binding = this.sessionBindings.get(sessionId);
|
|
110
|
+
if (!binding) return null;
|
|
111
|
+
if (isAuthFailure) {
|
|
112
|
+
await this.handleAuthFailure(binding.keyId, providerId, statusCode);
|
|
113
|
+
} else {
|
|
114
|
+
this.applyCooldown(binding.keyId, providerId, statusCode);
|
|
115
|
+
}
|
|
116
|
+
const keys = await this.getAvailableKeys(providerId);
|
|
117
|
+
if (keys.length === 0) {
|
|
118
|
+
this.logger.warn("No available API keys after error", { providerId, statusCode });
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
const newKey = this.selectWeightedRoundRobin(providerId, keys);
|
|
122
|
+
this.sessionBindings.set(sessionId, { keyId: newKey.id, providerId });
|
|
123
|
+
this.logger.info("Session re-bound to new API key", {
|
|
124
|
+
sessionId,
|
|
125
|
+
newKeyId: newKey.id,
|
|
126
|
+
label: newKey.label
|
|
127
|
+
});
|
|
128
|
+
return this.resolveKey(newKey.apiKey);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Report a successful request — resets cooldown counter for the session's key.
|
|
132
|
+
*/
|
|
133
|
+
reportSuccess(sessionId) {
|
|
134
|
+
const binding = this.sessionBindings.get(sessionId);
|
|
135
|
+
if (!binding) return;
|
|
136
|
+
if (this.cooldowns.has(binding.keyId)) {
|
|
137
|
+
this.cooldowns.delete(binding.keyId);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Release session binding (call when session ends or is deleted).
|
|
142
|
+
*/
|
|
143
|
+
releaseSession(sessionId) {
|
|
144
|
+
this.sessionBindings.delete(sessionId);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Invalidate the key cache. Call after CRUD operations on API keys.
|
|
148
|
+
*/
|
|
149
|
+
invalidateCache(providerId) {
|
|
150
|
+
if (providerId) {
|
|
151
|
+
this.keyCache.delete(providerId);
|
|
152
|
+
} else {
|
|
153
|
+
this.keyCache.clear();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Check if a provider has any keys in the pool.
|
|
158
|
+
*/
|
|
159
|
+
async hasKeys(providerId) {
|
|
160
|
+
const keys = await this.getAllKeys(providerId);
|
|
161
|
+
return keys.length > 0;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Get the live (in-memory) rate-limit cooldown health for a provider's keys.
|
|
165
|
+
*
|
|
166
|
+
* Returns ONLY keys that are currently cooling down (cooldown `until` is in
|
|
167
|
+
* the future). A key absent from the returned map is not cooling. This is a
|
|
168
|
+
* pure read of the in-memory cooldown map — auth-failure auto-disable state
|
|
169
|
+
* is persisted on the key row itself (getApiKeys) and is NOT included here.
|
|
170
|
+
*/
|
|
171
|
+
async getKeyHealth(providerId) {
|
|
172
|
+
const keys = await this.getAllKeys(providerId);
|
|
173
|
+
const now = Date.now();
|
|
174
|
+
const health = {};
|
|
175
|
+
for (const key of keys) {
|
|
176
|
+
const cd = this.cooldowns.get(key.id);
|
|
177
|
+
if (cd && cd.until > now) {
|
|
178
|
+
health[key.id] = {
|
|
179
|
+
until: cd.until,
|
|
180
|
+
errors: cd.errors,
|
|
181
|
+
lastStatus: cd.lastStatus ?? null
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return health;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Dispose of the service (stop cleanup timer).
|
|
189
|
+
*/
|
|
190
|
+
dispose() {
|
|
191
|
+
if (this.cleanupTimer) {
|
|
192
|
+
clearInterval(this.cleanupTimer);
|
|
193
|
+
this.cleanupTimer = null;
|
|
194
|
+
}
|
|
195
|
+
this.sessionBindings.clear();
|
|
196
|
+
this.keyCache.clear();
|
|
197
|
+
this.cooldowns.clear();
|
|
198
|
+
}
|
|
199
|
+
// ==========================================================================
|
|
200
|
+
// Private methods
|
|
201
|
+
// ==========================================================================
|
|
202
|
+
/**
|
|
203
|
+
* Handle auth failure (401/403): disable the key in DB permanently.
|
|
204
|
+
*/
|
|
205
|
+
async handleAuthFailure(keyId, providerId, statusCode) {
|
|
206
|
+
this.logger.warn("API key auth failure \u2014 disabling permanently", {
|
|
207
|
+
keyId,
|
|
208
|
+
providerId,
|
|
209
|
+
statusCode
|
|
210
|
+
});
|
|
211
|
+
if (this.markAutoDisabled) {
|
|
212
|
+
try {
|
|
213
|
+
await this.markAutoDisabled(keyId, statusCode, Date.now());
|
|
214
|
+
} catch (err) {
|
|
215
|
+
this.logger.error("Failed to auto-disable API key in database", err instanceof Error ? err : void 0, { keyId });
|
|
216
|
+
}
|
|
217
|
+
} else if (this.disableKey) {
|
|
218
|
+
try {
|
|
219
|
+
await this.disableKey(keyId);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
this.logger.error("Failed to disable API key in database", err instanceof Error ? err : void 0, { keyId });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
this.keyCache.delete(providerId);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Apply cooldown with exponential backoff for rate-limit errors (429/529).
|
|
228
|
+
*/
|
|
229
|
+
applyCooldown(keyId, providerId, statusCode) {
|
|
230
|
+
const current = this.cooldowns.get(keyId);
|
|
231
|
+
const errors = (current?.errors ?? 0) + 1;
|
|
232
|
+
const cooldownMs = Math.min(
|
|
233
|
+
this.DEFAULT_COOLDOWN_MS * Math.pow(this.COOLDOWN_MULTIPLIER, errors - 1),
|
|
234
|
+
this.MAX_COOLDOWN_MS
|
|
235
|
+
);
|
|
236
|
+
this.cooldowns.set(keyId, {
|
|
237
|
+
until: Date.now() + cooldownMs,
|
|
238
|
+
errors,
|
|
239
|
+
lastStatus: statusCode
|
|
240
|
+
});
|
|
241
|
+
this.logger.info("API key put in cooldown", {
|
|
242
|
+
keyId,
|
|
243
|
+
providerId,
|
|
244
|
+
statusCode,
|
|
245
|
+
cooldownMs,
|
|
246
|
+
errors
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Get all keys for a provider (with caching).
|
|
251
|
+
*/
|
|
252
|
+
async getAllKeys(providerId) {
|
|
253
|
+
if (!this.keyCache.has(providerId)) {
|
|
254
|
+
const keys = await this.loadKeys(providerId);
|
|
255
|
+
this.keyCache.set(providerId, keys);
|
|
256
|
+
}
|
|
257
|
+
return this.keyCache.get(providerId);
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Get available keys: enabled AND not in cooldown.
|
|
261
|
+
*/
|
|
262
|
+
async getAvailableKeys(providerId) {
|
|
263
|
+
const all = await this.getAllKeys(providerId);
|
|
264
|
+
const now = Date.now();
|
|
265
|
+
return all.filter((k) => {
|
|
266
|
+
if (!k.enabled) return false;
|
|
267
|
+
const cd = this.cooldowns.get(k.id);
|
|
268
|
+
if (cd && cd.until > now) return false;
|
|
269
|
+
return true;
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Weighted round-robin selection.
|
|
274
|
+
*
|
|
275
|
+
* Each key's weight determines how many "slots" it occupies in the rotation.
|
|
276
|
+
* The round-robin index advances by 1 on each call per provider.
|
|
277
|
+
*/
|
|
278
|
+
selectWeightedRoundRobin(providerId, keys) {
|
|
279
|
+
if (keys.length === 1) return keys[0];
|
|
280
|
+
const totalWeight = keys.reduce((sum, k) => sum + k.weight, 0);
|
|
281
|
+
const idx = ((this.rrIndex.get(providerId) ?? -1) + 1) % totalWeight;
|
|
282
|
+
this.rrIndex.set(providerId, idx);
|
|
283
|
+
let accum = 0;
|
|
284
|
+
for (const key of keys) {
|
|
285
|
+
accum += key.weight;
|
|
286
|
+
if (idx < accum) {
|
|
287
|
+
return key;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return keys[0];
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Clean up expired cooldowns.
|
|
294
|
+
*/
|
|
295
|
+
cleanupExpiredCooldowns() {
|
|
296
|
+
const now = Date.now();
|
|
297
|
+
for (const [keyId, cd] of this.cooldowns) {
|
|
298
|
+
if (cd.until <= now) {
|
|
299
|
+
this.cooldowns.delete(keyId);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
export {
|
|
305
|
+
ApiKeyPoolService
|
|
306
|
+
};
|