open-sse 1.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.
Files changed (36) hide show
  1. package/README.md +180 -0
  2. package/config/constants.js +206 -0
  3. package/config/defaultThinkingSignature.js +7 -0
  4. package/config/ollamaModels.js +19 -0
  5. package/config/providerModels.js +161 -0
  6. package/handlers/chatCore.js +277 -0
  7. package/handlers/responsesHandler.js +69 -0
  8. package/index.js +69 -0
  9. package/package.json +44 -0
  10. package/services/accountFallback.js +148 -0
  11. package/services/combo.js +69 -0
  12. package/services/compact.js +64 -0
  13. package/services/model.js +109 -0
  14. package/services/provider.js +237 -0
  15. package/services/tokenRefresh.js +542 -0
  16. package/services/usage.js +398 -0
  17. package/translator/formats.js +12 -0
  18. package/translator/from-openai/claude.js +341 -0
  19. package/translator/from-openai/gemini.js +469 -0
  20. package/translator/from-openai/openai-responses.js +361 -0
  21. package/translator/helpers/claudeHelper.js +179 -0
  22. package/translator/helpers/geminiHelper.js +131 -0
  23. package/translator/helpers/openaiHelper.js +80 -0
  24. package/translator/helpers/responsesApiHelper.js +103 -0
  25. package/translator/helpers/toolCallHelper.js +111 -0
  26. package/translator/index.js +167 -0
  27. package/translator/to-openai/claude.js +238 -0
  28. package/translator/to-openai/gemini.js +151 -0
  29. package/translator/to-openai/openai-responses.js +140 -0
  30. package/translator/to-openai/openai.js +371 -0
  31. package/utils/bypassHandler.js +258 -0
  32. package/utils/error.js +133 -0
  33. package/utils/ollamaTransform.js +82 -0
  34. package/utils/requestLogger.js +217 -0
  35. package/utils/stream.js +274 -0
  36. package/utils/streamHandler.js +131 -0
@@ -0,0 +1,277 @@
1
+ import { detectFormat, getTargetFormat, buildProviderUrl, buildProviderHeaders } from "../services/provider.js";
2
+ import { translateRequest, needsTranslation } from "../translator/index.js";
3
+ import { FORMATS } from "../translator/formats.js";
4
+ import { createSSETransformStreamWithLogger, createPassthroughStreamWithLogger, COLORS } from "../utils/stream.js";
5
+ import { createStreamController, pipeWithDisconnect } from "../utils/streamHandler.js";
6
+ import { refreshTokenByProvider, refreshWithRetry } from "../services/tokenRefresh.js";
7
+ import { createRequestLogger } from "../utils/requestLogger.js";
8
+ import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
9
+ import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
10
+ import { handleBypassRequest } from "../utils/bypassHandler.js";
11
+
12
+ /**
13
+ * Core chat handler - shared between SSE and Worker
14
+ * Returns { success, response, status, error } for caller to handle fallback
15
+ * @param {object} options
16
+ * @param {object} options.body - Request body
17
+ * @param {object} options.modelInfo - { provider, model }
18
+ * @param {object} options.credentials - Provider credentials
19
+ * @param {object} options.log - Logger instance (optional)
20
+ * @param {function} options.onCredentialsRefreshed - Callback when credentials are refreshed
21
+ * @param {function} options.onRequestSuccess - Callback when request succeeds (to clear error status)
22
+ * @param {function} options.onDisconnect - Callback when client disconnects
23
+ */
24
+ export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest }) {
25
+ const { provider, model } = modelInfo;
26
+
27
+ const sourceFormat = detectFormat(body);
28
+
29
+ // Check for bypass patterns (warmup, skip) - return fake response
30
+ const bypassResponse = handleBypassRequest(body, model);
31
+ if (bypassResponse) {
32
+ return bypassResponse;
33
+ }
34
+
35
+ // Detect source format and get target format
36
+ // Model-specific targetFormat takes priority over provider default
37
+
38
+ const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
39
+ const modelTargetFormat = getModelTargetFormat(alias, model);
40
+ const targetFormat = modelTargetFormat || getTargetFormat(provider);
41
+ const stream = body.stream !== false;
42
+
43
+ // Create request logger for this session: sourceFormat_targetFormat_model
44
+ const reqLogger = createRequestLogger(sourceFormat, targetFormat, model);
45
+
46
+ // 0. Log client raw request (before any conversion)
47
+ if (clientRawRequest) {
48
+ reqLogger.logClientRawRequest(
49
+ clientRawRequest.endpoint,
50
+ clientRawRequest.body,
51
+ clientRawRequest.headers
52
+ );
53
+ }
54
+
55
+ // 1. Log raw request from client
56
+ reqLogger.logRawRequest(body);
57
+
58
+ // 1a. Log format detection info
59
+ reqLogger.logFormatInfo({
60
+ sourceFormat,
61
+ targetFormat,
62
+ provider,
63
+ model,
64
+ stream
65
+ });
66
+
67
+ log?.debug?.("FORMAT", `${sourceFormat} → ${targetFormat} | stream=${stream}`);
68
+
69
+ // Translate request
70
+ let translatedBody = body;
71
+ translatedBody = translateRequest(sourceFormat, targetFormat, model, body, stream, credentials, provider);
72
+
73
+
74
+ // Update model in body
75
+ translatedBody.model = model;
76
+
77
+ // Build provider URL and headers
78
+ const providerUrl = buildProviderUrl(provider, model, stream);
79
+ const providerHeaders = buildProviderHeaders(provider, credentials, stream, translatedBody);
80
+
81
+ // 2. Log converted request to provider
82
+ reqLogger.logConvertedRequest(providerUrl, providerHeaders, translatedBody);
83
+
84
+ const msgCount = translatedBody.messages?.length
85
+ || translatedBody.contents?.length
86
+ || translatedBody.request?.contents?.length
87
+ || 0;
88
+ log?.debug?.("REQUEST", `${provider.toUpperCase()} | ${model} | ${msgCount} msgs`);
89
+
90
+ // Log headers (mask sensitive values)
91
+ const safeHeaders = {};
92
+ for (const [key, value] of Object.entries(providerHeaders)) {
93
+ if (key.toLowerCase().includes("auth") || key.toLowerCase().includes("key") || key.toLowerCase().includes("token")) {
94
+ safeHeaders[key] = value ? `${value.slice(0, 10)}...` : "";
95
+ } else {
96
+ safeHeaders[key] = value;
97
+ }
98
+ }
99
+ log?.debug?.("HEADERS", JSON.stringify(safeHeaders));
100
+
101
+ // Create stream controller for disconnect detection
102
+ const streamController = createStreamController({ onDisconnect, log, provider, model });
103
+
104
+ // Make request to provider with abort signal
105
+ let providerResponse;
106
+ try {
107
+ providerResponse = await fetch(providerUrl, {
108
+ method: "POST",
109
+ headers: providerHeaders,
110
+ body: JSON.stringify(translatedBody),
111
+ signal: streamController.signal
112
+ });
113
+ } catch (error) {
114
+ if (error.name === "AbortError") {
115
+ streamController.handleError(error);
116
+ return createErrorResult(499, "Request aborted");
117
+ }
118
+ const errMsg = formatProviderError(error, provider, model);
119
+ console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
120
+ return createErrorResult(502, errMsg);
121
+ }
122
+
123
+
124
+ // Handle 401/403 - try token refresh
125
+ if (providerResponse.status === 401 || providerResponse.status === 403) {
126
+ let newCredentials = null;
127
+
128
+ // GitHub needs special handling - refresh copilotToken using accessToken
129
+ if (provider === "github") {
130
+ const { refreshCopilotToken, refreshGitHubToken } = await import("../services/tokenRefresh.js");
131
+
132
+ // First try refreshing copilotToken using existing accessToken
133
+ let copilotResult = await refreshCopilotToken(credentials.accessToken, log);
134
+
135
+ // If that fails, refresh GitHub accessToken first, then get new copilotToken
136
+ if (!copilotResult && credentials.refreshToken) {
137
+ const githubTokens = await refreshGitHubToken(credentials.refreshToken, log);
138
+ if (githubTokens?.accessToken) {
139
+ credentials.accessToken = githubTokens.accessToken;
140
+ if (githubTokens.refreshToken) {
141
+ credentials.refreshToken = githubTokens.refreshToken;
142
+ }
143
+ copilotResult = await refreshCopilotToken(githubTokens.accessToken, log);
144
+ }
145
+ }
146
+
147
+ if (copilotResult?.token) {
148
+ credentials.copilotToken = copilotResult.token;
149
+ newCredentials = {
150
+ accessToken: credentials.accessToken,
151
+ refreshToken: credentials.refreshToken,
152
+ providerSpecificData: {
153
+ ...credentials.providerSpecificData,
154
+ copilotToken: copilotResult.token,
155
+ copilotTokenExpiresAt: copilotResult.expiresAt
156
+ }
157
+ };
158
+ log?.info?.("TOKEN", `${provider.toUpperCase()} | copilotToken refreshed`);
159
+ }
160
+ } else {
161
+ newCredentials = await refreshWithRetry(
162
+ () => refreshTokenByProvider(provider, credentials, log),
163
+ 3,
164
+ log
165
+ );
166
+ }
167
+
168
+ if (newCredentials?.accessToken || (provider === "github" && credentials.copilotToken)) {
169
+ if (newCredentials?.accessToken) {
170
+ log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed`);
171
+ credentials.accessToken = newCredentials.accessToken;
172
+ }
173
+ if (newCredentials?.refreshToken) {
174
+ credentials.refreshToken = newCredentials.refreshToken;
175
+ }
176
+ if (newCredentials?.providerSpecificData) {
177
+ credentials.providerSpecificData = {
178
+ ...credentials.providerSpecificData,
179
+ ...newCredentials.providerSpecificData
180
+ };
181
+ }
182
+
183
+ // Notify caller about refreshed credentials
184
+ if (onCredentialsRefreshed && newCredentials) {
185
+ await onCredentialsRefreshed(newCredentials);
186
+ }
187
+
188
+ // Retry with new credentials
189
+ const newHeaders = buildProviderHeaders(provider, credentials, stream, translatedBody);
190
+ const retryResponse = await fetch(providerUrl, {
191
+ method: "POST",
192
+ headers: newHeaders,
193
+ body: JSON.stringify(translatedBody),
194
+ signal: streamController.signal
195
+ });
196
+
197
+ if (retryResponse.ok) {
198
+ providerResponse = retryResponse;
199
+ }
200
+ } else {
201
+ log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`);
202
+ }
203
+ }
204
+
205
+ // Check provider response - return error info for fallback handling
206
+ if (!providerResponse.ok) {
207
+ const { statusCode, message } = await parseUpstreamError(providerResponse);
208
+ const errMsg = formatProviderError(new Error(message), provider, model);
209
+ console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
210
+
211
+ // Log error with full request body for debugging
212
+ reqLogger.logError(new Error(message), translatedBody);
213
+
214
+ return createErrorResult(statusCode, errMsg);
215
+ }
216
+
217
+ // Non-streaming response
218
+ if (!stream) {
219
+ const responseBody = await providerResponse.json();
220
+
221
+ // Notify success - caller can clear error status if needed
222
+ if (onRequestSuccess) {
223
+ await onRequestSuccess();
224
+ }
225
+
226
+ return {
227
+ success: true,
228
+ response: new Response(JSON.stringify(responseBody), {
229
+ headers: {
230
+ "Content-Type": "application/json",
231
+ "Access-Control-Allow-Origin": "*"
232
+ }
233
+ })
234
+ };
235
+ }
236
+
237
+ // Streaming response
238
+
239
+ // Notify success - caller can clear error status if needed
240
+ if (onRequestSuccess) {
241
+ await onRequestSuccess();
242
+ }
243
+
244
+ const responseHeaders = {
245
+ "Content-Type": "text/event-stream",
246
+ "Cache-Control": "no-cache",
247
+ "Connection": "keep-alive",
248
+ "Access-Control-Allow-Origin": "*"
249
+ };
250
+
251
+ // Create transform stream with logger for streaming response
252
+ let transformStream;
253
+ if (needsTranslation(targetFormat, sourceFormat)) {
254
+ transformStream = createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger);
255
+ } else {
256
+ transformStream = createPassthroughStreamWithLogger(provider, reqLogger);
257
+ }
258
+
259
+ // Pipe response through transform with disconnect detection
260
+ const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController);
261
+
262
+ return {
263
+ success: true,
264
+ response: new Response(transformedBody, {
265
+ headers: responseHeaders
266
+ })
267
+ };
268
+ }
269
+
270
+ /**
271
+ * Check if token is expired or about to expire
272
+ */
273
+ export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) {
274
+ if (!expiresAt) return false;
275
+ const expiresAtMs = new Date(expiresAt).getTime();
276
+ return expiresAtMs - Date.now() < bufferMs;
277
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Responses API Handler for Workers
3
+ * Converts Chat Completions to Codex Responses API format
4
+ */
5
+
6
+ import { handleChatCore } from "./chatCore.js";
7
+ import { convertResponsesApiFormat } from "../translator/helpers/responsesApiHelper.js";
8
+ import { createResponsesApiTransformStream } from "../transformer/responsesTransformer.js";
9
+
10
+ /**
11
+ * Handle /v1/responses request
12
+ * @param {object} options
13
+ * @param {object} options.body - Request body (Responses API format)
14
+ * @param {object} options.modelInfo - { provider, model }
15
+ * @param {object} options.credentials - Provider credentials
16
+ * @param {object} options.log - Logger instance (optional)
17
+ * @param {function} options.onCredentialsRefreshed - Callback when credentials are refreshed
18
+ * @param {function} options.onRequestSuccess - Callback when request succeeds
19
+ * @param {function} options.onDisconnect - Callback when client disconnects
20
+ * @returns {Promise<{success: boolean, response?: Response, status?: number, error?: string}>}
21
+ */
22
+ export async function handleResponsesCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect }) {
23
+ // Convert Responses API format to Chat Completions format
24
+ const convertedBody = convertResponsesApiFormat(body);
25
+
26
+ // Ensure stream is enabled
27
+ convertedBody.stream = true;
28
+
29
+ // Call chat core handler
30
+ const result = await handleChatCore({
31
+ body: convertedBody,
32
+ modelInfo,
33
+ credentials,
34
+ log,
35
+ onCredentialsRefreshed,
36
+ onRequestSuccess,
37
+ onDisconnect
38
+ });
39
+
40
+ if (!result.success || !result.response) {
41
+ return result;
42
+ }
43
+
44
+ const response = result.response;
45
+ const contentType = response.headers.get("Content-Type") || "";
46
+
47
+ // If not SSE or error, return as-is
48
+ if (!contentType.includes("text/event-stream") || response.status !== 200) {
49
+ return result;
50
+ }
51
+
52
+ // Transform SSE stream to Responses API format (no logging in worker)
53
+ const transformStream = createResponsesApiTransformStream(null);
54
+ const transformedBody = response.body.pipeThrough(transformStream);
55
+
56
+ return {
57
+ success: true,
58
+ response: new Response(transformedBody, {
59
+ status: 200,
60
+ headers: {
61
+ "Content-Type": "text/event-stream",
62
+ "Cache-Control": "no-cache",
63
+ "Connection": "keep-alive",
64
+ "Access-Control-Allow-Origin": "*"
65
+ }
66
+ })
67
+ };
68
+ }
69
+
package/index.js ADDED
@@ -0,0 +1,69 @@
1
+ // Config
2
+ export { PROVIDERS, OAUTH_ENDPOINTS, CACHE_TTL, DEFAULT_MAX_TOKENS, CLAUDE_SYSTEM_PROMPT, COOLDOWN_MS, BACKOFF_CONFIG } from "./config/constants.js";
3
+ export {
4
+ PROVIDER_MODELS,
5
+ getProviderModels,
6
+ getDefaultModel,
7
+ isValidModel,
8
+ findModelName,
9
+ getModelTargetFormat,
10
+ PROVIDER_ID_TO_ALIAS,
11
+ getModelsByProviderId
12
+ } from "./config/providerModels.js";
13
+
14
+ // Translator
15
+ export { FORMATS } from "./translator/formats.js";
16
+ export {
17
+ register,
18
+ translateRequest,
19
+ translateResponse,
20
+ needsTranslation,
21
+ initState,
22
+ initTranslators
23
+ } from "./translator/index.js";
24
+
25
+ // Services
26
+ export {
27
+ detectFormat,
28
+ getProviderConfig,
29
+ buildProviderUrl,
30
+ buildProviderHeaders,
31
+ getTargetFormat
32
+ } from "./services/provider.js";
33
+
34
+ export { parseModel, resolveModelAliasFromMap, getModelInfoCore } from "./services/model.js";
35
+
36
+ export {
37
+ checkFallbackError,
38
+ isAccountUnavailable,
39
+ getUnavailableUntil,
40
+ filterAvailableAccounts
41
+ } from "./services/accountFallback.js";
42
+
43
+ export {
44
+ TOKEN_EXPIRY_BUFFER_MS,
45
+ refreshAccessToken,
46
+ refreshClaudeOAuthToken,
47
+ refreshGoogleToken,
48
+ refreshQwenToken,
49
+ refreshCodexToken,
50
+ refreshIflowToken,
51
+ refreshGitHubToken,
52
+ refreshCopilotToken,
53
+ getAccessToken,
54
+ refreshTokenByProvider
55
+ } from "./services/tokenRefresh.js";
56
+
57
+ // Handlers
58
+ export { handleChatCore, isTokenExpiringSoon } from "./handlers/chatCore.js";
59
+ export { createStreamController, pipeWithDisconnect, createDisconnectAwareStream } from "./utils/streamHandler.js";
60
+
61
+ // Utils
62
+ export { errorResponse, formatProviderError } from "./utils/error.js";
63
+ export {
64
+ createSSETransformStreamWithLogger,
65
+ createPassthroughStreamWithLogger
66
+ } from "./utils/stream.js";
67
+
68
+ export { createRequestLogger } from "./utils/requestLogger.js";
69
+
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "open-sse",
3
+ "version": "1.0.0",
4
+ "description": "Universal AI proxy library with SSE streaming support for OpenAI, Claude, Gemini and more",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./*": "./*"
10
+ },
11
+ "files": [
12
+ "index.js",
13
+ "config/",
14
+ "handlers/",
15
+ "services/",
16
+ "translator/",
17
+ "utils/"
18
+ ],
19
+ "scripts": {
20
+ "prepublishOnly": "echo '✅ Publishing open-sse...'"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/yourusername/router4.git",
25
+ "directory": "open-sse"
26
+ },
27
+ "keywords": [
28
+ "ai",
29
+ "proxy",
30
+ "sse",
31
+ "openai",
32
+ "claude",
33
+ "gemini",
34
+ "streaming",
35
+ "llm",
36
+ "api"
37
+ ],
38
+ "author": "Your Name",
39
+ "license": "MIT",
40
+ "engines": {
41
+ "node": ">=18.0.0"
42
+ }
43
+ }
44
+
@@ -0,0 +1,148 @@
1
+ import { COOLDOWN_MS, BACKOFF_CONFIG } from "../config/constants.js";
2
+
3
+ /**
4
+ * Calculate exponential backoff cooldown for rate limits (429)
5
+ * Level 0: 1s, Level 1: 2s, Level 2: 4s... → max 30 min
6
+ * @param {number} backoffLevel - Current backoff level
7
+ * @returns {number} Cooldown in milliseconds
8
+ */
9
+ export function getQuotaCooldown(backoffLevel = 0) {
10
+ const cooldown = BACKOFF_CONFIG.base * Math.pow(2, backoffLevel);
11
+ return Math.min(cooldown, BACKOFF_CONFIG.max);
12
+ }
13
+
14
+ /**
15
+ * Check if error should trigger account fallback (switch to next account)
16
+ * @param {number} status - HTTP status code
17
+ * @param {string} errorText - Error message text
18
+ * @param {number} backoffLevel - Current backoff level for exponential backoff
19
+ * @returns {{ shouldFallback: boolean, cooldownMs: number, newBackoffLevel?: number }}
20
+ */
21
+ export function checkFallbackError(status, errorText, backoffLevel = 0) {
22
+ // 401 - Authentication error (token expired/invalid)
23
+ if (status === 401) {
24
+ return { shouldFallback: true, cooldownMs: COOLDOWN_MS.unauthorized };
25
+ }
26
+
27
+ // 402/403 - Payment required / Forbidden (quota/permission)
28
+ if (status === 402 || status === 403) {
29
+ return { shouldFallback: true, cooldownMs: COOLDOWN_MS.paymentRequired };
30
+ }
31
+
32
+ // 404 - Model not found (long cooldown)
33
+ if (status === 404) {
34
+ return { shouldFallback: true, cooldownMs: COOLDOWN_MS.notFound };
35
+ }
36
+
37
+ // Check error message FIRST (before status codes) for specific patterns
38
+ if (errorText) {
39
+ const lowerError = errorText.toLowerCase();
40
+
41
+ // "Request not allowed" - short cooldown (5s), takes priority over status code
42
+ if (lowerError.includes("request not allowed")) {
43
+ return { shouldFallback: true, cooldownMs: COOLDOWN_MS.requestNotAllowed };
44
+ }
45
+
46
+ // Rate limit keywords - exponential backoff
47
+ if (
48
+ lowerError.includes("rate limit") ||
49
+ lowerError.includes("too many requests") ||
50
+ lowerError.includes("quota exceeded") ||
51
+ lowerError.includes("capacity") ||
52
+ lowerError.includes("overloaded")
53
+ ) {
54
+ const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel);
55
+ return {
56
+ shouldFallback: true,
57
+ cooldownMs: getQuotaCooldown(backoffLevel),
58
+ newBackoffLevel: newLevel
59
+ };
60
+ }
61
+ }
62
+
63
+ // 429 - Rate limit with exponential backoff
64
+ if (status === 429) {
65
+ const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel);
66
+ return {
67
+ shouldFallback: true,
68
+ cooldownMs: getQuotaCooldown(backoffLevel),
69
+ newBackoffLevel: newLevel
70
+ };
71
+ }
72
+
73
+ // 408/500/502/503/504 - Transient errors (short cooldown)
74
+ if (status === 408 || status === 500 || status === 502 || status === 503 || status === 504) {
75
+ return { shouldFallback: true, cooldownMs: COOLDOWN_MS.transient };
76
+ }
77
+
78
+ return { shouldFallback: false, cooldownMs: 0 };
79
+ }
80
+
81
+ /**
82
+ * Check if account is currently unavailable (cooldown not expired)
83
+ */
84
+ export function isAccountUnavailable(unavailableUntil) {
85
+ if (!unavailableUntil) return false;
86
+ return new Date(unavailableUntil).getTime() > Date.now();
87
+ }
88
+
89
+ /**
90
+ * Calculate unavailable until timestamp
91
+ */
92
+ export function getUnavailableUntil(cooldownMs) {
93
+ return new Date(Date.now() + cooldownMs).toISOString();
94
+ }
95
+
96
+ /**
97
+ * Filter available accounts (not in cooldown)
98
+ */
99
+ export function filterAvailableAccounts(accounts, excludeId = null) {
100
+ const now = Date.now();
101
+ return accounts.filter(acc => {
102
+ if (excludeId && acc.id === excludeId) return false;
103
+ if (acc.rateLimitedUntil) {
104
+ const until = new Date(acc.rateLimitedUntil).getTime();
105
+ if (until > now) return false;
106
+ }
107
+ return true;
108
+ });
109
+ }
110
+
111
+ /**
112
+ * Reset account state when request succeeds
113
+ * Clears cooldown and resets backoff level to 0
114
+ * @param {object} account - Account object
115
+ * @returns {object} Updated account with reset state
116
+ */
117
+ export function resetAccountState(account) {
118
+ if (!account) return account;
119
+ return {
120
+ ...account,
121
+ rateLimitedUntil: null,
122
+ backoffLevel: 0,
123
+ lastError: null,
124
+ status: "active"
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Apply error state to account
130
+ * @param {object} account - Account object
131
+ * @param {number} status - HTTP status code
132
+ * @param {string} errorText - Error message
133
+ * @returns {object} Updated account with error state
134
+ */
135
+ export function applyErrorState(account, status, errorText) {
136
+ if (!account) return account;
137
+
138
+ const backoffLevel = account.backoffLevel || 0;
139
+ const { cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel);
140
+
141
+ return {
142
+ ...account,
143
+ rateLimitedUntil: cooldownMs > 0 ? getUnavailableUntil(cooldownMs) : null,
144
+ backoffLevel: newBackoffLevel ?? backoffLevel,
145
+ lastError: { status, message: errorText, timestamp: new Date().toISOString() },
146
+ status: "error"
147
+ };
148
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Shared combo (model combo) handling with fallback support
3
+ */
4
+
5
+ /**
6
+ * Get combo models from combos data
7
+ * @param {string} modelStr - Model string to check
8
+ * @param {Array|Object} combosData - Array of combos or object with combos
9
+ * @returns {string[]|null} Array of models or null if not a combo
10
+ */
11
+ export function getComboModelsFromData(modelStr, combosData) {
12
+ // Don't check if it's in provider/model format
13
+ if (modelStr.includes("/")) return null;
14
+
15
+ // Handle both array and object formats
16
+ const combos = Array.isArray(combosData) ? combosData : (combosData?.combos || []);
17
+
18
+ const combo = combos.find(c => c.name === modelStr);
19
+ if (combo && combo.models && combo.models.length > 0) {
20
+ return combo.models;
21
+ }
22
+ return null;
23
+ }
24
+
25
+ /**
26
+ * Handle combo chat with fallback
27
+ * @param {Object} options
28
+ * @param {Object} options.body - Request body
29
+ * @param {string[]} options.models - Array of model strings to try
30
+ * @param {Function} options.handleSingleModel - Function to handle single model: (body, modelStr) => Promise<Response>
31
+ * @param {Object} options.log - Logger object
32
+ * @returns {Promise<Response>}
33
+ */
34
+ export async function handleComboChat({ body, models, handleSingleModel, log }) {
35
+ let lastError = null;
36
+
37
+ for (let i = 0; i < models.length; i++) {
38
+ const modelStr = models[i];
39
+ log.info("COMBO", `Trying model ${i + 1}/${models.length}: ${modelStr}`);
40
+
41
+ const result = await handleSingleModel(body, modelStr);
42
+
43
+ // Success (2xx) - return response
44
+ if (result.ok) {
45
+ return result;
46
+ }
47
+
48
+ // 401 unauthorized - return immediately (auth error)
49
+ if (result.status === 401) {
50
+ return result;
51
+ }
52
+
53
+ // 4xx/5xx - try next model
54
+ lastError = `${modelStr}: ${result.statusText || result.status}`;
55
+ log.warn("COMBO", `Model failed, trying next`, { model: modelStr, status: result.status });
56
+ }
57
+
58
+ log.warn("COMBO", "All models failed");
59
+
60
+ // Return 503 with last error
61
+ return new Response(
62
+ JSON.stringify({ error: lastError || "All combo models unavailable" }),
63
+ {
64
+ status: 503,
65
+ headers: { "Content-Type": "application/json" }
66
+ }
67
+ );
68
+ }
69
+