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.
- package/README.md +180 -0
- package/config/constants.js +206 -0
- package/config/defaultThinkingSignature.js +7 -0
- package/config/ollamaModels.js +19 -0
- package/config/providerModels.js +161 -0
- package/handlers/chatCore.js +277 -0
- package/handlers/responsesHandler.js +69 -0
- package/index.js +69 -0
- package/package.json +44 -0
- package/services/accountFallback.js +148 -0
- package/services/combo.js +69 -0
- package/services/compact.js +64 -0
- package/services/model.js +109 -0
- package/services/provider.js +237 -0
- package/services/tokenRefresh.js +542 -0
- package/services/usage.js +398 -0
- package/translator/formats.js +12 -0
- package/translator/from-openai/claude.js +341 -0
- package/translator/from-openai/gemini.js +469 -0
- package/translator/from-openai/openai-responses.js +361 -0
- package/translator/helpers/claudeHelper.js +179 -0
- package/translator/helpers/geminiHelper.js +131 -0
- package/translator/helpers/openaiHelper.js +80 -0
- package/translator/helpers/responsesApiHelper.js +103 -0
- package/translator/helpers/toolCallHelper.js +111 -0
- package/translator/index.js +167 -0
- package/translator/to-openai/claude.js +238 -0
- package/translator/to-openai/gemini.js +151 -0
- package/translator/to-openai/openai-responses.js +140 -0
- package/translator/to-openai/openai.js +371 -0
- package/utils/bypassHandler.js +258 -0
- package/utils/error.js +133 -0
- package/utils/ollamaTransform.js +82 -0
- package/utils/requestLogger.js +217 -0
- package/utils/stream.js +274 -0
- 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
|
+
|