mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
277 lines
10 KiB
JavaScript
277 lines
10 KiB
JavaScript
import { detectFormat, getTargetFormat, buildProviderUrl, buildProviderHeaders } from "../services/provider.js";
|
|
import { translateRequest, needsTranslation } from "../translator/index.js";
|
|
import { FORMATS } from "../translator/formats.js";
|
|
import { createSSETransformStreamWithLogger, createPassthroughStreamWithLogger, COLORS } from "../utils/stream.js";
|
|
import { createStreamController, pipeWithDisconnect } from "../utils/streamHandler.js";
|
|
import { refreshTokenByProvider, refreshWithRetry } from "../services/tokenRefresh.js";
|
|
import { createRequestLogger } from "../utils/requestLogger.js";
|
|
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
|
|
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
|
|
import { handleBypassRequest } from "../utils/bypassHandler.js";
|
|
|
|
/**
|
|
* Core chat handler - shared between SSE and Worker
|
|
* Returns { success, response, status, error } for caller to handle fallback
|
|
* @param {object} options
|
|
* @param {object} options.body - Request body
|
|
* @param {object} options.modelInfo - { provider, model }
|
|
* @param {object} options.credentials - Provider credentials
|
|
* @param {object} options.log - Logger instance (optional)
|
|
* @param {function} options.onCredentialsRefreshed - Callback when credentials are refreshed
|
|
* @param {function} options.onRequestSuccess - Callback when request succeeds (to clear error status)
|
|
* @param {function} options.onDisconnect - Callback when client disconnects
|
|
*/
|
|
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest }) {
|
|
const { provider, model } = modelInfo;
|
|
|
|
const sourceFormat = detectFormat(body);
|
|
|
|
// Check for bypass patterns (warmup, skip) - return fake response
|
|
const bypassResponse = handleBypassRequest(body, model);
|
|
if (bypassResponse) {
|
|
return bypassResponse;
|
|
}
|
|
|
|
// Detect source format and get target format
|
|
// Model-specific targetFormat takes priority over provider default
|
|
|
|
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
|
|
const modelTargetFormat = getModelTargetFormat(alias, model);
|
|
const targetFormat = modelTargetFormat || getTargetFormat(provider);
|
|
const stream = body.stream !== false;
|
|
|
|
// Create request logger for this session: sourceFormat_targetFormat_model
|
|
const reqLogger = await createRequestLogger(sourceFormat, targetFormat, model);
|
|
|
|
// 0. Log client raw request (before any conversion)
|
|
if (clientRawRequest) {
|
|
reqLogger.logClientRawRequest(
|
|
clientRawRequest.endpoint,
|
|
clientRawRequest.body,
|
|
clientRawRequest.headers
|
|
);
|
|
}
|
|
|
|
// 1. Log raw request from client
|
|
reqLogger.logRawRequest(body);
|
|
|
|
// 1a. Log format detection info
|
|
reqLogger.logFormatInfo({
|
|
sourceFormat,
|
|
targetFormat,
|
|
provider,
|
|
model,
|
|
stream
|
|
});
|
|
|
|
log?.debug?.("FORMAT", `${sourceFormat} → ${targetFormat} | stream=${stream}`);
|
|
|
|
// Translate request
|
|
let translatedBody = body;
|
|
translatedBody = translateRequest(sourceFormat, targetFormat, model, body, stream, credentials, provider);
|
|
|
|
|
|
// Update model in body
|
|
translatedBody.model = model;
|
|
|
|
// Build provider URL and headers
|
|
const providerUrl = buildProviderUrl(provider, model, stream);
|
|
const providerHeaders = buildProviderHeaders(provider, credentials, stream, translatedBody);
|
|
|
|
// 2. Log converted request to provider
|
|
reqLogger.logConvertedRequest(providerUrl, providerHeaders, translatedBody);
|
|
|
|
const msgCount = translatedBody.messages?.length
|
|
|| translatedBody.contents?.length
|
|
|| translatedBody.request?.contents?.length
|
|
|| 0;
|
|
log?.debug?.("REQUEST", `${provider.toUpperCase()} | ${model} | ${msgCount} msgs`);
|
|
|
|
// Log headers (mask sensitive values)
|
|
const safeHeaders = {};
|
|
for (const [key, value] of Object.entries(providerHeaders)) {
|
|
if (key.toLowerCase().includes("auth") || key.toLowerCase().includes("key") || key.toLowerCase().includes("token")) {
|
|
safeHeaders[key] = value ? `${value.slice(0, 10)}...` : "";
|
|
} else {
|
|
safeHeaders[key] = value;
|
|
}
|
|
}
|
|
log?.debug?.("HEADERS", JSON.stringify(safeHeaders));
|
|
|
|
// Create stream controller for disconnect detection
|
|
const streamController = createStreamController({ onDisconnect, log, provider, model });
|
|
|
|
// Make request to provider with abort signal
|
|
let providerResponse;
|
|
try {
|
|
providerResponse = await fetch(providerUrl, {
|
|
method: "POST",
|
|
headers: providerHeaders,
|
|
body: JSON.stringify(translatedBody),
|
|
signal: streamController.signal
|
|
});
|
|
} catch (error) {
|
|
if (error.name === "AbortError") {
|
|
streamController.handleError(error);
|
|
return createErrorResult(499, "Request aborted");
|
|
}
|
|
const errMsg = formatProviderError(error, provider, model);
|
|
console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
|
|
return createErrorResult(502, errMsg);
|
|
}
|
|
|
|
|
|
// Handle 401/403 - try token refresh
|
|
if (providerResponse.status === 401 || providerResponse.status === 403) {
|
|
let newCredentials = null;
|
|
|
|
// GitHub needs special handling - refresh copilotToken using accessToken
|
|
if (provider === "github") {
|
|
const { refreshCopilotToken, refreshGitHubToken } = await import("../services/tokenRefresh.js");
|
|
|
|
// First try refreshing copilotToken using existing accessToken
|
|
let copilotResult = await refreshCopilotToken(credentials.accessToken, log);
|
|
|
|
// If that fails, refresh GitHub accessToken first, then get new copilotToken
|
|
if (!copilotResult && credentials.refreshToken) {
|
|
const githubTokens = await refreshGitHubToken(credentials.refreshToken, log);
|
|
if (githubTokens?.accessToken) {
|
|
credentials.accessToken = githubTokens.accessToken;
|
|
if (githubTokens.refreshToken) {
|
|
credentials.refreshToken = githubTokens.refreshToken;
|
|
}
|
|
copilotResult = await refreshCopilotToken(githubTokens.accessToken, log);
|
|
}
|
|
}
|
|
|
|
if (copilotResult?.token) {
|
|
credentials.copilotToken = copilotResult.token;
|
|
newCredentials = {
|
|
accessToken: credentials.accessToken,
|
|
refreshToken: credentials.refreshToken,
|
|
providerSpecificData: {
|
|
...credentials.providerSpecificData,
|
|
copilotToken: copilotResult.token,
|
|
copilotTokenExpiresAt: copilotResult.expiresAt
|
|
}
|
|
};
|
|
log?.info?.("TOKEN", `${provider.toUpperCase()} | copilotToken refreshed`);
|
|
}
|
|
} else {
|
|
newCredentials = await refreshWithRetry(
|
|
() => refreshTokenByProvider(provider, credentials, log),
|
|
3,
|
|
log
|
|
);
|
|
}
|
|
|
|
if (newCredentials?.accessToken || (provider === "github" && credentials.copilotToken)) {
|
|
if (newCredentials?.accessToken) {
|
|
log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed`);
|
|
credentials.accessToken = newCredentials.accessToken;
|
|
}
|
|
if (newCredentials?.refreshToken) {
|
|
credentials.refreshToken = newCredentials.refreshToken;
|
|
}
|
|
if (newCredentials?.providerSpecificData) {
|
|
credentials.providerSpecificData = {
|
|
...credentials.providerSpecificData,
|
|
...newCredentials.providerSpecificData
|
|
};
|
|
}
|
|
|
|
// Notify caller about refreshed credentials
|
|
if (onCredentialsRefreshed && newCredentials) {
|
|
await onCredentialsRefreshed(newCredentials);
|
|
}
|
|
|
|
// Retry with new credentials
|
|
const newHeaders = buildProviderHeaders(provider, credentials, stream, translatedBody);
|
|
const retryResponse = await fetch(providerUrl, {
|
|
method: "POST",
|
|
headers: newHeaders,
|
|
body: JSON.stringify(translatedBody),
|
|
signal: streamController.signal
|
|
});
|
|
|
|
if (retryResponse.ok) {
|
|
providerResponse = retryResponse;
|
|
}
|
|
} else {
|
|
log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`);
|
|
}
|
|
}
|
|
|
|
// Check provider response - return error info for fallback handling
|
|
if (!providerResponse.ok) {
|
|
const { statusCode, message } = await parseUpstreamError(providerResponse);
|
|
const errMsg = formatProviderError(new Error(message), provider, model);
|
|
console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
|
|
|
|
// Log error with full request body for debugging
|
|
reqLogger.logError(new Error(message), translatedBody);
|
|
|
|
return createErrorResult(statusCode, errMsg);
|
|
}
|
|
|
|
// Non-streaming response
|
|
if (!stream) {
|
|
const responseBody = await providerResponse.json();
|
|
|
|
// Notify success - caller can clear error status if needed
|
|
if (onRequestSuccess) {
|
|
await onRequestSuccess();
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
response: new Response(JSON.stringify(responseBody), {
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Access-Control-Allow-Origin": "*"
|
|
}
|
|
})
|
|
};
|
|
}
|
|
|
|
// Streaming response
|
|
|
|
// Notify success - caller can clear error status if needed
|
|
if (onRequestSuccess) {
|
|
await onRequestSuccess();
|
|
}
|
|
|
|
const responseHeaders = {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
"Connection": "keep-alive",
|
|
"Access-Control-Allow-Origin": "*"
|
|
};
|
|
|
|
// Create transform stream with logger for streaming response
|
|
let transformStream;
|
|
if (needsTranslation(targetFormat, sourceFormat)) {
|
|
transformStream = createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger);
|
|
} else {
|
|
transformStream = createPassthroughStreamWithLogger(provider, reqLogger);
|
|
}
|
|
|
|
// Pipe response through transform with disconnect detection
|
|
const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController);
|
|
|
|
return {
|
|
success: true,
|
|
response: new Response(transformedBody, {
|
|
headers: responseHeaders
|
|
})
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if token is expired or about to expire
|
|
*/
|
|
export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) {
|
|
if (!expiresAt) return false;
|
|
const expiresAtMs = new Date(expiresAt).getTime();
|
|
return expiresAtMs - Date.now() < bufferMs;
|
|
} |