Codex Responses Lite clients routed to a chat-native OpenAI-compatible provider lost tool use in three places: non-streaming Chat responses leaked the raw chat.completion envelope instead of Responses output items, internal reasoning continuity fields leaked into the outbound Chat body causing some upstreams to reject the request, and the Responses to Chat request translator ignored additional_tools, custom_tool_call, and custom_tool_call_output items entirely. Also fixes apiType (chat vs responses) for openai-compatible nodes being resolved from the immutable provider ID instead of the stored node config, so editing a node's API Type had no runtime effect.
453 lines
24 KiB
JavaScript
453 lines
24 KiB
JavaScript
import { detectFormat, getTargetFormat, resolveTransport } from "../services/provider.js";
|
|
import { translateRequest } from "../translator/index.js";
|
|
import { applyThinking, extractThinking, stripThinkingSuffix } from "../translator/concerns/thinkingUnified.js";
|
|
import { FORMATS } from "../translator/formats.js";
|
|
import { normalizeClaudePassthrough } from "../translator/formats/claude.js";
|
|
import { createStreamController } from "../utils/streamHandler.js";
|
|
import { refreshWithRetry } from "../services/tokenRefresh.js";
|
|
import { createRequestLogger } from "../utils/requestLogger.js";
|
|
import { getModelTargetFormat, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
|
|
import { PROVIDERS } from "../config/providers.js";
|
|
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
|
|
import { HTTP_STATUS, TOKEN_SAVER_HEADER } from "../config/runtimeConfig.js";
|
|
import { handleBypassRequest } from "../utils/bypassHandler.js";
|
|
import { trackPendingRequest, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
|
|
import { getExecutor } from "../executors/index.js";
|
|
import { supportsGrokCliReasoningEffort } from "../config/grokCli.js";
|
|
import { buildRequestDetail, extractRequestConfig } from "./chatCore/requestDetail.js";
|
|
import { handleForcedSSEToJson } from "./chatCore/sseToJsonHandler.js";
|
|
import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js";
|
|
import { handleStreamingResponse, buildOnStreamComplete } from "./chatCore/streamingHandler.js";
|
|
import { detectClientTool, isNativePassthrough } from "../utils/clientDetector.js";
|
|
import { dedupeTools } from "../utils/toolDeduper.js";
|
|
import { injectCaveman } from "../rtk/caveman.js";
|
|
import { injectPonytail } from "../rtk/ponytail.js";
|
|
import { compressMessages, formatRtkLog } from "../rtk/index.js";
|
|
import { compressWithHeadroom, formatHeadroomLog, formatHeadroomSizeLog, isHeadroomPhantomSavings } from "../rtk/headroom.js";
|
|
import { compressWithPxpipe } from "../rtk/pxpipe.js";
|
|
import { getCapabilitiesForModel } from "../providers/capabilities.js";
|
|
import { stripUnsupportedModalities } from "../translator/concerns/modality.js";
|
|
import { prefetchRemoteImages } from "../translator/concerns/prefetch.js";
|
|
import { resolveSessionId } from "../utils/sessionManager.js";
|
|
|
|
/**
|
|
* Core chat handler - shared between SSE and Worker
|
|
* @param {object} options.body - Request body
|
|
* @param {object} options.modelInfo - { provider, model }
|
|
* @param {object} options.credentials - Provider credentials
|
|
* @param {string} options.sourceFormatOverride - Override detected source format (e.g. "openai-responses")
|
|
*/
|
|
/**
|
|
* Remove translator-internal continuity fields from the outbound upstream
|
|
* body. The Responses→Chat request translator stashes reasoning
|
|
* `encrypted_content` on assistant messages so a later openai→responses
|
|
* round-trip can restore the store=false continuity blob; that stash must
|
|
* never reach an upstream provider. Chat-native proxies reject the unknown
|
|
* assistant-message field and answer every turn with a literal "400" body
|
|
* (observed with multi-turn Codex sessions via OpenAI-compatible nodes).
|
|
*/
|
|
export function stripContinuityFields(body) {
|
|
if (!body || !Array.isArray(body.messages)) return body;
|
|
for (const msg of body.messages) {
|
|
if (msg && typeof msg === "object") {
|
|
delete msg.encrypted_content;
|
|
delete msg.reasoning_encrypted_content;
|
|
}
|
|
}
|
|
return body;
|
|
}
|
|
|
|
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled, pxpipeMinChars, pxpipeTimeoutMs, pxpipeTransform, onPxpipeEvent, sourceFormatOverride, providerThinking }) {
|
|
const { provider, model } = modelInfo;
|
|
const requestStartTime = Date.now();
|
|
// Stable per-session color so all lines of one CLI conversation share a tag
|
|
const sessionSeed = (() => {
|
|
try {
|
|
return resolveSessionId({ headers: clientRawRequest?.headers, body, connectionId, scope: provider });
|
|
} catch {
|
|
return connectionId || "";
|
|
}
|
|
})();
|
|
const reqTag = log?.tagForSession ? log.tagForSession(sessionSeed) : (log?.nextTag ? log.nextTag() : "");
|
|
|
|
const sourceFormat = sourceFormatOverride || detectFormat(body);
|
|
|
|
// Check for bypass patterns (warmup, skip, cc naming)
|
|
const bypassResponse = handleBypassRequest(body, model, userAgent, ccFilterNaming);
|
|
if (bypassResponse) return bypassResponse;
|
|
|
|
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
|
|
const modelTargetFormat = getModelTargetFormat(alias, model);
|
|
// Multi-endpoint providers: pick transport matching sourceFormat → zero translation
|
|
const runtimeTransport = resolveTransport(provider, sourceFormat);
|
|
const targetFormat = modelTargetFormat || runtimeTransport?.format || getTargetFormat(provider, credentials);
|
|
if (runtimeTransport && credentials) credentials.runtimeTransport = runtimeTransport;
|
|
const stripList = getModelStrip(alias, model);
|
|
const upstreamModel = getModelUpstreamId(alias, model);
|
|
|
|
// Inject provider-level thinking config override (only if client hasn't set)
|
|
// on/off → extended type (body.thinking), none/low/medium/high → effort type (body.reasoning_effort)
|
|
if (providerThinking?.mode && providerThinking.mode !== "auto") {
|
|
const mode = providerThinking.mode;
|
|
if (mode === "on" && !body.thinking) {
|
|
console.log("Injecting provider-level thinking config override: on");
|
|
body = { ...body, thinking: { type: "enabled", budget_tokens: 10000 } };
|
|
} else if (mode === "off" && !body.thinking) {
|
|
body = { ...body, thinking: { type: "disabled" } };
|
|
} else if (!body.reasoning_effort) {
|
|
body = { ...body, reasoning_effort: mode };
|
|
}
|
|
}
|
|
|
|
const clientRequestedStreaming = body.stream === true || sourceFormat === FORMATS.ANTIGRAVITY || sourceFormat === FORMATS.GEMINI || sourceFormat === FORMATS.GEMINI_CLI;
|
|
const providerRequiresStreaming = PROVIDERS[provider]?.forceStream === true;
|
|
let stream = providerRequiresStreaming ? true : (body.stream !== false);
|
|
|
|
// Image generation models require non-streaming (Google v1internal:generateContent)
|
|
const modelType = getModelType(alias, model);
|
|
const isImageGenModel = modelType === "imageGen" || /image|imagen|image-generation/i.test(model);
|
|
if (isImageGenModel && (provider === "antigravity" || provider === "gemini-cli")) {
|
|
stream = false;
|
|
}
|
|
|
|
// DeepSeek-TUI: interactive TUI panel sends stream:true and needs SSE.
|
|
// Non-interactive mode (-p flag) sends without stream and can't parse SSE.
|
|
// Only force non-streaming when client didn't explicitly request it.
|
|
const detectedTool = detectClientTool(clientRawRequest?.headers || {}, body);
|
|
if (detectedTool === "deepseek-tui" && body.stream !== true) stream = false;
|
|
|
|
// Check client Accept header preference for non-streaming requests
|
|
// This fixes AI SDK compatibility where clients send Accept: application/json
|
|
const acceptHeader = clientRawRequest?.headers?.accept || "";
|
|
const clientPrefersJson = acceptHeader.includes("application/json");
|
|
const clientPrefersSSE = acceptHeader.includes("text/event-stream");
|
|
if (clientPrefersJson && !clientPrefersSSE && body.stream !== true && !providerRequiresStreaming) {
|
|
stream = false;
|
|
}
|
|
|
|
const reqLogger = await createRequestLogger(sourceFormat, targetFormat, model);
|
|
if (clientRawRequest) reqLogger.logClientRawRequest(clientRawRequest.endpoint, clientRawRequest.body, clientRawRequest.headers);
|
|
reqLogger.logRawRequest(body);
|
|
log?.debug?.("FORMAT", `${sourceFormat} → ${targetFormat} | stream=${stream}`);
|
|
|
|
// Native passthrough: CLI tool and provider are the same ecosystem
|
|
// Skip all translation/normalization — only model and Bearer are swapped
|
|
const clientTool = detectClientTool(clientRawRequest?.headers || {}, body);
|
|
const passthrough = isNativePassthrough(clientTool, provider);
|
|
|
|
// Expose raw client headers to translators/executors for session-id resolution
|
|
if (credentials) credentials.rawHeaders = clientRawRequest?.headers || {};
|
|
|
|
// Auto-strip media blocks the model can't read (vision/audio/pdf) before translation.
|
|
if (!passthrough) {
|
|
const caps = getCapabilitiesForModel(provider, model);
|
|
if (stripUnsupportedModalities(body, sourceFormat, caps)) {
|
|
log?.debug?.("MODALITY", `stripped unsupported media for ${provider}/${model}`);
|
|
}
|
|
// Convert remote image URLs to base64 for targets that can't fetch URLs.
|
|
try {
|
|
const n = await prefetchRemoteImages(body, sourceFormat, targetFormat, { signal: undefined });
|
|
if (n > 0) log?.debug?.("MODALITY", `prefetched ${n} remote image(s) for ${targetFormat}`);
|
|
} catch (e) { log?.warn?.("MODALITY", `image prefetch failed: ${e.message}`); }
|
|
}
|
|
|
|
let translatedBody;
|
|
let toolNameMap;
|
|
let customToolNames;
|
|
if (passthrough) {
|
|
log?.debug?.("PASSTHROUGH", `${clientTool} → ${provider} | native lossless`);
|
|
translatedBody = { ...body, model: stripThinkingSuffix(upstreamModel) };
|
|
if (provider === "codex") {
|
|
const suffixThinking = {};
|
|
applyThinking(sourceFormat, upstreamModel, suffixThinking, provider);
|
|
if (suffixThinking.reasoning_effort) {
|
|
const reasoning = translatedBody.reasoning;
|
|
translatedBody.reasoning = {
|
|
...(reasoning && typeof reasoning === "object" && !Array.isArray(reasoning) ? reasoning : {}),
|
|
effort: suffixThinking.reasoning_effort,
|
|
};
|
|
delete translatedBody.reasoning_effort;
|
|
}
|
|
}
|
|
// Normalize newer Cowork/CC beta shapes (adaptive thinking, mid-conversation system) the API rejects
|
|
if (clientTool === "claude") normalizeClaudePassthrough(translatedBody, translatedBody.model);
|
|
} else {
|
|
translatedBody = translateRequest(sourceFormat, targetFormat, upstreamModel, body, stream, credentials, provider, reqLogger, stripList, connectionId, clientTool);
|
|
if (!translatedBody) {
|
|
trackPendingRequest(model, provider, connectionId, false, true);
|
|
return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Failed to translate request for ${sourceFormat} → ${targetFormat}`);
|
|
}
|
|
toolNameMap = translatedBody._toolNameMap;
|
|
delete translatedBody._toolNameMap;
|
|
customToolNames = translatedBody._customToolNames;
|
|
delete translatedBody._customToolNames;
|
|
translatedBody.model = stripThinkingSuffix(upstreamModel);
|
|
stripContinuityFields(translatedBody);
|
|
}
|
|
|
|
// Dedupe duplicate built-in tools when equivalent MCP tools are present (Claude clients only).
|
|
if (clientTool === "claude" && Array.isArray(translatedBody.tools)) {
|
|
const { tools: deduped, stripped } = dedupeTools(translatedBody.tools);
|
|
if (stripped.length > 0) {
|
|
translatedBody.tools = deduped;
|
|
log?.debug?.("TOOLDEDUP", `stripped ${stripped.length}: ${stripped.slice(0, 3).join(", ")}${stripped.length > 3 ? "..." : ""}`);
|
|
}
|
|
}
|
|
|
|
// Token savers: applied at the final body just before dispatch
|
|
// Covers both passthrough (source shape) and translated (target shape) flows
|
|
const finalFormat = passthrough ? sourceFormat : targetFormat;
|
|
|
|
// Request line: one correlated summary (fmt + thinking + counts + account)
|
|
if (log?.line) {
|
|
const clientModel = clientRawRequest?.body?.model || `${provider}/${model}`;
|
|
const msgN = translatedBody.messages?.length || translatedBody.input?.length || translatedBody.contents?.length || body.messages?.length || body.input?.length || 0;
|
|
const toolN = translatedBody.tools?.length || body.tools?.length || 0;
|
|
const fmtStr = passthrough ? `FMT: ${sourceFormat} (passthrough)` : `FMT: ${sourceFormat}→${targetFormat}`;
|
|
const showThinking = provider !== "grok-cli" || supportsGrokCliReasoningEffort(model);
|
|
const think = showThinking ? log.fmtThink?.(extractThinking(translatedBody)) : null;
|
|
const acc = credentials?.connectionName || credentials?.connectionId?.slice(0, 8) || "-";
|
|
const parts = [
|
|
`POST ${clientModel} → ${provider}/${model}`,
|
|
fmtStr,
|
|
stream ? "STREAM" : "JSON",
|
|
`${msgN} MSG`,
|
|
];
|
|
if (toolN) parts.push(`${toolN} TOOL`);
|
|
if (think) parts.push(`THINK:${think}`);
|
|
parts.push(`ACC:${acc}`);
|
|
log.line(reqTag, "▶", parts.join(" · "));
|
|
}
|
|
|
|
// TTS models don't support tool messages/function calling
|
|
if (getModelType(alias, model) === "tts" && translatedBody.messages) {
|
|
translatedBody.messages = translatedBody.messages.filter(msg => msg.role !== "tool");
|
|
delete translatedBody.tools;
|
|
}
|
|
|
|
// Per-request opt-out: client can bypass all token savers via header
|
|
const tokenSaverEnabled = clientRawRequest?.headers?.[TOKEN_SAVER_HEADER]?.toLowerCase() !== "off";
|
|
|
|
// RTK: compress tool_result content
|
|
const rtkStats = compressMessages(translatedBody, tokenSaverEnabled && rtkEnabled);
|
|
const rtkLine = formatRtkLog(rtkStats);
|
|
if (rtkLine) console.log(rtkLine);
|
|
|
|
// Headroom: optional external proxy compression; fail open if proxy is absent.
|
|
const headroomDiagnostics = {};
|
|
const headroomStats = await compressWithHeadroom(translatedBody, { enabled: tokenSaverEnabled && headroomEnabled, url: headroomUrl, model: upstreamModel, format: finalFormat, compressUserMessages: headroomCompressUserMessages, diagnostics: headroomDiagnostics });
|
|
const headroomLine = formatHeadroomLog(headroomStats);
|
|
const headroomSizeLine = formatHeadroomSizeLog(headroomDiagnostics);
|
|
if (headroomLine) {
|
|
log?.info?.("HEADROOM", `${headroomLine}${headroomSizeLine ? ` | ${headroomSizeLine}` : ""}`);
|
|
if (isHeadroomPhantomSavings(headroomStats, headroomDiagnostics)) {
|
|
log?.warn?.("HEADROOM", `reported token delta, but outbound JSON shrank <5%; provider may bill near-original payload | ${formatHeadroomSizeLog(headroomDiagnostics)}`);
|
|
}
|
|
} else if (tokenSaverEnabled && headroomEnabled) log?.warn?.("HEADROOM", `skipped: ${headroomDiagnostics.reason || "compression unavailable"}${headroomDiagnostics.endpoint ? ` (${headroomDiagnostics.endpoint})` : ""}`);
|
|
|
|
// Token-saver flags accumulator for the single "⚙" log line below.
|
|
const xf = [];
|
|
|
|
// Caveman: inject terse-style system prompt
|
|
if (tokenSaverEnabled && cavemanEnabled && cavemanLevel) {
|
|
injectCaveman(translatedBody, finalFormat, cavemanLevel);
|
|
xf.push(`CAVEMAN:${cavemanLevel}`);
|
|
}
|
|
|
|
// Ponytail: inject lazy-senior-dev system prompt
|
|
if (tokenSaverEnabled && ponytailEnabled && ponytailLevel) {
|
|
injectPonytail(translatedBody, finalFormat, ponytailLevel);
|
|
xf.push(`PONYTAIL:${ponytailLevel}`);
|
|
}
|
|
|
|
// PXPIPE: image bulky context (Claude-format bodies only), last saver before dispatch
|
|
let pxpipeSummary = null;
|
|
if (pxpipeEnabled) {
|
|
const pxpipeResult = await compressWithPxpipe(translatedBody, {
|
|
enabled: true, format: finalFormat, model: upstreamModel,
|
|
minChars: pxpipeMinChars, timeoutMs: pxpipeTimeoutMs, transform: pxpipeTransform,
|
|
});
|
|
pxpipeSummary = pxpipeResult.summary;
|
|
if (pxpipeResult.body) translatedBody = pxpipeResult.body;
|
|
if (pxpipeSummary?.applied) xf.push(`PXPIPE:${pxpipeSummary.imageCount}img`);
|
|
try { onPxpipeEvent?.({ provider, model, ...pxpipeSummary }); } catch { /* stats must not break requests */ }
|
|
}
|
|
|
|
if (xf.length && log?.line) log.line(reqTag, "⚙", xf.join(" · "));
|
|
|
|
const executor = getExecutor(provider);
|
|
trackPendingRequest(model, provider, connectionId, true);
|
|
appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => { });
|
|
|
|
const msgCount = translatedBody.messages?.length || translatedBody.input?.length || translatedBody.contents?.length || translatedBody.request?.contents?.length || 0;
|
|
log?.debug?.("REQUEST", `${provider.toUpperCase()} | ${model} | ${msgCount} msgs`);
|
|
|
|
const streamController = createStreamController({
|
|
onDisconnect: (reason) => {
|
|
trackPendingRequest(model, provider, connectionId, false);
|
|
if (onDisconnect) onDisconnect(reason);
|
|
},
|
|
onError: () => trackPendingRequest(model, provider, connectionId, false),
|
|
log, provider, model, reqTag
|
|
});
|
|
|
|
const proxyOptions = {
|
|
connectionProxyEnabled: credentials?.providerSpecificData?.connectionProxyEnabled === true,
|
|
connectionProxyUrl: credentials?.providerSpecificData?.connectionProxyUrl || "",
|
|
connectionNoProxy: credentials?.providerSpecificData?.connectionNoProxy || "",
|
|
vercelRelayUrl: credentials?.providerSpecificData?.vercelRelayUrl || "",
|
|
};
|
|
|
|
if (proxyOptions.vercelRelayUrl) {
|
|
const connectionName = credentials?.connectionName || credentials?.connectionId || "unknown";
|
|
const poolId = credentials?.providerSpecificData?.connectionProxyPoolId || "none";
|
|
log?.info?.("PROXY", `${provider.toUpperCase()} | ${model} | conn=${connectionName} | pool=${poolId} | vercel-relay=${proxyOptions.vercelRelayUrl}`);
|
|
} else if (proxyOptions.connectionProxyEnabled && proxyOptions.connectionProxyUrl) {
|
|
let maskedProxyUrl = proxyOptions.connectionProxyUrl;
|
|
try {
|
|
const parsed = new URL(proxyOptions.connectionProxyUrl);
|
|
const host = parsed.hostname || "";
|
|
const port = parsed.port ? `:${parsed.port}` : "";
|
|
const protocol = parsed.protocol || "http:";
|
|
maskedProxyUrl = `${protocol}//${host}${port}`;
|
|
} catch {
|
|
// Keep raw if URL parsing fails
|
|
}
|
|
|
|
const poolId = credentials?.providerSpecificData?.connectionProxyPoolId || "none";
|
|
const connectionName = credentials?.connectionName || credentials?.connectionId || "unknown";
|
|
log?.info?.("PROXY", `${provider.toUpperCase()} | ${model} | conn=${connectionName} | pool=${poolId} | url=${maskedProxyUrl}`);
|
|
}
|
|
|
|
if (proxyOptions.connectionProxyEnabled && proxyOptions.connectionNoProxy) {
|
|
const connectionName = credentials?.connectionName || credentials?.connectionId || "unknown";
|
|
log?.debug?.("PROXY", `${provider.toUpperCase()} | ${model} | conn=${connectionName} | no_proxy=${proxyOptions.connectionNoProxy}`);
|
|
}
|
|
|
|
// Execute request
|
|
let providerResponse, providerUrl, providerHeaders, finalBody;
|
|
// Most executors return their registry format. Cursor AgentService is an
|
|
// exception: it is decoded by the executor into OpenAI-compatible output.
|
|
let providerResponseFormat = targetFormat;
|
|
try {
|
|
const result = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions });
|
|
providerResponse = result.response;
|
|
providerUrl = result.url;
|
|
providerHeaders = result.headers;
|
|
finalBody = result.transformedBody;
|
|
providerResponseFormat = result.responseFormat || targetFormat;
|
|
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
|
|
} catch (error) {
|
|
trackPendingRequest(model, provider, connectionId, false, true);
|
|
appendRequestLog({ model, provider, connectionId, status: `FAILED ${error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY}` }).catch(() => { });
|
|
saveRequestDetail(buildRequestDetail({
|
|
provider, model, connectionId,
|
|
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
|
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
|
request: extractRequestConfig(body, stream),
|
|
providerRequest: translatedBody || null,
|
|
response: { error: error.message || String(error), status: error.name === "AbortError" ? 499 : 502, thinking: null },
|
|
pxpipe: pxpipeSummary,
|
|
status: "error"
|
|
})).catch(() => { });
|
|
|
|
if (error.name === "AbortError") {
|
|
streamController.handleError(error);
|
|
return createErrorResult(499, "Request aborted");
|
|
}
|
|
const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY);
|
|
if (log?.errorLine) {
|
|
log.errorLine(reqTag, "✗", `ERROR 502 · ${provider}/${model} · ${Date.now() - requestStartTime}ms\n ${errMsg}${error.stack ? `\n ${error.stack}` : ""}`);
|
|
}
|
|
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg);
|
|
}
|
|
|
|
// Handle 401/403 - try token refresh (skip for noAuth providers)
|
|
if (!executor.noAuth && (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || providerResponse.status === HTTP_STATUS.FORBIDDEN)) {
|
|
try {
|
|
// Mutate credentials after each successful refresh: rotating refresh_token
|
|
// providers (xAI/grok-cli) issue a new RT on every refresh; without this,
|
|
// refreshWithRetry's 2nd/3rd attempt reuses the already-consumed RT →
|
|
// invalid_grant → auth_failed retryable=false.
|
|
const newCredentials = await refreshWithRetry(async () => {
|
|
const result = await executor.refreshCredentials(credentials, log);
|
|
if (result?.refreshToken && result.refreshToken !== credentials.refreshToken) {
|
|
if (result.accessToken) credentials.accessToken = result.accessToken;
|
|
credentials.refreshToken = result.refreshToken;
|
|
}
|
|
return result;
|
|
}, 3, log);
|
|
if (newCredentials?.accessToken || newCredentials?.copilotToken) {
|
|
if (log?.line) log.line(reqTag, "🔑", `TOKEN REFRESHED · ${provider}/${model}`);
|
|
Object.assign(credentials, newCredentials);
|
|
if (onCredentialsRefreshed) {
|
|
try { await onCredentialsRefreshed(newCredentials); } catch (e) { log?.warn?.("TOKEN", `onCredentialsRefreshed failed: ${e.message}`); }
|
|
}
|
|
try {
|
|
const retryResult = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions });
|
|
if (retryResult.response.ok) {
|
|
providerResponse = retryResult.response;
|
|
providerUrl = retryResult.url;
|
|
providerResponseFormat = retryResult.responseFormat || targetFormat;
|
|
}
|
|
} catch { log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`); }
|
|
} else {
|
|
log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`);
|
|
}
|
|
} catch (e) {
|
|
log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh threw: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
// Provider returned error
|
|
if (!providerResponse.ok) {
|
|
trackPendingRequest(model, provider, connectionId, false, true);
|
|
const { statusCode, message, resetsAtMs } = await parseUpstreamError(providerResponse, executor);
|
|
appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => { });
|
|
saveRequestDetail(buildRequestDetail({
|
|
provider, model, connectionId,
|
|
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
|
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
|
request: extractRequestConfig(body, stream),
|
|
providerRequest: finalBody || translatedBody || null,
|
|
response: { error: message, status: statusCode, thinking: null },
|
|
pxpipe: pxpipeSummary,
|
|
status: "error"
|
|
})).catch(() => { });
|
|
|
|
const errMsg = formatProviderError(new Error(message), provider, model, statusCode);
|
|
if (log?.errorLine) {
|
|
const urlStr = providerUrl ? `\n URL: ${providerUrl}` : "";
|
|
log.errorLine(reqTag, "✗", `ERROR ${statusCode} · ${provider}/${model} · ${Date.now() - requestStartTime}ms${urlStr}\n ${errMsg}`);
|
|
}
|
|
reqLogger.logError(new Error(message), finalBody || translatedBody);
|
|
return createErrorResult(statusCode, errMsg, resetsAtMs);
|
|
}
|
|
|
|
const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, pxpipe: pxpipeSummary, reqTag, log };
|
|
const appendLog = (extra) => appendRequestLog({ model, provider, connectionId, ...extra }).catch(() => { });
|
|
const trackDone = () => trackPendingRequest(model, provider, connectionId, false);
|
|
|
|
// Provider forced streaming but client wants JSON
|
|
if (!clientRequestedStreaming && providerRequiresStreaming) {
|
|
const result = await handleForcedSSEToJson({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, customToolNames, trackDone, appendLog });
|
|
if (result) { streamController.handleComplete(); return result; }
|
|
}
|
|
|
|
// True non-streaming response
|
|
if (!stream) {
|
|
const result = await handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, reqLogger, toolNameMap, customToolNames, trackDone, appendLog });
|
|
streamController.handleComplete();
|
|
return result;
|
|
}
|
|
|
|
// Streaming response
|
|
const { onStreamComplete, streamDetailId } = buildOnStreamComplete({ ...sharedCtx });
|
|
return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, userAgent, reqLogger, toolNameMap, customToolNames, streamController, onStreamComplete, streamDetailId });
|
|
}
|
|
|
|
export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) {
|
|
if (!expiresAt) return false;
|
|
return new Date(expiresAt).getTime() - Date.now() < bufferMs;
|
|
}
|