4453 lines
182 KiB
TypeScript
4453 lines
182 KiB
TypeScript
import { injectMemoryAndSkills } from "./chatCore/memorySkillsInjection.ts";
|
|
import { resolveChatCoreRequestSetup } from "./chatCore/requestSetup.ts";
|
|
import { buildFailureUsageRecord } from "./chatCore/failureUsage.ts";
|
|
import { extractSystemRoleMessages } from "./chatCore/claudeSystemRole.ts";
|
|
export { extractSystemRoleMessages } from "./chatCore/claudeSystemRole.ts";
|
|
import { checkIdempotencyCache } from "./chatCore/idempotency.ts";
|
|
import { checkSemanticCache } from "./chatCore/semanticCache.ts";
|
|
import {
|
|
shouldDefaultAllowClassifier,
|
|
buildDefaultAllowClaudeMessage,
|
|
} from "./chatCore/claudeClassifierCompat.ts";
|
|
import { applyClientUsageBuffer } from "./chatCore/clientUsageBuffer.ts";
|
|
import { buildPostCallGuardrailContext } from "./chatCore/postCallGuardrailContext.ts";
|
|
import { storeSemanticCacheResponse } from "./chatCore/semanticCacheStore.ts";
|
|
import { buildNonStreamingResponseHeaders } from "./chatCore/nonStreamingResponseHeaders.ts";
|
|
import { buildNonStreamingJsonResponse } from "./chatCore/nonStreamingJsonResponse.ts";
|
|
import { maybeConvertJsonBodyToSse } from "./chatCore/jsonBodyToSse.ts";
|
|
import { assembleStreamingResponseHeaders } from "./chatCore/streamingResponseHeaders.ts";
|
|
import { storeStreamingSemanticCacheResponse } from "./chatCore/streamingSemanticCacheStore.ts";
|
|
import { assembleStreamingPipeline } from "./chatCore/streamingPipeline.ts";
|
|
import { sanitizeChatRequestBody } from "./chatCore/sanitization.ts";
|
|
import {
|
|
getHeaderValueCaseInsensitive,
|
|
isNoMemoryRequested,
|
|
resolveCompressionHeader,
|
|
isStripReasoningRequested,
|
|
} from "./chatCore/headers.ts";
|
|
import { markCodexScopeRateLimited } from "./chatCore/codexFailover.ts";
|
|
import { trackDevice, extractIpFromHeaders } from "../services/deviceTracker.ts";
|
|
import { getCombosCached } from "./chatCore/comboContextCache.ts";
|
|
export { clearCombosCache, clearUpstreamProxyConfigCache } from "./chatCore/comboContextCache.ts";
|
|
import {
|
|
resolveAccountSemaphoreKey,
|
|
resolveAccountSemaphoreMaxConcurrency,
|
|
buildClaudePromptCacheLogMeta,
|
|
} from "./chatCore/executorHelpers.ts";
|
|
import {
|
|
shouldUseNativeCodexPassthrough,
|
|
redactPassthroughThinkingSignatures,
|
|
isClaudeCodeSemanticPassthroughRequest,
|
|
} from "./chatCore/passthroughHelpers.ts";
|
|
import {
|
|
buildStreamingResponseHeaders,
|
|
materializeDeduplicatedExecutionResult,
|
|
stripNextMiddlewareControlHeaders,
|
|
stripStaleForwardingHeaders,
|
|
} from "./chatCore/responseHeaders.ts";
|
|
import {
|
|
forwardDashboardEventToLiveWs,
|
|
maybeSyncClaudeExtraUsageState,
|
|
} from "./chatCore/telemetryHelpers.ts";
|
|
// Re-export the previously inline-defined helpers so existing importers of these
|
|
// symbols from chatCore.ts (tests, sibling modules) keep resolving after the split.
|
|
export {
|
|
shouldUseNativeCodexPassthrough,
|
|
redactPassthroughThinkingSignatures,
|
|
isClaudeCodeSemanticPassthroughRequest,
|
|
buildStreamingResponseHeaders,
|
|
stripStaleForwardingHeaders,
|
|
};
|
|
import {
|
|
extractMemoryTextFromResponse,
|
|
extractMemoryTextFromRequestBody,
|
|
resolveMemoryOwnerId,
|
|
} from "./chatCore/memoryExtraction.ts";
|
|
import { CORS_HEADERS } from "../utils/cors.ts";
|
|
import { checkHeapPressureGuard } from "../utils/heapPressure.ts";
|
|
import { normalizeHeaders } from "../utils/headers.ts";
|
|
import { resolveChatCoreRequestFormat } from "./chatCore/requestFormat.ts";
|
|
import { resolveChatCoreTargetFormat } from "./chatCore/targetFormat.ts";
|
|
import { defaultClaudeToolType } from "./chatCore/claudeToolDefaults.ts";
|
|
import { injectSystemPrompt, injectCustomSystemPrompt } from "../services/systemPrompt.ts";
|
|
import { translateRequest, needsTranslation } from "../translator/index.ts";
|
|
import { FORMATS } from "../translator/formats.ts";
|
|
import { sanitizeKiroTools } from "../utils/kiroSanitizer.ts";
|
|
import { splitMisplacedToolResults } from "../translator/helpers/claudeHelper.ts";
|
|
import {
|
|
createSSETransformStreamWithLogger,
|
|
createPassthroughStreamWithLogger,
|
|
COLORS,
|
|
withBodyTimeout,
|
|
} from "../utils/stream.ts";
|
|
import { ensureStreamReadiness } from "../utils/streamReadiness.ts";
|
|
import { resolveSuppressThinkClose, THINKING_MARKER_HEADER } from "../utils/thinkCloseMarker.ts";
|
|
import { resolveStreamReadinessTimeout } from "../utils/streamReadinessPolicy.ts";
|
|
import { resolveAgentGoalPolicy } from "../utils/agentGoalPolicy.ts";
|
|
import { createStreamController } from "../utils/streamHandler.ts";
|
|
import * as streamFailure from "../utils/streamFailureFinalization.ts";
|
|
import { createSseHeartbeatTransform, shapeForClientFormat } from "../utils/sseHeartbeat.ts";
|
|
import { addBufferToUsage, filterUsageForFormat, estimateUsage } from "../utils/usageTracking.ts";
|
|
import {
|
|
refreshWithRetry,
|
|
isUnrecoverableRefreshError,
|
|
runWithOnPersist,
|
|
runWithCasGuard,
|
|
} from "../services/tokenRefresh.ts";
|
|
import { createRequestLogger } from "../utils/requestLogger.ts";
|
|
import { createPreparedRequestLogger, runWithCapture } from "../utils/providerRequestLogging.ts";
|
|
import { summarizeToolSources } from "../utils/toolSources.ts";
|
|
import { applyResponsesPreviousResponseIdPolicy } from "../utils/responsesStatePolicy.ts";
|
|
import { applyClaudeEffortVariant } from "./chatCore/claudeEffortVariant.ts";
|
|
import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../config/defaultThinkingSignature.ts";
|
|
import {
|
|
getStripTypesForProviderModel,
|
|
stripIncompatibleMessageContent,
|
|
} from "../services/modelStrip.ts";
|
|
import { resolveModelAlias } from "../services/modelDeprecation.ts";
|
|
import { normalizeMimoThinking } from "../services/mimoThinking.ts";
|
|
import { normalizeClaudeAdaptiveThinking } from "../services/claudeAdaptiveThinking.ts";
|
|
import { normalizeClaudeHaikuConstraints } from "../services/claudeHaikuConstraints.ts";
|
|
import { echoModelInObject } from "../services/responseModelEcho.ts";
|
|
import { stripGpt5SamplingWhenReasoning } from "../services/gpt5SamplingGuard.ts";
|
|
import { getUnsupportedParams, REGISTRY } from "../config/providerRegistry.ts";
|
|
import { supportsMaxTokens } from "@/lib/modelCapabilities.ts";
|
|
import { normalizeThinkingForModel } from "@/shared/constants/modelSpecs.ts";
|
|
import {
|
|
buildErrorBody,
|
|
createErrorResult,
|
|
parseUpstreamError,
|
|
formatProviderError,
|
|
sanitizeErrorMessage,
|
|
} from "../utils/error.ts";
|
|
import { reportMalformed200, detectMalformedNonStream } from "../utils/diagnostics.ts";
|
|
import {
|
|
checkTokenLimits,
|
|
recordTokenUsage,
|
|
} from "@omniroute/open-sse/services/tokenLimitCounter.ts";
|
|
import {
|
|
COOLDOWN_MS,
|
|
HTTP_STATUS,
|
|
FETCH_BODY_TIMEOUT_MS,
|
|
PROVIDER_MAX_TOKENS,
|
|
STREAM_IDLE_TIMEOUT_MS,
|
|
STREAM_READINESS_MAX_TIMEOUT_MS,
|
|
STREAM_READINESS_TIMEOUT_MS,
|
|
ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE,
|
|
STREAM_RECOVERY,
|
|
} from "../config/constants.ts";
|
|
import { createRecoverableStream, makeContinuationBody } from "../services/streamRecovery.ts";
|
|
import {
|
|
resolveResilienceSettings,
|
|
isStreamRecoveryExplicitlyConfigured,
|
|
} from "@/lib/resilience/settings";
|
|
import {
|
|
classifyProviderError,
|
|
PROVIDER_ERROR_TYPES,
|
|
isEmptyContentResponse,
|
|
} from "../services/errorClassifier.ts";
|
|
import { updateProviderConnection, getProviderConnectionById } from "@/lib/db/providers";
|
|
import { wasRefreshTokenRotated } from "@omniroute/open-sse/services/refreshSerializer.ts";
|
|
import { connectionHasExtraKeys } from "../services/apiKeyRotator.ts";
|
|
import { recordKeyHealthStatus as recordKeyHealthStatusFor } from "./chatCore/keyHealth.ts";
|
|
import { getSkillsModelIdForFormat } from "./chatCore/skillsFormat.ts";
|
|
import { readNonStreamingResponseBody } from "./chatCore/nonStreamingResponseBody.ts";
|
|
import {
|
|
isSemaphoreCapacityError,
|
|
createStreamingErrorResult,
|
|
getUpstreamErrorIdentifier,
|
|
} from "./chatCore/streamErrorResult.ts";
|
|
import { wrapReadableStreamWithFinalize } from "./chatCore/streamFinalize.ts";
|
|
import { buildCacheUsageLogMeta } from "./chatCore/cacheUsageMeta.ts";
|
|
import { buildExecutorClientHeaders } from "./chatCore/executorClientHeaders.ts";
|
|
import { resolveExecutionCredentials as resolveExecutionCredentialsFor } from "./chatCore/executionCredentials.ts";
|
|
import { resolveExecutorWithProxy as resolveExecutorWithProxyFor } from "./chatCore/executorProxy.ts";
|
|
import type { ClaudeMessage } from "./chatCore/claudeMessageTypes.ts";
|
|
import { normalizeClaudeUpstreamMessages as normalizeClaudeUpstreamMessagesFor } from "./chatCore/claudeUpstreamMessages.ts";
|
|
import {
|
|
persistAttemptLogs as persistAttemptLogsFor,
|
|
type PersistAttemptLogsArgs,
|
|
} from "./chatCore/attemptLogging.ts";
|
|
import { stageTrace } from "./chatCore/stageTrace.ts";
|
|
import { attachCompressionUsageReceiptAfterAnalytics as attachCompressionUsageReceiptAfterAnalyticsFor } from "./chatCore/compressionUsageReceipt.ts";
|
|
import { prepareUpstreamBody } from "./chatCore/upstreamBody.ts";
|
|
import { getQuotaScopeLabelForProvider } from "../services/antigravityQuotaFamily.ts";
|
|
|
|
import {
|
|
getCallLogPipelineCaptureStreamChunks,
|
|
getCallLogPipelineMaxSizeBytes,
|
|
} from "@/lib/logEnv";
|
|
import { logAuditEvent } from "@/lib/compliance";
|
|
import { emit } from "@/lib/events/eventBus";
|
|
import { adaptBodyForCompression } from "../services/compression/bodyAdapter.ts";
|
|
import { ensureEngineBreakdown } from "../services/compression/engineBreakdown.ts";
|
|
import { handleBypassRequest } from "../utils/bypassHandler.ts";
|
|
import { saveRequestUsage, trackPendingRequest, appendRequestLog } from "@/lib/usageDb";
|
|
import { finalizePendingScope, updatePendingScope } from "@/lib/usage/pendingRequestScope";
|
|
import { recordCost } from "@/domain/costRules";
|
|
import { calculateCost } from "@/lib/usage/costCalculator";
|
|
import {
|
|
buildClaudePassthroughToolNameMap,
|
|
restoreClaudePassthroughToolNames,
|
|
mergeResponseToolNameMap,
|
|
} from "./chatCore/passthroughToolNames.ts";
|
|
import { resolveCompressionSettings } from "./chatCore/compressionSettings.ts";
|
|
import {
|
|
isBuiltinStackedPipeline,
|
|
isStackedCompressionCombo,
|
|
type RuntimeCompressionCombo,
|
|
} from "./chatCore/compressionComboPredicates.ts";
|
|
import { emitOutputStyleTelemetry } from "./chatCore/outputStyleTelemetry.ts";
|
|
import {
|
|
writeCompressionAnalytics,
|
|
writeCompressionSkip,
|
|
} from "./chatCore/compressionAnalyticsWrite.ts";
|
|
import { runPluginOnRequestHook } from "./chatCore/pluginOnRequest.ts";
|
|
import { recordContextEditingTelemetryHook } from "./chatCore/contextEditingTelemetry.ts";
|
|
import { recordCompressionCacheStats } from "./chatCore/compressionCacheStats.ts";
|
|
import { writeCavemanOutputAnalytics } from "./chatCore/cavemanOutputAnalytics.ts";
|
|
import { scheduleQuotaShareConsumption } from "./chatCore/quotaShareConsumption.ts";
|
|
import { emitRequestGamificationEvent } from "./chatCore/gamificationEvent.ts";
|
|
import { runPluginOnResponseHook } from "./chatCore/pluginOnResponse.ts";
|
|
import { scheduleStreamingQuotaShareConsumption } from "./chatCore/streamingQuotaShare.ts";
|
|
import { recordStreamingUsageStats } from "./chatCore/streamingUsageStats.ts";
|
|
import { recordStreamingCost } from "./chatCore/streamingCost.ts";
|
|
import {
|
|
appendNonStreamingSseTerminalSignal,
|
|
type NonStreamingSseTerminalState,
|
|
} from "./chatCore/nonStreamingSse.ts";
|
|
import { parseNonStreamingResponseBody } from "./chatCore/nonStreamingResponseParse.ts";
|
|
import { unwrapClinepassEnvelope } from "../utils/clinepassEnvelope.ts";
|
|
import { recordNonStreamingUsageStats } from "./chatCore/nonStreamingUsageStats.ts";
|
|
import {
|
|
createBodyTimeoutError,
|
|
readStreamChunkWithTimeout,
|
|
computeBillableTokens,
|
|
normalizeExecutorResult,
|
|
executeWithUpstreamStartTimeout,
|
|
} from "./chatCore/upstreamTimeouts.ts";
|
|
import { getModelNormalizeToolCallId, getModelPreserveOpenAIDeveloperRole } from "@/lib/localDb";
|
|
import { getProviderCredentials, extractSessionAffinityKey } from "@/sse/services/auth";
|
|
import { deleteSessionAccountAffinity } from "@/lib/db/sessionAccountAffinity";
|
|
import { getCacheControlSettings } from "@/lib/cacheControlSettings";
|
|
import { guardrailRegistry } from "@/lib/guardrails";
|
|
import { shouldPreserveCacheControl } from "../utils/cacheControlPolicy.ts";
|
|
import { getCachedSettings } from "@/lib/db/readCache";
|
|
import { applyCodexGlobalFastServiceTier } from "@/lib/providers/codexFastTier";
|
|
import { buildUpstreamHeadersForExecute as buildUpstreamHeadersForExecuteFor } from "./chatCore/upstreamExecuteHeaders.ts";
|
|
import {
|
|
resolveEffectiveServiceTier as resolveEffectiveServiceTierFor,
|
|
resolveReportedServiceTier as resolveReportedServiceTierFor,
|
|
type EffectiveServiceTier,
|
|
} from "./chatCore/serviceTier.ts";
|
|
import { cacheReasoningFromAssistantMessage } from "../services/reasoningCache.ts";
|
|
import { sanitizeOpenAITool } from "../services/toolSchemaSanitizer.ts";
|
|
import {
|
|
setDetectedToolLimit,
|
|
parseToolLimitFromError,
|
|
shouldDetectLimit,
|
|
} from "../services/toolLimitDetector.ts";
|
|
|
|
import { isCompactResponsesEndpoint } from "../executors/codex.ts";
|
|
import { buildCodexQuotaPersistence } from "./chatCore/codexQuota.ts";
|
|
import { invalidateCodexQuotaCache } from "../services/codexQuotaFetcher.ts";
|
|
import { translateNonStreamingResponse } from "./responseTranslator.ts";
|
|
import { unwrapClineNonStreamingEnvelope } from "./chatCore/clineResponseEnvelope.ts";
|
|
import { extractUsageFromResponse } from "./usageExtractor.ts";
|
|
import {
|
|
sanitizeOpenAIResponse,
|
|
sanitizeResponsesApiResponse,
|
|
shouldParseTextualReasoningTags,
|
|
} from "./responseSanitizer.ts";
|
|
import {
|
|
withRateLimit,
|
|
updateFromHeaders,
|
|
updateFromResponseBody,
|
|
initializeRateLimits,
|
|
} from "../services/rateLimitManager.ts";
|
|
import {
|
|
acquire as acquireAccountSemaphore,
|
|
markBlocked as markAccountSemaphoreBlocked,
|
|
} from "../services/accountSemaphore.ts";
|
|
import { lockModel, lockModelIfPerModelQuota } from "../services/accountFallback.ts";
|
|
import {
|
|
generateSignature,
|
|
getCachedResponse,
|
|
setCachedResponse,
|
|
isCacheableForRead,
|
|
isCacheableForWrite,
|
|
} from "@/lib/semanticCache";
|
|
import { saveIdempotency } from "@/lib/idempotencyLayer";
|
|
import {
|
|
isModelUnavailableError,
|
|
getNextFamilyFallback,
|
|
isContextOverflowError,
|
|
findLargerContextModel,
|
|
getModelFamily,
|
|
} from "../services/modelFamilyFallback.ts";
|
|
import { computeRequestHash, deduplicate, shouldDeduplicate } from "../services/requestDedup.ts";
|
|
import {
|
|
compressContext,
|
|
estimateTokens,
|
|
getTokenLimit,
|
|
resolveComboContextLimit,
|
|
} from "../services/contextManager.ts";
|
|
import { resolveBackgroundTaskRedirect } from "./chatCore/backgroundRedirect.ts";
|
|
import type { CompressionConfig, CompressionPipelineStep } from "../services/compression/types.ts";
|
|
import { prepareWebSearchFallbackBody } from "../services/webSearchFallback.ts";
|
|
import {
|
|
resolveExplicitStreamAlias,
|
|
resolveStreamFlag,
|
|
stripMarkdownCodeFence,
|
|
} from "../utils/aiSdkCompat.ts";
|
|
import { generateRequestId } from "@/shared/utils/requestId";
|
|
import { extractFacts } from "@/lib/memory/extraction";
|
|
import { handleToolCallExecution } from "@/lib/skills/interception";
|
|
import { OMNIROUTE_RESPONSE_HEADERS } from "@/shared/constants/headers";
|
|
import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults";
|
|
import {
|
|
buildClaudeCodeCompatibleRequest,
|
|
isClaudeCodeCompatibleProvider,
|
|
resolveClaudeCodeCompatibleSessionId,
|
|
} from "../services/claudeCodeCompatible.ts";
|
|
import { setGeminiThoughtSignatureMode } from "../services/geminiThoughtSignatureStore.ts";
|
|
import { fetchLiveProviderLimits } from "@/lib/usage/providerLimits";
|
|
import { isClaudeExtraUsageBlockEnabled } from "@/lib/providers/claudeExtraUsage";
|
|
import {
|
|
classifyModelScope429,
|
|
getModelScopeRetryDelayMs,
|
|
isModelScopeProvider,
|
|
} from "../services/modelscopePolicy.ts";
|
|
import { incrementRequestCount } from "../services/geminiRateLimitTracker.ts";
|
|
|
|
// ── Global memory pressure guard ────────────────────────────────────────
|
|
// Prevents OOM by rejecting new requests when V8 heap exceeds threshold.
|
|
// Self-healing: no counters to leak, no cleanup needed. The threshold
|
|
// auto-calibrates to 85% of the actual V8 heap ceiling (see heapPressure.ts) so
|
|
// it tracks --max-old-space-size across 1GB/2GB/large VPS instead of a fixed
|
|
// 200MB that sat below the app's own ~260MB baseline and rejected every request.
|
|
|
|
import { isSmallEnoughForSemanticCache } from "../utils/estimateSize.ts";
|
|
|
|
/**
|
|
* 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
|
|
* @param {string} options.connectionId - Connection ID for usage tracking
|
|
* @param {object} options.apiKeyInfo - API key metadata for usage attribution
|
|
* @param {string} options.userAgent - Client user agent for caching decisions
|
|
* @param {string} options.comboName - Combo name if this is a combo request
|
|
* @param {string} options.comboStrategy - Combo routing strategy (e.g., 'priority', 'cost-optimized')
|
|
* @param {boolean} options.isCombo - Whether this request is from a combo
|
|
* @param {string} options.connectionId - Connection ID for settings lookup
|
|
*/
|
|
|
|
// extractSystemRoleMessages extracted to chatCore/claudeSystemRole.ts (#3501); re-exported above so
|
|
// existing importers (e.g. tests/unit/system-role-extraction.test.ts) keep resolving it from here.
|
|
|
|
export async function handleChatCore({
|
|
body,
|
|
modelInfo,
|
|
credentials,
|
|
log,
|
|
onCredentialsRefreshed,
|
|
onRequestSuccess,
|
|
onStreamFailure,
|
|
onDisconnect,
|
|
clientRawRequest,
|
|
connectionId,
|
|
apiKeyInfo = null,
|
|
userAgent,
|
|
comboName,
|
|
comboStrategy = null,
|
|
isCombo = false,
|
|
comboStepId = null,
|
|
comboExecutionKey = null,
|
|
cachedSettings = null,
|
|
skipUpstreamRetry = false,
|
|
createPiiTransform = null,
|
|
correlationId = null,
|
|
modelPinned = false,
|
|
}) {
|
|
let { provider, model, extendedContext } = modelInfo;
|
|
// ── Memory pressure guard ────────────────────────────────────────────
|
|
// Reject early if V8 heap is already near the 256MB limit. Prevents
|
|
// cascading OOM when many large-context requests arrive concurrently.
|
|
try {
|
|
const heapUsedMB = process.memoryUsage().heapUsed / (1024 * 1024);
|
|
const heapGuard = checkHeapPressureGuard(heapUsedMB);
|
|
if (heapGuard) return heapGuard;
|
|
} catch {
|
|
/* memoryUsage() never throws */
|
|
}
|
|
|
|
// Per-request model-routing metadata (first extracted slice of the request-setup phase).
|
|
const { apiFormat, customModelTargetFormat, requestedModel } = resolveChatCoreRequestSetup(
|
|
modelInfo,
|
|
body,
|
|
model
|
|
);
|
|
const isModelScope = () => isModelScopeProvider(provider, credentials?.providerSpecificData);
|
|
const startTime = Date.now();
|
|
// Per-request trace id + checkpoint helper. Lets us see exactly which await
|
|
// a hung request was sitting on in `[STAGE_TRACE]` log lines. Uses crypto RNG
|
|
// (not Math.random) purely to satisfy CodeQL js/insecure-randomness — this id
|
|
// is a log-correlation token, not a security secret.
|
|
const traceId = globalThis.crypto.randomUUID().slice(0, 6);
|
|
|
|
// Emit request.started event for real-time dashboard
|
|
setImmediate(() => {
|
|
emit("request.started", {
|
|
id: traceId,
|
|
model: model || "unknown",
|
|
provider: provider || "unknown",
|
|
timestamp: startTime,
|
|
comboName: comboName || undefined,
|
|
});
|
|
});
|
|
const traceEnabled = process.env.OMNIROUTE_TRACE === "true" || process.env.DEBUG === "true";
|
|
// Stage trace extracted to chatCore/stageTrace.ts (#3501); bind the per-request inputs once so the
|
|
// call sites stay byte-identical.
|
|
const trace = (label: string, extra?: Record<string, unknown>) =>
|
|
stageTrace(label, extra, { traceEnabled, startTime, traceId, log });
|
|
const getCurrentConnectionId = () => {
|
|
const credentialConnectionId =
|
|
typeof credentials?.connectionId === "string" && credentials.connectionId.trim().length > 0
|
|
? credentials.connectionId.trim()
|
|
: null;
|
|
return credentialConnectionId || connectionId || null;
|
|
};
|
|
let tokensCompressed: number | null = null;
|
|
body = injectSystemPrompt(body);
|
|
// ── Per-endpoint custom system prompt (port of upstream #2063) ──
|
|
// Reads from cachedSettings if available (passed in from combo/chat layer)
|
|
// to avoid an extra DB read on the hot path. Falls through to getCachedSettings()
|
|
// only when this function is called outside the normal chat dispatch.
|
|
{
|
|
const _s = cachedSettings ?? (await getCachedSettings());
|
|
if (
|
|
_s.customSystemPromptEnabled === true &&
|
|
typeof _s.customSystemPrompt === "string" &&
|
|
_s.customSystemPrompt
|
|
) {
|
|
body = injectCustomSystemPrompt(body as Record<string, unknown>, _s.customSystemPrompt);
|
|
log?.debug?.("CUSTOMSP", "custom system prompt injected");
|
|
}
|
|
}
|
|
// ── Plugin onRequest hook ──
|
|
// Dynamic import cached by Node.js after first call — minimal overhead
|
|
const pluginGate = await runPluginOnRequestHook({
|
|
requestId: traceId,
|
|
body,
|
|
model,
|
|
provider,
|
|
apiKeyInfo,
|
|
log,
|
|
});
|
|
if (pluginGate.blocked) {
|
|
return {
|
|
success: false,
|
|
status: 403,
|
|
error: "Request blocked by plugin",
|
|
response: pluginGate.response,
|
|
};
|
|
}
|
|
if (pluginGate.body) {
|
|
body = pluginGate.body;
|
|
}
|
|
// Per-API-key device/connection tracking (port of upstream 9router#931,
|
|
// thanks @mugnimaestra). In-memory only, never blocks the request path.
|
|
if (apiKeyInfo?.id) {
|
|
trackDevice(
|
|
apiKeyInfo.id,
|
|
extractIpFromHeaders(clientRawRequest?.headers ?? null),
|
|
userAgent ?? null
|
|
);
|
|
}
|
|
const agentGoalPolicy = resolveAgentGoalPolicy(body, clientRawRequest?.headers ?? null);
|
|
if (agentGoalPolicy.detected) {
|
|
log?.debug?.(
|
|
"AGENT_GOAL",
|
|
`long-running goal mode enabled: readinessMax=${agentGoalPolicy.readinessMaxTimeoutMs}ms streamRecovery=${agentGoalPolicy.streamRecoveryEnabled}`
|
|
);
|
|
}
|
|
|
|
let effectiveServiceTier: EffectiveServiceTier = "standard";
|
|
// Codex service-tier resolvers extracted to chatCore/serviceTier.ts (#3501); bind the per-request
|
|
// provider/credentials once and delegate so the existing call sites stay byte-identical.
|
|
const resolveEffectiveServiceTier = (requestBody?: unknown): EffectiveServiceTier =>
|
|
resolveEffectiveServiceTierFor(provider, credentials?.providerSpecificData, requestBody);
|
|
const resolveReportedServiceTier = (
|
|
payload?: unknown,
|
|
maxDepth = 3
|
|
): EffectiveServiceTier | null => resolveReportedServiceTierFor(provider, payload, maxDepth);
|
|
// Failure usage record building extracted to chatCore/failureUsage.ts (#3501); the handler keeps
|
|
// the fire-and-forget save + computes latencyMs, so the call sites stay byte-identical.
|
|
const persistFailureUsage = (statusCode: number, errorCode?: string | null) => {
|
|
saveRequestUsage(
|
|
buildFailureUsageRecord({
|
|
provider,
|
|
model,
|
|
connectionId: getCurrentConnectionId(),
|
|
apiKeyInfo,
|
|
effectiveServiceTier,
|
|
isCombo,
|
|
comboStrategy,
|
|
statusCode,
|
|
errorCode,
|
|
latencyMs: Date.now() - startTime,
|
|
endpoint: endpointPath,
|
|
})
|
|
).catch(() => {});
|
|
};
|
|
|
|
// Key-health updater extracted to chatCore/keyHealth.ts (#3501); bind the per-request log once
|
|
// and delegate so the existing call sites stay byte-identical.
|
|
const recordKeyHealthStatus = (
|
|
status: number,
|
|
creds: Record<string, unknown> | null | undefined
|
|
): void => recordKeyHealthStatusFor(status, creds, log);
|
|
|
|
const persistCodexQuotaState = async (headers: Record<string, string> | null, status = 0) => {
|
|
const currentConnectionId = getCurrentConnectionId();
|
|
if (provider !== "codex" || !currentConnectionId || !headers) return;
|
|
|
|
try {
|
|
const existingProviderData =
|
|
credentials?.providerSpecificData && typeof credentials.providerSpecificData === "object"
|
|
? (credentials.providerSpecificData as Record<string, unknown>)
|
|
: {};
|
|
// Pure payload build extracted to chatCore/codexQuota.ts (#3501). Returns null when the
|
|
// response carries no quota headers (nothing to persist).
|
|
const built = buildCodexQuotaPersistence({
|
|
headers,
|
|
existingProviderData,
|
|
modelForScope: model || requestedModel || "",
|
|
status,
|
|
});
|
|
if (!built) return;
|
|
|
|
if (built.exhaustionLog) {
|
|
log?.debug?.("CODEX", built.exhaustionLog);
|
|
}
|
|
|
|
// Invalidate the preflight cache for this connection so the next
|
|
// isModelAvailable check fetches fresh quota data.
|
|
if (status === 429) {
|
|
invalidateCodexQuotaCache(currentConnectionId);
|
|
}
|
|
|
|
await updateProviderConnection(currentConnectionId, {
|
|
providerSpecificData: built.nextProviderData,
|
|
});
|
|
|
|
credentials.providerSpecificData = built.nextProviderData;
|
|
} catch (err) {
|
|
const errMessage = err instanceof Error ? err.message : String(err);
|
|
log?.debug?.("CODEX", `Failed to persist codex quota state: ${errMessage}`);
|
|
}
|
|
};
|
|
|
|
// ── Phase 9.2: Idempotency check ──
|
|
// Resolve the idempotency key once here and reuse it at the Phase 9.2 save site below,
|
|
// rather than re-deriving it. (#3821-review LEDGER-6)
|
|
const { hit: idempotencyHit, idempotencyKey } = await checkIdempotencyCache({
|
|
clientRawRequest,
|
|
provider,
|
|
model,
|
|
effectiveServiceTier,
|
|
startTime,
|
|
log,
|
|
});
|
|
if (idempotencyHit) {
|
|
return idempotencyHit;
|
|
}
|
|
|
|
// T07: Inject connectionId into credentials so executors can rotate API keys
|
|
// using providerSpecificData.extraApiKeys (API Key Round-Robin feature)
|
|
if (connectionId && credentials && !credentials.connectionId) {
|
|
credentials.connectionId = connectionId;
|
|
}
|
|
|
|
// Endpoint/format resolution extracted to chatCore/requestFormat.ts (#3501); pure derivation
|
|
// from the inbound request, destructured so every downstream use stays byte-identical.
|
|
const {
|
|
endpointPath,
|
|
sourceFormat,
|
|
isResponsesEndpoint,
|
|
nativeCodexPassthrough,
|
|
isDroidCLI,
|
|
isOpencodeClient,
|
|
copilotCompatibleReasoning,
|
|
clientResponseFormat,
|
|
} = resolveChatCoreRequestFormat({ clientRawRequest, body, provider, userAgent });
|
|
|
|
// Check for bypass patterns (warmup, skip) - return fake response
|
|
const bypassResponse = handleBypassRequest(body, model, userAgent);
|
|
if (bypassResponse) {
|
|
return bypassResponse;
|
|
}
|
|
|
|
// ── Claude Code auto-mode classifier compat (opt-in, default "off") ──
|
|
// Claude Code's `--permission-mode auto` sends an internal classifier request that
|
|
// requires the response to START with `<block>no</block>`/`<block>yes</block>`.
|
|
// When a combo/fallback route sends that call to a cheap model returning 200 with
|
|
// empty content, Claude Code fails closed on every gated action. Detect the
|
|
// classifier request and short-circuit with a synthetic ALLOW response, WITHOUT
|
|
// calling the upstream provider. See chatCore/claudeClassifierCompat.ts.
|
|
{
|
|
const classifierSettings = cachedSettings ?? (await getCachedSettings());
|
|
if (
|
|
shouldDefaultAllowClassifier(
|
|
sourceFormat,
|
|
body as Record<string, unknown>,
|
|
classifierSettings.claudeClassifierCompat as string | undefined
|
|
)
|
|
) {
|
|
log?.warn?.(
|
|
"CHAT",
|
|
`classifier compat=${classifierSettings.claudeClassifierCompat} | short-circuit default-allow`
|
|
);
|
|
return buildDefaultAllowClaudeMessage(requestedModel);
|
|
}
|
|
}
|
|
|
|
// Detect source format and get target format
|
|
// Model-specific targetFormat takes priority over provider default
|
|
|
|
// ── Background Task Redirection (T41) — decision extracted to chatCore/backgroundRedirect.ts (#3501)
|
|
// backgroundReason is the detection signal (threaded into memory/skills injection below); redirect
|
|
// is the actual model downgrade to apply, if any.
|
|
const { backgroundReason, redirect: bgRedirect } = resolveBackgroundTaskRedirect({
|
|
body,
|
|
headers: clientRawRequest?.headers,
|
|
model,
|
|
});
|
|
if (bgRedirect) {
|
|
const originalModel = model;
|
|
log?.info?.(
|
|
"BACKGROUND",
|
|
`Background task redirect (${bgRedirect.reason}): ${originalModel} → ${bgRedirect.degradedModel}`
|
|
);
|
|
model = bgRedirect.degradedModel;
|
|
if (body && typeof body === "object") {
|
|
body.model = model;
|
|
}
|
|
|
|
logAuditEvent({
|
|
action: "routing.background_task_redirect",
|
|
actor: apiKeyInfo?.name || "system",
|
|
target: connectionId || provider || "chat",
|
|
details: {
|
|
original_model: originalModel,
|
|
redirected_to: bgRedirect.degradedModel,
|
|
reason: bgRedirect.reason,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Apply custom model aliases (Settings → Model Aliases → Pattern→Target) before routing (#315, #472)
|
|
// Custom aliases take priority over built-in and must be resolved here so the
|
|
// downstream getModelTargetFormat() lookup AND the actual provider request use
|
|
// the correct, aliased model ID. Without this, aliases only affect format detection.
|
|
const resolvedModel = resolveModelAlias(model);
|
|
// Use resolvedModel for all downstream operations (routing, provider requests, logging)
|
|
let effectiveModel = resolvedModel === model ? model : resolvedModel;
|
|
if (resolvedModel !== model) {
|
|
log?.info?.("ALIAS", `Model alias applied: ${model} → ${resolvedModel}`);
|
|
}
|
|
|
|
// Effort-variant model ids: the Claude / Claude-Code model picker (e.g. VS Code's
|
|
// "Effort" slider) advertises claude-...-{low,medium,high,xhigh,max}. Anthropic has
|
|
// no such model, so the suffixed id 404s upstream. Strip it back to the real base id
|
|
// (forwarded as the upstream model via finalModelToUpstream below) and surface the
|
|
// level as reasoning_effort so the OpenAI→Claude translator / Claude-Code bridge turn
|
|
// it into Claude thinking/effort config. An explicit client-supplied effort always
|
|
// wins; native Claude passthrough is left untouched (it carries its own `thinking`),
|
|
// and non-thinking base models are cleaned up later by normalizeThinkingForModel().
|
|
// Extracted to chatCore/claudeEffortVariant.ts (#3501); mutates body in place and returns the
|
|
// stripped model + an optional log line, keeping behaviour byte-identical.
|
|
{
|
|
const effortVariant = applyClaudeEffortVariant({
|
|
provider,
|
|
effectiveModel,
|
|
body,
|
|
sourceFormat,
|
|
});
|
|
effectiveModel = effortVariant.effectiveModel;
|
|
if (effortVariant.log) {
|
|
log?.info?.("PARAMS", effortVariant.log);
|
|
}
|
|
}
|
|
|
|
// Wire target-format resolution extracted to chatCore/targetFormat.ts (#3501); `alias` is reused
|
|
// downstream when stripping the alias/ prefix off the upstream model id.
|
|
const { alias, targetFormat } = resolveChatCoreTargetFormat({
|
|
provider,
|
|
resolvedModel,
|
|
apiFormat,
|
|
customModelTargetFormat,
|
|
providerSpecificData: credentials?.providerSpecificData,
|
|
});
|
|
|
|
const initialProviderRequest =
|
|
body && typeof body === "object" && !Array.isArray(body)
|
|
? {
|
|
...(body as Record<string, unknown>),
|
|
model:
|
|
typeof (body as Record<string, unknown>).model === "string"
|
|
? (body as Record<string, unknown>).model
|
|
: effectiveModel,
|
|
}
|
|
: body;
|
|
|
|
// Track pending requests before slower optional enrichment (settings, logging,
|
|
// compression) so internal usage/runtime counters stay accurate even when
|
|
// upstream never returns response headers.
|
|
// Use credentials.connectionId as a fallback so that requests without an
|
|
// explicit session-level connectionId still register in the pendingRequests map.
|
|
const pendingConnId = connectionId || credentials?.connectionId || null;
|
|
const pendingRequestId =
|
|
trackPendingRequest(model, provider, pendingConnId, true, {
|
|
clientEndpoint: clientRawRequest?.endpoint || "/v1/chat/completions",
|
|
clientRequest: clientRawRequest?.body ?? body,
|
|
providerRequest: initialProviderRequest,
|
|
stage: "registered",
|
|
correlationId,
|
|
}) || generateRequestId();
|
|
|
|
// Initialize rate limit settings from persisted DB (once, lazy)
|
|
await initializeRateLimits();
|
|
|
|
const { body: bodyWithWebSearchFallback, fallback: webSearchFallbackPlan } =
|
|
prepareWebSearchFallbackBody(body as Record<string, unknown>, {
|
|
provider,
|
|
sourceFormat,
|
|
targetFormat,
|
|
nativeCodexPassthrough,
|
|
});
|
|
if (webSearchFallbackPlan.enabled) {
|
|
body = bodyWithWebSearchFallback as typeof body;
|
|
log?.info?.(
|
|
"TOOLS",
|
|
`Converted ${webSearchFallbackPlan.convertedToolCount} web_search tool(s) to OmniRoute fallback for ${provider}`
|
|
);
|
|
}
|
|
const noLogEnabled = apiKeyInfo?.noLog === true;
|
|
// Consolidate settings reads — fetch once, reuse throughout the request
|
|
const settings = cachedSettings ?? (await getCachedSettings());
|
|
// Opt-in tool-source diagnostics (#1825): summarize the request's tool definitions
|
|
// (count + MCP/hosted/client source breakdown + first names) as a single debug line.
|
|
if (settings.logToolSources === true) {
|
|
const toolSummary = summarizeToolSources((body as { tools?: unknown }).tools);
|
|
if (toolSummary) log?.debug?.("TOOLS", toolSummary);
|
|
}
|
|
// #1311 (opt-in): echo the client-requested alias/combo name in the response `model`
|
|
// field instead of the upstream model, so strict clients (Claude Desktop) that validate
|
|
// response.model === request.model stop rejecting alias/combo requests with a 401.
|
|
const echoModel =
|
|
settings.echoRequestedModelName === true && typeof requestedModel === "string" && requestedModel
|
|
? requestedModel
|
|
: null;
|
|
const detailedLoggingEnabled =
|
|
!noLogEnabled &&
|
|
(settings.call_log_pipeline_enabled === true ||
|
|
settings.call_log_pipeline_enabled === "1" ||
|
|
settings.call_log_pipeline_enabled === "true");
|
|
const capturePipelineStreamChunks =
|
|
detailedLoggingEnabled && getCallLogPipelineCaptureStreamChunks();
|
|
const skillRequestId = generateRequestId();
|
|
let compressionAnalyticsWritePromise: Promise<void> | null = null;
|
|
// Compression usage-receipt attachment extracted to chatCore/compressionUsageReceipt.ts (#3501);
|
|
// pass the in-flight analytics write + request id so behaviour stays byte-identical.
|
|
const attachCompressionUsageReceiptAfterAnalytics = (
|
|
usage: Record<string, unknown>,
|
|
source: "provider" | "estimated" | "stream"
|
|
) =>
|
|
attachCompressionUsageReceiptAfterAnalyticsFor(usage, source, {
|
|
pendingWrite: compressionAnalyticsWritePromise,
|
|
skillRequestId,
|
|
});
|
|
const pipelineSessionId =
|
|
(clientRawRequest?.headers && typeof clientRawRequest.headers.get === "function"
|
|
? clientRawRequest.headers.get("x-omniroute-session-id")
|
|
: getHeaderValueCaseInsensitive(
|
|
clientRawRequest?.headers ?? null,
|
|
"x-omniroute-session-id"
|
|
)) || skillRequestId;
|
|
// persistAttemptLogs extracted to chatCore/attemptLogging.ts (#3501); bind the per-request context
|
|
// once so the 16 call sites keep passing only the per-attempt args (byte-identical).
|
|
const persistAttemptLogs = (args: PersistAttemptLogsArgs) =>
|
|
persistAttemptLogsFor(args, {
|
|
provider,
|
|
connectionId,
|
|
model,
|
|
skillRequestId,
|
|
detailedLoggingEnabled,
|
|
reqLogger,
|
|
pendingRequestId,
|
|
clientRawRequest,
|
|
requestedModel,
|
|
credentials,
|
|
startTime,
|
|
body,
|
|
sourceFormat,
|
|
targetFormat,
|
|
comboName,
|
|
comboStepId,
|
|
comboExecutionKey,
|
|
tokensCompressed,
|
|
apiKeyInfo,
|
|
noLogEnabled,
|
|
correlationId,
|
|
modelPinned,
|
|
});
|
|
|
|
// Primary path: merge client model id + alias target so config on either key applies; resolved
|
|
// id wins on same header name. T5 family fallback uses only (nextModel, resolveModelAlias(next))
|
|
// so A-model headers are not sent to B — see buildUpstreamHeadersForExecute.
|
|
const connectionCustomUserAgent =
|
|
credentials?.providerSpecificData &&
|
|
typeof credentials.providerSpecificData === "object" &&
|
|
typeof credentials.providerSpecificData.customUserAgent === "string"
|
|
? credentials.providerSpecificData.customUserAgent.trim()
|
|
: "";
|
|
|
|
// Upstream extra-header building extracted to chatCore/upstreamExecuteHeaders.ts (#3501); bind the
|
|
// per-request inputs once and delegate so the existing call sites stay byte-identical.
|
|
const buildUpstreamHeadersForExecute = (modelToCall: string): Record<string, string> =>
|
|
buildUpstreamHeadersForExecuteFor({
|
|
modelToCall,
|
|
effectiveModel,
|
|
provider,
|
|
model,
|
|
resolvedModel,
|
|
sourceFormat,
|
|
connectionCustomUserAgent,
|
|
settings,
|
|
});
|
|
|
|
// Default to false unless client explicitly sets stream: true (OpenAI spec compliant)
|
|
const acceptHeader =
|
|
clientRawRequest?.headers && typeof clientRawRequest.headers.get === "function"
|
|
? clientRawRequest.headers.get("accept") || clientRawRequest.headers.get("Accept")
|
|
: clientRawRequest?.headers?.["accept"] || clientRawRequest?.headers?.["Accept"];
|
|
const streamUserAgent = [
|
|
typeof userAgent === "string" ? userAgent : "",
|
|
getHeaderValueCaseInsensitive(clientRawRequest?.headers ?? null, "user-agent") || "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
|
|
// Explicit per-request opt-in/out for the `</think>` close marker
|
|
// (#5312 / #5245): `x-omniroute-thinking-marker: off` suppresses it for
|
|
// reasoning_content-native clients (e.g. Cursor's OpenAI path) that the UA
|
|
// allowlist does not cover; absent the header, the UA policy applies.
|
|
const thinkingMarkerHeader = getHeaderValueCaseInsensitive(
|
|
clientRawRequest?.headers ?? null,
|
|
THINKING_MARKER_HEADER
|
|
);
|
|
|
|
const explicitStreamAlias = resolveExplicitStreamAlias(body);
|
|
|
|
// Remove non-standard non-stream aliases before provider translation/execution.
|
|
// They are accepted for compatibility at the OmniRoute API boundary only.
|
|
if (body && typeof body === "object") {
|
|
const b = body as Record<string, unknown>;
|
|
if (explicitStreamAlias !== undefined) {
|
|
b.stream = explicitStreamAlias;
|
|
}
|
|
|
|
delete b.non_stream;
|
|
delete b.disable_stream;
|
|
delete b.disable_streaming;
|
|
delete b.streaming;
|
|
}
|
|
|
|
// Codex /responses/compact is JSON-only: Codex CLI does not send stream=false,
|
|
// so route shape must override the usual Accept/header fallback.
|
|
// sourceFormat="claude" applies the Anthropic Messages spec default (stream=false
|
|
// when body omits stream), preventing STREAM_EARLY_EOF on /v1/messages when
|
|
// clients send Accept: */* without an explicit stream flag.
|
|
// providerRequiresStreaming: providers with forceStream:true (cline/clinepass)
|
|
// only implement upstream streaming — a non-streaming request returns
|
|
// "generateText is not implemented" / an empty body. This flag forces the
|
|
// UPSTREAM request to stream (see `upstreamStream` below), but it MUST NOT
|
|
// force the client-facing `stream` flag: a stream:false client (e.g. the
|
|
// model-test button, plain JSON API callers) still expects a JSON response.
|
|
// The client-side `if (!stream)` branch drains the forced upstream SSE and
|
|
// converts it back to JSON via readNonStreamingResponseBody. Passing this
|
|
// flag into resolveStreamFlag would force `stream=true` and skip that
|
|
// conversion, yielding STREAM_EARLY_EOF for JSON callers. (#2081, #6126)
|
|
const providerRequiresStreaming = REGISTRY[provider]?.forceStream === true;
|
|
const stream =
|
|
nativeCodexPassthrough && isCompactResponsesEndpoint(endpointPath)
|
|
? false
|
|
: resolveStreamFlag(body?.stream, acceptHeader, sourceFormat, {
|
|
userAgent: streamUserAgent,
|
|
streamDefaultMode: apiKeyInfo?.streamDefaultMode,
|
|
});
|
|
|
|
// `settings` is already consolidated once near the top of handleChatCore
|
|
// (the "fetch once, reuse" const). A second `const settings` here was a
|
|
// duplicate same-scope declaration that broke the esbuild/tsx transform
|
|
// ("settings has already been declared") and the production build. Reuse it.
|
|
credentials = applyCodexGlobalFastServiceTier(provider, credentials, settings, {
|
|
model: requestedModel,
|
|
body: body && typeof body === "object" ? (body as Record<string, unknown>) : null,
|
|
});
|
|
effectiveServiceTier = resolveEffectiveServiceTier(body);
|
|
setGeminiThoughtSignatureMode(settings.antigravitySignatureCacheMode);
|
|
const semanticCacheEnabled = settings.semanticCacheEnabled !== false;
|
|
|
|
const reqLogger = await createRequestLogger(sourceFormat, targetFormat, model, {
|
|
enabled: detailedLoggingEnabled,
|
|
captureStreamChunks: capturePipelineStreamChunks,
|
|
maxStreamChunkBytes: getCallLogPipelineMaxSizeBytes(),
|
|
requestId: pendingRequestId,
|
|
model,
|
|
provider: provider || undefined,
|
|
connectionId: connectionId || credentials?.connectionId || undefined,
|
|
});
|
|
const pendingScope = { id: pendingRequestId, model, provider, connectionId: pendingConnId };
|
|
const providerRequestCapture = createPreparedRequestLogger(reqLogger, pendingScope);
|
|
// 0. Log client raw request (before format conversion)
|
|
if (clientRawRequest) {
|
|
reqLogger.logClientRawRequest(
|
|
clientRawRequest.endpoint,
|
|
clientRawRequest.body,
|
|
clientRawRequest.headers
|
|
);
|
|
}
|
|
|
|
log?.debug?.("FORMAT", `${sourceFormat} → ${targetFormat} | stream=${stream}`);
|
|
|
|
// ── Phase 9.1: Semantic cache check (temp=0, any streaming mode) ──
|
|
const cacheHit = await checkSemanticCache({
|
|
semanticCacheEnabled,
|
|
body,
|
|
clientRawRequest,
|
|
model,
|
|
provider,
|
|
stream: !!stream,
|
|
reqLogger,
|
|
effectiveServiceTier,
|
|
connectionId,
|
|
startTime,
|
|
log,
|
|
persistAttemptLogs,
|
|
apiKeyId: apiKeyInfo?.id ?? undefined,
|
|
});
|
|
if (cacheHit) {
|
|
return cacheHit;
|
|
}
|
|
|
|
body = sanitizeChatRequestBody(body, sourceFormat, targetFormat);
|
|
// Per-request opt-out: clients that manage their own context send
|
|
// `x-omniroute-no-memory: true` to skip memory+skills injection (a null owner
|
|
// disables both branches in injectMemoryAndSkills). See PRD-2026-06-19-no-memory-header.
|
|
const memoryOwnerId = isNoMemoryRequested(clientRawRequest?.headers ?? null)
|
|
? null
|
|
: resolveMemoryOwnerId(apiKeyInfo as Record<string, unknown> | null);
|
|
const injectionResult = await injectMemoryAndSkills({
|
|
body,
|
|
memoryOwnerId,
|
|
provider,
|
|
effectiveModel,
|
|
sourceFormat,
|
|
targetFormat,
|
|
backgroundReason,
|
|
log,
|
|
});
|
|
body = injectionResult.body;
|
|
const memorySettings = injectionResult.memorySettings;
|
|
|
|
// Translate request (pass reqLogger for intermediate logging)
|
|
// ── Proactive Context Compression (Phase 4) ──
|
|
// Check if context exceeds 70% of limit and compress proactively before sending to provider.
|
|
// This prevents "prompt too long" errors for large-but-not-full contexts.
|
|
const compressionBody = body
|
|
? adaptBodyForCompression(body as Record<string, unknown>).body
|
|
: null;
|
|
const allMessages = compressionBody?.messages || body?.contents || body?.request?.contents || [];
|
|
let cavemanOutputModeApplied = false;
|
|
let cavemanOutputModeIntensity: string | null = null;
|
|
let preCompressionBody: typeof body | null = null;
|
|
let compressionResponseMeta: string | null = null;
|
|
// Delegated Context Editing (Claude only): captured at the canonical compression
|
|
// settings read below, then threaded to executor.execute() further down. Lives at
|
|
// function scope because the read happens inside the per-message compression block.
|
|
let contextEditingEnabled = false;
|
|
if (body && Array.isArray(allMessages) && allMessages.length > 0) {
|
|
let estimatedTokens = estimateTokens(allMessages);
|
|
const compressionSettingsResult = await resolveCompressionSettings(log);
|
|
const compressionSettings: CompressionConfig | null = compressionSettingsResult.settings;
|
|
const promptCompressionEnabled = compressionSettingsResult.enabled;
|
|
contextEditingEnabled = compressionSettingsResult.contextEditingEnabled;
|
|
|
|
// --- Modular Compression Pipeline (Phase 1 Lite + Phase 2 Standard/Caveman + Phase 3 Aggressive) ---
|
|
// Runs BEFORE the existing reactive compressContext() to proactively reduce tokens.
|
|
try {
|
|
const {
|
|
selectCompressionStrategy,
|
|
selectCompressionPlan,
|
|
enginesMapDerivesStackedPipeline,
|
|
activeComboResolves,
|
|
applyCompressionAsync,
|
|
resolveCacheAwareConfig,
|
|
formatCompressionMeta,
|
|
buildNamedComboLookup,
|
|
formatCompressionAnnotation,
|
|
} = await import("../services/compression/strategySelector.ts");
|
|
const { trackCompressionStats } = await import("../services/compression/stats.ts");
|
|
let config: CompressionConfig = compressionSettings ?? {
|
|
enabled: false,
|
|
defaultMode: "off",
|
|
autoTriggerTokens: 0,
|
|
cacheMinutes: 5,
|
|
preserveSystemPrompt: true,
|
|
comboOverrides: {},
|
|
};
|
|
if (!promptCompressionEnabled || !compressionSettings) {
|
|
log?.debug?.("COMPRESSION", "Prompt compression disabled or unavailable");
|
|
}
|
|
let compressionComboKey = comboName ?? null;
|
|
let compressionComboApplied = false;
|
|
const applyCompressionComboConfig = (
|
|
compressionCombo: RuntimeCompressionCombo | null,
|
|
routingOverrideIds: string[] = []
|
|
): boolean => {
|
|
if (!compressionCombo || compressionCombo.pipeline.length === 0) return false;
|
|
const comboLanguagePacks = [
|
|
...new Set(
|
|
compressionCombo.languagePacks
|
|
.map((pack) => pack.trim())
|
|
.filter((pack) => pack.length > 0)
|
|
),
|
|
];
|
|
const comboOutputIntensity = (
|
|
["lite", "full", "ultra"].includes(compressionCombo.outputModeIntensity)
|
|
? compressionCombo.outputModeIntensity
|
|
: (config.cavemanOutputMode?.intensity ?? "full")
|
|
) as "lite" | "full" | "ultra";
|
|
const comboDefaultLanguage =
|
|
comboLanguagePacks.find((pack) => pack === config.languageConfig?.defaultLanguage) ??
|
|
comboLanguagePacks[0] ??
|
|
config.languageConfig?.defaultLanguage ??
|
|
"en";
|
|
const comboOverrides = { ...(config.comboOverrides ?? {}) };
|
|
for (const id of routingOverrideIds) {
|
|
if (id) comboOverrides[id] = "stacked";
|
|
}
|
|
config = {
|
|
...config,
|
|
compressionComboId: compressionCombo.id,
|
|
stackedPipeline: compressionCombo.pipeline,
|
|
languageConfig: {
|
|
...(config.languageConfig ?? {
|
|
enabled: false,
|
|
defaultLanguage: "en",
|
|
autoDetect: true,
|
|
enabledPacks: ["en"],
|
|
}),
|
|
enabled: true,
|
|
defaultLanguage: comboDefaultLanguage,
|
|
enabledPacks:
|
|
comboLanguagePacks.length > 0
|
|
? comboLanguagePacks
|
|
: (config.languageConfig?.enabledPacks ?? ["en"]),
|
|
},
|
|
cavemanOutputMode: {
|
|
...(config.cavemanOutputMode ?? {
|
|
enabled: false,
|
|
intensity: "full",
|
|
autoClarity: true,
|
|
}),
|
|
enabled: compressionCombo.outputMode,
|
|
intensity: comboOutputIntensity,
|
|
},
|
|
comboOverrides,
|
|
};
|
|
compressionComboApplied = true;
|
|
return true;
|
|
};
|
|
if (isCombo && comboName) {
|
|
try {
|
|
const { getComboByName } = await import("../../src/lib/localDb");
|
|
let comboConfig = await getComboByName(comboName);
|
|
if (!comboConfig && comboName.startsWith("combo/")) {
|
|
comboConfig = await getComboByName(comboName.substring(6));
|
|
}
|
|
const comboRuntimeConfig =
|
|
comboConfig?.config && typeof comboConfig.config === "object"
|
|
? (comboConfig.config as Record<string, unknown>)
|
|
: {};
|
|
const comboMode =
|
|
typeof comboRuntimeConfig.compressionMode === "string"
|
|
? comboRuntimeConfig.compressionMode
|
|
: typeof comboConfig?.compressionOverride === "string"
|
|
? comboConfig.compressionOverride
|
|
: null;
|
|
if (
|
|
comboMode === "off" ||
|
|
comboMode === "lite" ||
|
|
comboMode === "standard" ||
|
|
comboMode === "aggressive" ||
|
|
comboMode === "ultra" ||
|
|
comboMode === "rtk" ||
|
|
comboMode === "stacked"
|
|
) {
|
|
config = {
|
|
...config,
|
|
comboOverrides: {
|
|
...(config.comboOverrides ?? {}),
|
|
...(comboName ? { [comboName]: comboMode } : {}),
|
|
...(comboConfig?.id ? { [String(comboConfig.id)]: comboMode } : {}),
|
|
},
|
|
};
|
|
compressionComboKey = comboName;
|
|
}
|
|
const routingComboIds = [
|
|
comboConfig?.id,
|
|
comboName,
|
|
comboName.startsWith("combo/") ? comboName.substring(6) : null,
|
|
].filter((id): id is string => typeof id === "string" && id.length > 0);
|
|
if (routingComboIds.length > 0) {
|
|
const { getCompressionComboForRoutingCombo } =
|
|
await import("../../src/lib/db/compressionCombos.ts");
|
|
const assignedCompressionCombo =
|
|
routingComboIds
|
|
.map((id) => getCompressionComboForRoutingCombo(id))
|
|
.find((combo) => combo !== null) ?? null;
|
|
if (
|
|
applyCompressionComboConfig(
|
|
assignedCompressionCombo as RuntimeCompressionCombo | null,
|
|
routingComboIds
|
|
)
|
|
) {
|
|
compressionComboKey = comboName;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
log?.debug?.(
|
|
"COMPRESSION",
|
|
"Combo compression override lookup skipped: " +
|
|
(err instanceof Error ? err.message : String(err))
|
|
);
|
|
}
|
|
}
|
|
let namedCombos: Record<string, CompressionPipelineStep[]> = {};
|
|
try {
|
|
const { listCompressionCombos } = await import("../../src/lib/db/compressionCombos.ts");
|
|
namedCombos = buildNamedComboLookup(listCompressionCombos());
|
|
} catch (err) {
|
|
log?.debug?.(
|
|
"COMPRESSION",
|
|
"Named combos load skipped: " + (err instanceof Error ? err.message : String(err))
|
|
);
|
|
}
|
|
// Phase 3: per-request override. Unknown values fall through in the resolver (never error).
|
|
const compressionHeader = resolveCompressionHeader(clientRawRequest?.headers ?? null);
|
|
if (compressionHeader) {
|
|
log?.debug?.("COMPRESSION", `x-omniroute-compression header: ${compressionHeader}`);
|
|
}
|
|
const modeBeforeOutputTransform = selectCompressionStrategy(
|
|
config,
|
|
compressionComboKey,
|
|
estimatedTokens,
|
|
body as Record<string, unknown>,
|
|
{ provider, targetFormat, model: effectiveModel },
|
|
namedCombos,
|
|
compressionHeader
|
|
);
|
|
if (
|
|
modeBeforeOutputTransform === "stacked" &&
|
|
!compressionComboApplied &&
|
|
!config.compressionComboId &&
|
|
isBuiltinStackedPipeline(config.stackedPipeline) &&
|
|
// Don't let the legacy default combo override a panel-configured engines map: when the
|
|
// operator's explicit engines derive their own stacked pipeline, that pipeline (applied
|
|
// below from compressionPlan.stackedPipeline) is authoritative. Legacy/backfilled
|
|
// installs (enginesExplicit false) still fall through to the seeded default combo.
|
|
!enginesMapDerivesStackedPipeline(config) &&
|
|
// Never let the legacy seeded default combo shadow the operator's active profile.
|
|
!activeComboResolves(config, namedCombos)
|
|
) {
|
|
try {
|
|
const { getDefaultCompressionCombo } =
|
|
await import("../../src/lib/db/compressionCombos.ts");
|
|
const defaultCompressionCombo = getDefaultCompressionCombo();
|
|
if (
|
|
isStackedCompressionCombo(defaultCompressionCombo as RuntimeCompressionCombo | null) &&
|
|
applyCompressionComboConfig(defaultCompressionCombo as RuntimeCompressionCombo | null)
|
|
) {
|
|
log?.debug?.(
|
|
"COMPRESSION",
|
|
`Default compression combo applied: ${defaultCompressionCombo?.id}`
|
|
);
|
|
}
|
|
} catch (err) {
|
|
log?.debug?.(
|
|
"COMPRESSION",
|
|
"Default compression combo lookup skipped: " +
|
|
(err instanceof Error ? err.message : String(err))
|
|
);
|
|
}
|
|
}
|
|
// Phase 4A: unified output styles (supersedes cavemanOutputMode via the back-compat shim).
|
|
let outputStyleResult:
|
|
import("../services/compression/outputStyles/apply.ts").OutputStylesResult | null = null;
|
|
if (config.enabled) {
|
|
try {
|
|
const { resolveOutputStyleSelection } =
|
|
await import("../services/compression/outputStyles/backCompat.ts");
|
|
const selection = resolveOutputStyleSelection(config);
|
|
if (selection.length > 0) {
|
|
const { applyOutputStyles } =
|
|
await import("../services/compression/outputStyles/apply.ts");
|
|
const outputStyleLanguage =
|
|
config.languageConfig?.enabled === true
|
|
? config.languageConfig.defaultLanguage
|
|
: "en";
|
|
outputStyleResult = applyOutputStyles(
|
|
body as Parameters<typeof applyOutputStyles>[0],
|
|
selection,
|
|
outputStyleLanguage
|
|
);
|
|
if (outputStyleResult.applied) {
|
|
body = outputStyleResult.body as typeof body;
|
|
cavemanOutputModeApplied = true;
|
|
cavemanOutputModeIntensity =
|
|
outputStyleResult.appliedStyles?.map((s) => `${s.id}:${s.level}`).join(",") ?? null;
|
|
estimatedTokens = estimateTokens(body?.messages ?? body?.input ?? []);
|
|
log?.debug?.("COMPRESSION", "Output styles applied");
|
|
} else if (
|
|
outputStyleResult.skippedReason &&
|
|
outputStyleResult.skippedReason !== "no_styles"
|
|
) {
|
|
log?.debug?.(
|
|
"COMPRESSION",
|
|
`Output styles skipped: ${outputStyleResult.skippedReason}`
|
|
);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
log?.debug?.(
|
|
"COMPRESSION",
|
|
"Output styles skipped: " + (err instanceof Error ? err.message : String(err))
|
|
);
|
|
}
|
|
}
|
|
const compressionInputBody = body as Record<string, unknown>;
|
|
// Adaptive context-budget (Sub-project C): model context window + request max_tokens drive
|
|
// the budget target. getTokenLimit is already imported; provider/effectiveModel resolved above.
|
|
const adaptiveModelContextLimit =
|
|
provider && effectiveModel ? getTokenLimit(provider, effectiveModel) : null;
|
|
const requestMaxTokens =
|
|
typeof (compressionInputBody as Record<string, unknown>)?.max_tokens === "number"
|
|
? ((compressionInputBody as Record<string, unknown>).max_tokens as number)
|
|
: null;
|
|
let adaptiveTelemetry:
|
|
import("../services/compression/adaptiveCompression/types.ts").AdaptiveTelemetry | null =
|
|
null;
|
|
const compressionPlan = selectCompressionPlan(
|
|
config,
|
|
compressionComboKey,
|
|
estimatedTokens,
|
|
compressionInputBody,
|
|
{ provider, targetFormat, model: effectiveModel },
|
|
namedCombos,
|
|
compressionHeader,
|
|
{
|
|
modelContextLimit: adaptiveModelContextLimit,
|
|
requestMaxTokens: requestMaxTokens,
|
|
onAdaptive: (t) => {
|
|
adaptiveTelemetry = t;
|
|
},
|
|
}
|
|
);
|
|
const mode = compressionPlan.mode as CompressionConfig["defaultMode"];
|
|
if (adaptiveTelemetry && adaptiveTelemetry.fit === false) {
|
|
log?.warn?.(
|
|
"COMPRESSION",
|
|
`adaptive budget-exceeded: target=${adaptiveTelemetry.target} headroomAfter=${adaptiveTelemetry.headroomAfter} stages=${adaptiveTelemetry.stagesApplied.join(",")} (best-effort plan sent, content preserved)`
|
|
);
|
|
}
|
|
compressionResponseMeta = formatCompressionMeta(compressionPlan);
|
|
// When the per-engine toggle map derives a stacked pipeline (and no named/routing
|
|
// combo already set config.stackedPipeline), feed that derived pipeline through so
|
|
// applyCompressionAsync (which reads config.stackedPipeline for stacked mode) runs the
|
|
// engines the operator actually toggled on instead of the built-in rtk+caveman default.
|
|
if (
|
|
mode === "stacked" &&
|
|
compressionPlan.stackedPipeline.length > 0 &&
|
|
!compressionComboApplied &&
|
|
!config.compressionComboId
|
|
) {
|
|
config = {
|
|
...config,
|
|
stackedPipeline: compressionPlan.stackedPipeline as CompressionConfig["stackedPipeline"],
|
|
};
|
|
}
|
|
let compressionAnalyticsRecorded = false;
|
|
if (mode !== "off") {
|
|
// #3890: in a caching context, never compress the system prompt (cacheable prefix)
|
|
// even if the operator disabled preserveSystemPrompt — honors the cache-aware flag
|
|
// that selectCompressionStrategy can only partially apply via the mode string.
|
|
const cacheCtx = { provider, targetFormat, model: effectiveModel };
|
|
const compressionConfig = resolveCacheAwareConfig(config, compressionInputBody, cacheCtx);
|
|
const result = await applyCompressionAsync(compressionInputBody, mode, {
|
|
model: effectiveModel,
|
|
config: compressionConfig,
|
|
cachingContext: cacheCtx,
|
|
principalId: apiKeyInfo?.id ? String(apiKeyInfo.id) : undefined,
|
|
// F3.3: stream per-engine progress live (best-effort) before compression.completed.
|
|
onEngineStep: (s) => {
|
|
try {
|
|
const stepPayload = {
|
|
requestId: traceId,
|
|
comboId: null,
|
|
mode,
|
|
stepIndex: s.stepIndex,
|
|
totalSteps: s.totalSteps,
|
|
engine: s.engine,
|
|
state: s.state,
|
|
originalTokens: s.originalTokens,
|
|
compressedTokens: s.compressedTokens,
|
|
savingsPercent: s.savingsPercent,
|
|
...(s.durationMs !== undefined ? { durationMs: s.durationMs } : {}),
|
|
timestamp: Date.now(),
|
|
};
|
|
emit("compression.step", stepPayload);
|
|
void forwardDashboardEventToLiveWs("compression.step", stepPayload);
|
|
} catch (_stepErr) {
|
|
// best-effort live event — never fail the request
|
|
}
|
|
},
|
|
});
|
|
if (result.stats) {
|
|
const annotation = formatCompressionAnnotation(result.stats);
|
|
if (annotation) {
|
|
compressionResponseMeta = `${compressionResponseMeta}; ${annotation}`;
|
|
}
|
|
if (result.compressed) {
|
|
body = result.body as typeof body;
|
|
estimatedTokens = result.stats.compressedTokens;
|
|
tokensCompressed = Math.max(
|
|
0,
|
|
result.stats.originalTokens - result.stats.compressedTokens
|
|
);
|
|
}
|
|
|
|
// Fire-and-forget: emit live compression event for dashboard (U5).
|
|
// Guard: only emit when compression actually ran and produced stats.
|
|
if (result.compressed && result.stats) {
|
|
try {
|
|
const compressionCompletedPayload = {
|
|
requestId: traceId,
|
|
comboId: result.stats.compressionComboId ?? null,
|
|
mode,
|
|
originalTokens: result.stats.originalTokens,
|
|
compressedTokens: result.stats.compressedTokens,
|
|
savingsPercent: result.stats.savingsPercent,
|
|
// Single-engine modes leave engineBreakdown empty; synthesize a 1-entry
|
|
// breakdown so the studio shows a real engine node instead of an empty pipeline.
|
|
engineBreakdown: ensureEngineBreakdown(result.stats),
|
|
validationWarnings: result.stats.validationWarnings,
|
|
fallbackApplied: result.stats.fallbackApplied,
|
|
...(adaptiveTelemetry ? { adaptive: adaptiveTelemetry } : {}),
|
|
timestamp: Date.now(),
|
|
};
|
|
emit("compression.completed", compressionCompletedPayload);
|
|
void forwardDashboardEventToLiveWs(
|
|
"compression.completed",
|
|
compressionCompletedPayload
|
|
);
|
|
} catch (_emitErr) {
|
|
// never propagate into the hot path — but log like the sibling
|
|
// fire-and-forget blocks so a throwing event bus isn't fully silent.
|
|
log?.debug?.(
|
|
"COMPRESSION",
|
|
"compression.completed emit skipped: " +
|
|
(_emitErr instanceof Error ? _emitErr.message : String(_emitErr))
|
|
);
|
|
}
|
|
}
|
|
|
|
if (result.compressed || result.stats.fallbackApplied || cavemanOutputModeApplied) {
|
|
trackCompressionStats(result.stats);
|
|
compressionAnalyticsRecorded = true;
|
|
compressionAnalyticsWritePromise = writeCompressionAnalytics({
|
|
stats: result.stats,
|
|
provider,
|
|
effectiveModel,
|
|
effectiveServiceTier,
|
|
comboName,
|
|
mode,
|
|
compressionComboId: config.compressionComboId,
|
|
skillRequestId,
|
|
cavemanOutputModeApplied,
|
|
cavemanOutputModeIntensity,
|
|
log,
|
|
});
|
|
} else {
|
|
// Compression was attempted (mode active, engines ran) but produced no
|
|
// recordable saving — e.g. a Stacked RTK→Caveman pipeline on already-compact
|
|
// context. Record a skip row so analytics can distinguish "ran but saved
|
|
// nothing" from "never ran" instead of dropping it silently (#4268).
|
|
compressionAnalyticsRecorded = true;
|
|
compressionAnalyticsWritePromise = writeCompressionSkip(
|
|
{
|
|
stats: result.stats,
|
|
provider,
|
|
effectiveModel,
|
|
effectiveServiceTier,
|
|
comboName,
|
|
mode,
|
|
compressionComboId: config.compressionComboId,
|
|
skillRequestId,
|
|
cavemanOutputModeApplied,
|
|
cavemanOutputModeIntensity,
|
|
log,
|
|
},
|
|
"no_savings"
|
|
);
|
|
}
|
|
|
|
if (result.compressed) {
|
|
recordCompressionCacheStats({
|
|
compressionInputBody,
|
|
provider,
|
|
targetFormat,
|
|
effectiveModel,
|
|
mode,
|
|
stats: result.stats,
|
|
log,
|
|
});
|
|
log?.info?.(
|
|
"COMPRESSION",
|
|
`Prompt compressed (${mode}): ${result.stats.originalTokens} -> ${result.stats.compressedTokens} tokens (${result.stats.savingsPercent}% saved, techniques: ${result.stats.techniquesUsed.join(",")})`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (cavemanOutputModeApplied && !compressionAnalyticsRecorded) {
|
|
compressionAnalyticsWritePromise = writeCavemanOutputAnalytics({
|
|
comboName,
|
|
provider,
|
|
compressionComboId: config.compressionComboId,
|
|
estimatedTokens,
|
|
skillRequestId,
|
|
cavemanOutputModeIntensity,
|
|
log,
|
|
});
|
|
}
|
|
emitOutputStyleTelemetry({
|
|
outputStyleResult,
|
|
skillRequestId,
|
|
traceId,
|
|
effectiveModel,
|
|
provider,
|
|
compressionComboId: config.compressionComboId,
|
|
estimatedTokens,
|
|
log,
|
|
});
|
|
} catch (err) {
|
|
log?.warn?.(
|
|
"COMPRESSION",
|
|
"Compression pipeline error (non-fatal): " +
|
|
(err instanceof Error ? err.message : String(err))
|
|
);
|
|
}
|
|
// --- End Modular Compression Pipeline ---
|
|
|
|
if (!promptCompressionEnabled) {
|
|
log?.debug?.(
|
|
"CONTEXT",
|
|
"Skipping proactive context compression: Prompt Compression disabled"
|
|
);
|
|
}
|
|
let contextLimit = getTokenLimit(provider, effectiveModel);
|
|
|
|
if (isCombo && comboName) {
|
|
log?.info?.("CONTEXT", `Attempting to resolve combo limits for comboName=${comboName}`);
|
|
try {
|
|
const { getComboByName } = await import("../../src/lib/localDb");
|
|
const { parseModel } = await import("../services/model.ts");
|
|
const { resolveComboTargets } = await import("../services/combo.ts");
|
|
let comboConfig = await getComboByName(comboName);
|
|
if (!comboConfig && comboName.startsWith("combo/")) {
|
|
comboConfig = await getComboByName(comboName.substring(6));
|
|
}
|
|
let comboTargetLimits: number[] = [];
|
|
if (comboConfig) {
|
|
const allCombosData = await getCombosCached();
|
|
const targets = resolveComboTargets(
|
|
comboConfig as unknown as { name: string; models: unknown[] },
|
|
allCombosData as unknown as { name: string; models: unknown[] }[]
|
|
);
|
|
comboTargetLimits = targets.map((t: { modelStr?: string }) => {
|
|
const parsed = parseModel(t.modelStr);
|
|
return getTokenLimit(parsed.provider, parsed.model);
|
|
});
|
|
}
|
|
// chatCore executes per concrete target (handleSingleModel resolves
|
|
// provider/effectiveModel before delegating). Compress against THIS
|
|
// target's window; min(...allTargets) is only a defensive fallback —
|
|
// the old unconditional min compressed a 1M-target request at the
|
|
// smallest sibling's window ("agent keeps forgetting things").
|
|
const resolved = resolveComboContextLimit({
|
|
provider,
|
|
model: effectiveModel,
|
|
comboTargetLimits,
|
|
});
|
|
contextLimit = resolved.limit;
|
|
log?.info?.(
|
|
"CONTEXT",
|
|
`Combo context limit: ${resolved.limit} (source=${resolved.source})`
|
|
);
|
|
} catch (err) {
|
|
log?.warn?.("CONTEXT", "Failed to resolve combo limits for compression: " + err);
|
|
}
|
|
}
|
|
|
|
const COMPRESSION_THRESHOLD = 0.7;
|
|
let reservedTokens = 0;
|
|
if (Array.isArray(body.tools)) {
|
|
reservedTokens = estimateTokens(body.tools);
|
|
}
|
|
const threshold = Math.max(
|
|
1,
|
|
Math.floor((Math.max(1, contextLimit) - reservedTokens) * COMPRESSION_THRESHOLD)
|
|
);
|
|
|
|
log?.debug?.(
|
|
"CONTEXT",
|
|
`Checking compression: ${estimatedTokens} tokens vs ${threshold} threshold (${contextLimit} limit, ${reservedTokens} reserved)`
|
|
);
|
|
|
|
// Capture pre-compression body so translators can access original message
|
|
// content even after compression alters it (e.g. stable Kiro conversationId).
|
|
preCompressionBody = body;
|
|
|
|
if (promptCompressionEnabled && estimatedTokens > threshold) {
|
|
log?.info?.(
|
|
"CONTEXT",
|
|
`Proactive compression triggered: ${estimatedTokens} tokens > ${threshold} threshold (${contextLimit} limit)`
|
|
);
|
|
|
|
const compressionResult = compressContext(body, {
|
|
provider,
|
|
model: effectiveModel,
|
|
maxTokens: threshold,
|
|
reserveTokens: 0,
|
|
});
|
|
|
|
if (compressionResult.compressed) {
|
|
body = compressionResult.body;
|
|
const stats = compressionResult.stats;
|
|
tokensCompressed = Math.max(0, (stats?.original ?? 0) - (stats?.final ?? 0));
|
|
const layersInfo =
|
|
stats && "layers" in stats && Array.isArray(stats.layers)
|
|
? ` (layers: ${stats.layers.map((l: { name: string }) => l.name).join(", ")})`
|
|
: "";
|
|
|
|
log?.info?.(
|
|
"CONTEXT",
|
|
`Context compressed: ${stats.original} → ${stats.final} tokens${layersInfo}`
|
|
);
|
|
|
|
logAuditEvent({
|
|
action: "context.proactive_compression",
|
|
actor: apiKeyInfo?.name || "system",
|
|
target: connectionId || provider || "chat",
|
|
details: {
|
|
provider,
|
|
model: effectiveModel,
|
|
original_tokens: stats.original,
|
|
final_tokens: stats.final,
|
|
layers: "layers" in stats ? stats.layers : undefined,
|
|
},
|
|
});
|
|
} else {
|
|
log?.debug?.("CONTEXT", `Compression not applied: context already fits within target`);
|
|
}
|
|
}
|
|
} else {
|
|
log?.debug?.(
|
|
"CONTEXT",
|
|
`Skipping compression check: body=${!!body}, hasMessages=${Array.isArray(allMessages)}`
|
|
);
|
|
}
|
|
|
|
let translatedBody = body;
|
|
const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE;
|
|
const isClaudeCodeCompatible = isClaudeCodeCompatibleProvider(provider);
|
|
const isClaudeCodeSemanticPassthrough = isClaudeCodeSemanticPassthroughRequest({
|
|
provider,
|
|
sourceFormat,
|
|
targetFormat,
|
|
headers: clientRawRequest?.headers,
|
|
userAgent,
|
|
});
|
|
// `forceStream` providers (e.g. Cline / ClinePass) only implement upstream
|
|
// streaming — a non-streaming request returns "generateText is not implemented"
|
|
// / an empty body. Force the upstream request to stream even when the client
|
|
// wants JSON; the non-streaming branch below accumulates the SSE and converts
|
|
// it back to JSON (same mechanism already used for Claude-Code-compatible
|
|
// providers via isClaudeCodeCompatible).
|
|
const upstreamStream = stream || isClaudeCodeCompatible || providerRequiresStreaming;
|
|
let ccSessionId: string | null = null;
|
|
const stripTypes = getStripTypesForProviderModel(provider || "", model || "");
|
|
|
|
if (Array.isArray(translatedBody?.messages) && stripTypes.length > 0) {
|
|
const stripResult = stripIncompatibleMessageContent(translatedBody.messages, stripTypes);
|
|
if (stripResult.removedParts > 0) {
|
|
translatedBody = {
|
|
...translatedBody,
|
|
messages: stripResult.messages,
|
|
};
|
|
log?.warn?.(
|
|
"CONTENT",
|
|
`Stripped ${stripResult.removedParts} incompatible content part(s) for ${provider}/${model}`
|
|
);
|
|
}
|
|
}
|
|
|
|
// Determine if we should preserve client-side cache_control headers
|
|
// Fetch settings from DB to get user preference
|
|
const cacheControlMode = await getCacheControlSettings().catch(() => "auto" as const);
|
|
const preserveCacheControl = shouldPreserveCacheControl({
|
|
userAgent,
|
|
isCombo,
|
|
comboStrategy,
|
|
targetProvider: provider,
|
|
targetFormat,
|
|
settings: { alwaysPreserveClientCache: cacheControlMode },
|
|
});
|
|
|
|
if (preserveCacheControl) {
|
|
log?.debug?.(
|
|
"CACHE",
|
|
`Preserving client cache_control (client=${userAgent?.substring(0, 20)}, combo=${isCombo}, strategy=${comboStrategy}, provider=${provider})`
|
|
);
|
|
}
|
|
|
|
// extractSystemMessagesToBody + normalizeClaudeUpstreamMessages extracted to
|
|
// chatCore/claudeUpstreamMessages.ts (#3501); bind `log` once so the call sites stay byte-identical.
|
|
const normalizeClaudeUpstreamMessages = (
|
|
payload: Record<string, unknown>,
|
|
options?: { preserveToolResultBlocks?: boolean }
|
|
) => normalizeClaudeUpstreamMessagesFor(payload, options, log);
|
|
|
|
try {
|
|
if (nativeCodexPassthrough) {
|
|
translatedBody = { ...body, _nativeCodexPassthrough: true };
|
|
log?.debug?.("FORMAT", "native codex passthrough enabled");
|
|
} else if (isClaudeCodeCompatible) {
|
|
let normalizedForCc = { ...body };
|
|
|
|
// Claude Code-compatible providers expect Anthropic Messages-shaped payloads,
|
|
// but we extract only role/text/max_tokens/effort from an OpenAI-like view first.
|
|
if (sourceFormat === FORMATS.CLAUDE && isClaudeCodeSemanticPassthrough) {
|
|
log?.debug?.("FORMAT", "claude-code semantic passthrough enabled for compatible bridge");
|
|
} else if (sourceFormat !== FORMATS.OPENAI) {
|
|
const normalizeToolCallId = getModelNormalizeToolCallId(
|
|
provider || "",
|
|
model || "",
|
|
sourceFormat
|
|
);
|
|
const preserveDeveloperRole = getModelPreserveOpenAIDeveloperRole(
|
|
provider || "",
|
|
model || "",
|
|
sourceFormat
|
|
);
|
|
normalizedForCc = translateRequest(
|
|
sourceFormat,
|
|
FORMATS.OPENAI,
|
|
model,
|
|
{ ...body },
|
|
stream,
|
|
credentials,
|
|
provider,
|
|
reqLogger,
|
|
{
|
|
normalizeToolCallId,
|
|
preserveDeveloperRole,
|
|
preserveCacheControl,
|
|
copilotClient: copilotCompatibleReasoning,
|
|
}
|
|
);
|
|
}
|
|
|
|
ccSessionId = resolveClaudeCodeCompatibleSessionId(clientRawRequest?.headers);
|
|
const ccRequestDefaults = getClaudeCodeCompatibleRequestDefaults(
|
|
credentials?.providerSpecificData
|
|
);
|
|
translatedBody = buildClaudeCodeCompatibleRequest({
|
|
sourceBody: body,
|
|
normalizedBody: normalizedForCc,
|
|
claudeBody: sourceFormat === FORMATS.CLAUDE ? body : null,
|
|
model,
|
|
stream: upstreamStream,
|
|
sessionId: ccSessionId,
|
|
cwd: process.cwd(),
|
|
now: new Date(),
|
|
preserveCacheControl,
|
|
preserveClaudeMessages: sourceFormat === FORMATS.CLAUDE && isClaudeCodeSemanticPassthrough,
|
|
summarizeThinking: ccRequestDefaults.summarizeThinking === true,
|
|
});
|
|
log?.debug?.("FORMAT", "claude-code-compatible bridge enabled");
|
|
|
|
if (isClaudeCodeSemanticPassthrough) {
|
|
// Semantic passthrough: only lift system/developer role messages
|
|
// without converting file/document blocks, tool history, etc.
|
|
extractSystemRoleMessages(translatedBody);
|
|
} else {
|
|
// Non-CC path: full normalization including content type conversion.
|
|
normalizeClaudeUpstreamMessages(translatedBody, { preserveToolResultBlocks: true });
|
|
}
|
|
} else if (isClaudePassthrough) {
|
|
// Pure passthrough: forward the body as-is without OpenAI round-trip.
|
|
// The Claude→OpenAI→Claude double translation was lossy and corrupted
|
|
// payloads at high context (150+ msgs, 100+ tools). Fix: #1359.
|
|
// Claude Code sends well-formed Messages API payloads — trust them
|
|
// regardless of combo strategy or cache_control settings.
|
|
translatedBody = { ...body };
|
|
translatedBody._disableToolPrefix = true;
|
|
|
|
// Sanitize historical thinking-block signatures for Anthropic-native Claude OAuth.
|
|
// Only Anthropic's first-party API validates these signatures (token-bound); third-party
|
|
// Claude-shape providers do not. See redactPassthroughThinkingSignatures + issue #2454.
|
|
if (provider === "claude") {
|
|
translatedBody.messages = redactPassthroughThinkingSignatures(
|
|
translatedBody.messages,
|
|
DEFAULT_THINKING_CLAUDE_SIGNATURE
|
|
) as typeof translatedBody.messages;
|
|
|
|
// Anthropic API rejects requests with both temperature and top_p.
|
|
// VS Code Claude extension and similar clients send both; strip top_p.
|
|
if (translatedBody.temperature !== undefined && translatedBody.top_p !== undefined) {
|
|
delete translatedBody.top_p;
|
|
}
|
|
}
|
|
|
|
// Fix #2468: always extract role:"system" → top-level system.
|
|
// The semantic passthrough correctly skips the Claude→OpenAI→Claude
|
|
// round-trip, but even pure Claude bodies may carry system content as
|
|
// role:"system" messages rather than the top-level system field, which
|
|
// Anthropic's Messages API now rejects with a 400.
|
|
if (isClaudeCodeSemanticPassthrough) {
|
|
// Only lift system/developer messages — preserves Claude Code's
|
|
// native payload structure (documents, tool chains, thinking, etc.)
|
|
extractSystemRoleMessages(translatedBody);
|
|
if (Array.isArray(translatedBody.messages)) {
|
|
translatedBody.messages = splitMisplacedToolResults(
|
|
translatedBody.messages as ClaudeMessage[]
|
|
) as typeof translatedBody.messages;
|
|
}
|
|
} else {
|
|
normalizeClaudeUpstreamMessages(translatedBody, { preserveToolResultBlocks: true });
|
|
}
|
|
|
|
log?.debug?.("FORMAT", `claude passthrough (preserveCache=${preserveCacheControl})`);
|
|
|
|
// Migrate deprecated top-level `output_format` → `output_config.format`.
|
|
// Anthropic returns a 400 on the legacy field; some clients (e.g. ForgeCode)
|
|
// still emit it. Preserves an existing output_config.format if present.
|
|
if (translatedBody.output_format !== undefined) {
|
|
const oc =
|
|
translatedBody.output_config && typeof translatedBody.output_config === "object"
|
|
? (translatedBody.output_config as Record<string, unknown>)
|
|
: {};
|
|
if (oc.format === undefined) oc.format = translatedBody.output_format;
|
|
translatedBody.output_config = oc;
|
|
delete translatedBody.output_format;
|
|
}
|
|
|
|
// Fix #1719: Strip output_config.format for non-Anthropic Claude-compatible providers.
|
|
// Third-party Claude endpoints (MiniMax, DeepSeek via aggregators) reject this field
|
|
// with 400 errors since they don't support Anthropic's structured output / json_schema.
|
|
if (
|
|
provider !== "claude" &&
|
|
translatedBody.output_config &&
|
|
typeof translatedBody.output_config === "object"
|
|
) {
|
|
const oc = translatedBody.output_config as Record<string, unknown>;
|
|
delete oc.format;
|
|
if (Object.keys(oc).length === 0) {
|
|
delete translatedBody.output_config;
|
|
}
|
|
}
|
|
} else {
|
|
translatedBody = { ...body };
|
|
|
|
// Issue #199 + #618: Always disable tool name prefix in Claude passthrough.
|
|
// The proxy_ prefix was designed for OpenAI→Claude translation to avoid
|
|
// conflicts with Claude OAuth tools, but in the passthrough path the tools
|
|
// are already in Claude format. Applying the prefix turns "Bash" into
|
|
// "proxy_Bash", which Claude rejects ("No such tool available: proxy_Bash").
|
|
if (targetFormat === FORMATS.CLAUDE) {
|
|
translatedBody._disableToolPrefix = true;
|
|
normalizeClaudeUpstreamMessages(translatedBody);
|
|
}
|
|
|
|
// OpenAI-compatible providers only support function tools.
|
|
// Non-function tool types (computer, mcp, web_search, custom, etc.) are handled:
|
|
// - tools with a name → converted to function format in-place before translation
|
|
// - tools without a name AND without .function → dropped (unconvertible)
|
|
// This must happen before translateRequest, which validates and throws on unknown types.
|
|
if (provider?.startsWith("openai-compatible-") && Array.isArray(translatedBody.tools)) {
|
|
const before = (translatedBody.tools as unknown[]).length;
|
|
translatedBody.tools = (translatedBody.tools as Record<string, unknown>[])
|
|
.filter((t) => !t.type || t.type === "function" || !!t.function || !!t.name)
|
|
.map((t) => {
|
|
if (!t.type || t.type === "function" || t.function) return t;
|
|
// Named non-function tool: normalise to function format so the translator
|
|
// does not throw on the unknown type.
|
|
return {
|
|
type: "function",
|
|
function: {
|
|
name: t.name,
|
|
...(t.description === undefined ? {} : { description: t.description }),
|
|
...(t.parameters !== undefined || t.input_schema !== undefined
|
|
? { parameters: t.parameters ?? t.input_schema ?? {} }
|
|
: {}),
|
|
...(t.strict === undefined ? {} : { strict: t.strict }),
|
|
},
|
|
};
|
|
});
|
|
const dropped = before - (translatedBody.tools as unknown[]).length;
|
|
if (dropped > 0) {
|
|
log?.debug?.(
|
|
"TOOLS",
|
|
`Dropped ${dropped} unconvertible tool(s) for openai-compatible provider`
|
|
);
|
|
}
|
|
}
|
|
|
|
const normalizeToolCallId = getModelNormalizeToolCallId(
|
|
provider || "",
|
|
model || "",
|
|
sourceFormat
|
|
);
|
|
const preserveDeveloperRole = getModelPreserveOpenAIDeveloperRole(
|
|
provider || "",
|
|
model || "",
|
|
sourceFormat
|
|
);
|
|
translatedBody = translateRequest(
|
|
sourceFormat,
|
|
targetFormat,
|
|
model,
|
|
translatedBody,
|
|
stream,
|
|
credentials,
|
|
provider,
|
|
reqLogger,
|
|
{
|
|
normalizeToolCallId,
|
|
preserveDeveloperRole,
|
|
preserveCacheControl,
|
|
signatureNamespace: connectionId,
|
|
copilotClient: copilotCompatibleReasoning,
|
|
...(preCompressionBody ? { preCompressionBody } : {}),
|
|
}
|
|
);
|
|
}
|
|
} catch (error) {
|
|
// ── Plugin onError hook ──
|
|
try {
|
|
const { runOnError } = await import("@/lib/plugins/hooks");
|
|
await runOnError(
|
|
{ requestId: traceId, body, model, provider, apiKeyInfo, metadata: {} },
|
|
error instanceof Error ? error : new Error(String(error))
|
|
);
|
|
} catch (pluginErr) {
|
|
log?.debug?.(
|
|
"PLUGIN",
|
|
`onError hook error (non-fatal): ${pluginErr instanceof Error ? pluginErr.message : String(pluginErr)}`
|
|
);
|
|
}
|
|
|
|
const parsedStatus = Number(error?.statusCode);
|
|
const statusCode =
|
|
Number.isInteger(parsedStatus) && parsedStatus >= 400 && parsedStatus <= 599
|
|
? parsedStatus
|
|
: HTTP_STATUS.SERVER_ERROR;
|
|
const message = error?.message || "Invalid request";
|
|
const errorType = typeof error?.errorType === "string" ? error.errorType : null;
|
|
|
|
log?.warn?.("TRANSLATE", `Request translation failed: ${message}`);
|
|
|
|
if (errorType) {
|
|
trackPendingRequest(model, provider, connectionId, false);
|
|
return {
|
|
success: false,
|
|
status: statusCode,
|
|
error: message,
|
|
response: new Response(
|
|
JSON.stringify({
|
|
error: {
|
|
message,
|
|
type: errorType,
|
|
code: errorType,
|
|
},
|
|
}),
|
|
{
|
|
status: statusCode,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
}
|
|
),
|
|
};
|
|
}
|
|
|
|
trackPendingRequest(model, provider, connectionId, false);
|
|
return createErrorResult(statusCode, message);
|
|
}
|
|
|
|
trace("post_translation");
|
|
|
|
// Kiro: sanitize tool schemas before dispatch. Kiro returns 400 "Improperly
|
|
// formed request" for unsupported JSON-Schema keywords (anyOf/$ref/if-then,
|
|
// etc.) and tool names >64 chars. Strip those keys and hash-truncate long
|
|
// names; merge the truncated→original nameMap into the existing
|
|
// `_toolNameMap` so kiro-to-openai maps streamed tool-call names back (#1375).
|
|
if (targetFormat === FORMATS.KIRO) {
|
|
const kiroTools =
|
|
translatedBody?.conversationState?.currentMessage?.userInputMessage?.userInputMessageContext
|
|
?.tools;
|
|
if (kiroTools) {
|
|
const { tools: sanitizedKiroTools, nameMap: kiroNameMap } = sanitizeKiroTools(kiroTools);
|
|
translatedBody.conversationState.currentMessage.userInputMessage.userInputMessageContext.tools =
|
|
sanitizedKiroTools;
|
|
if (kiroNameMap.size > 0) {
|
|
const existing =
|
|
translatedBody._toolNameMap instanceof Map
|
|
? translatedBody._toolNameMap
|
|
: new Map<string, string>();
|
|
kiroNameMap.forEach((original, truncated) => existing.set(truncated, original));
|
|
translatedBody._toolNameMap = existing;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Claude: strict Anthropic-compatible gateways (e.g. MiniMax) reject tool
|
|
// definitions that omit the required `type` discriminator with HTTP 400. Default
|
|
// a missing `type` to "custom" before dispatch, mirroring Anthropic's own
|
|
// inference, so legacy Claude-format tool payloads survive strict gateways (#2195).
|
|
if (targetFormat === FORMATS.CLAUDE && Array.isArray(translatedBody.tools)) {
|
|
translatedBody.tools = defaultClaudeToolType(
|
|
translatedBody.tools
|
|
) as typeof translatedBody.tools;
|
|
}
|
|
|
|
// Extract toolNameMap for response translation (Claude OAuth)
|
|
const translatedToolNameMap = translatedBody._toolNameMap;
|
|
const nativeClaudeToolNameMap = isClaudePassthrough
|
|
? buildClaudePassthroughToolNameMap(body)
|
|
: null;
|
|
const toolNameMap =
|
|
translatedToolNameMap instanceof Map && translatedToolNameMap.size > 0
|
|
? translatedToolNameMap
|
|
: nativeClaudeToolNameMap;
|
|
delete translatedBody._toolNameMap;
|
|
delete translatedBody._disableToolPrefix;
|
|
|
|
// Update model in body — use resolved alias so the provider gets the correct model ID (#472)
|
|
// Strip provider/alias prefix if it exactly matches the routing prefix so upstream receives the raw model name (#1261)
|
|
let finalModelToUpstream = effectiveModel;
|
|
// Defense-in-depth: only string-strip when effectiveModel is actually a string.
|
|
// The API guards `model` via Zod (z.string()), but internal callers could pass a
|
|
// non-string and a bare `.startsWith` would crash with `startsWith is not a
|
|
// function` (same class as #2359 / #2463). Mirrors 9router's `?.startsWith?.()`.
|
|
if (typeof finalModelToUpstream === "string") {
|
|
if (finalModelToUpstream.startsWith(`${provider}/`)) {
|
|
finalModelToUpstream = finalModelToUpstream.slice(provider.length + 1);
|
|
} else if (alias && finalModelToUpstream.startsWith(`${alias}/`)) {
|
|
finalModelToUpstream = finalModelToUpstream.slice(alias.length + 1);
|
|
}
|
|
}
|
|
translatedBody.model = finalModelToUpstream;
|
|
|
|
// #3554: a combo/route may substitute the upstream model AFTER the client chose its
|
|
// `thinking` value. Claude Code sends `thinking:{type:"disabled"}` for internal calls,
|
|
// which claude-fable-5 (adaptive-only) rejects with a 400. Drop the now-invalid value
|
|
// when the resolved target model rejects it; models that accept `disabled` are untouched.
|
|
if (typeof finalModelToUpstream === "string") {
|
|
translatedBody = normalizeThinkingForModel(translatedBody, finalModelToUpstream);
|
|
// Claude Opus 4.7+/Fable 5 removed manual extended thinking: `thinking.type:"enabled"`
|
|
// or any `thinking.budget_tokens` is a hard 400. Collapse any manual thinking that
|
|
// reached this point (passthrough legacy shape, reasoning_effort buckets, per-model
|
|
// defaults) to `{type:"adaptive"}` — effort stays on `output_config.effort`. Keyed on
|
|
// the resolved upstream model, so it covers every routing mode. See claudeAdaptiveThinking.ts.
|
|
translatedBody = normalizeClaudeAdaptiveThinking(translatedBody, finalModelToUpstream);
|
|
// Claude Haiku rejects `thinking.type:"adaptive"` and `output_config.effort`
|
|
// (both Sonnet 4.6 / Opus 4.5+ only). Several paths can still emit those
|
|
// shapes on a Haiku target — native passthrough, reasoning_effort buckets,
|
|
// per-model defaults — so collapse them to a Haiku-valid shape here, after
|
|
// model substitution. Mirrors upstream 9router 401d93bd5. See
|
|
// services/claudeHaikuConstraints.ts.
|
|
translatedBody = normalizeClaudeHaikuConstraints(translatedBody, finalModelToUpstream);
|
|
}
|
|
|
|
// Xiaomi MiMo controls reasoning ONLY via `thinking:{type:"enabled"|"disabled"}` and
|
|
// rejects unknown/extra params with a strict "400 Param Incorrect". Map OmniRoute's
|
|
// OpenAI reasoning signals onto that native shape: reduce any thinking object to
|
|
// `{type}` and drop `reasoning_effort`/`reasoning`. See services/mimoThinking.ts.
|
|
if (provider === "xiaomi-mimo") {
|
|
translatedBody = normalizeMimoThinking(translatedBody);
|
|
}
|
|
|
|
const previousResponseIdPolicy = applyResponsesPreviousResponseIdPolicy(translatedBody, {
|
|
mode: settings.responsesPreviousResponseIdMode,
|
|
sourceFormat,
|
|
targetFormat,
|
|
credentials,
|
|
});
|
|
translatedBody = previousResponseIdPolicy.body as typeof translatedBody;
|
|
|
|
// #1789: Prevent output_config.effort from overriding effort encoded in model name (Codex)
|
|
if (provider === "codex" || provider?.startsWith("codex")) {
|
|
const hasEffortSuffix = finalModelToUpstream.match(/-(low|medium|high|xhigh)$/i);
|
|
if (
|
|
hasEffortSuffix &&
|
|
translatedBody.output_config &&
|
|
typeof translatedBody.output_config === "object"
|
|
) {
|
|
const oc = translatedBody.output_config as Record<string, unknown>;
|
|
if (oc.effort) {
|
|
log?.warn?.(
|
|
"PARAMS",
|
|
`Stripped output_config.effort="${oc.effort}" because model "${finalModelToUpstream}" already encodes effort`
|
|
);
|
|
delete oc.effort;
|
|
if (Object.keys(oc).length === 0) {
|
|
delete translatedBody.output_config;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Strip unsupported parameters for reasoning models (o1, o3, etc.)
|
|
const unsupported = getUnsupportedParams(provider, model);
|
|
if (unsupported.length > 0) {
|
|
const stripped: string[] = [];
|
|
for (const param of unsupported) {
|
|
if (Object.hasOwn(translatedBody, param)) {
|
|
stripped.push(param);
|
|
delete translatedBody[param];
|
|
}
|
|
}
|
|
if (stripped.length > 0) {
|
|
log?.warn?.("PARAMS", `Stripped unsupported params for ${model}: ${stripped.join(", ")}`);
|
|
}
|
|
}
|
|
|
|
// GPT-5 reasoning models (openai Chat Completions) reject temperature/top_p with a 400
|
|
// whenever a reasoning effort is active, yet accept them under reasoning_effort=none (the
|
|
// GPT-5.1+ default). A static unsupportedParams list can't express that, so strip sampling
|
|
// conditionally here. The codex Responses path is already covered by the executor allowlist.
|
|
translatedBody = stripGpt5SamplingWhenReasoning(
|
|
translatedBody,
|
|
provider,
|
|
finalModelToUpstream,
|
|
log
|
|
);
|
|
|
|
// Rename max_tokens to max_completion_tokens if not supported (#1961)
|
|
if (!supportsMaxTokens({ provider, model })) {
|
|
if (translatedBody.max_tokens !== undefined) {
|
|
if (translatedBody.max_completion_tokens === undefined) {
|
|
translatedBody.max_completion_tokens = translatedBody.max_tokens;
|
|
}
|
|
delete translatedBody.max_tokens;
|
|
log?.debug?.("PARAMS", `Renamed max_tokens to max_completion_tokens for ${model}`);
|
|
}
|
|
}
|
|
|
|
// OpenAI's `store` parameter is not supported by most compatible providers and breaks them
|
|
if (provider !== "openai" && "store" in translatedBody) {
|
|
delete translatedBody.store;
|
|
}
|
|
|
|
// Chat clients may send stream_options.include_usage, but OpenAI Responses
|
|
// upstreams (including Azure AI Foundry /responses) reject stream_options.
|
|
if (targetFormat === FORMATS.OPENAI_RESPONSES && "stream_options" in translatedBody) {
|
|
delete translatedBody.stream_options;
|
|
}
|
|
|
|
// Provider-specific max_tokens caps (#711)
|
|
// Some providers reject requests when max_tokens exceeds their API limit.
|
|
// Cap before sending to avoid upstream HTTP 400 errors.
|
|
const providerCap = PROVIDER_MAX_TOKENS[provider];
|
|
if (providerCap) {
|
|
for (const field of ["max_tokens", "max_completion_tokens"] as const) {
|
|
if (typeof translatedBody[field] === "number" && translatedBody[field] > providerCap) {
|
|
log?.debug?.(
|
|
"PARAMS",
|
|
`Capping ${field} from ${translatedBody[field]} to ${providerCap} for ${provider}`
|
|
);
|
|
translatedBody[field] = providerCap;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Resolve executor with optional upstream proxy (CLIProxyAPI) routing.
|
|
// mode="native" (default): returns the native executor unchanged.
|
|
// mode="cliproxyapi": returns the CLIProxyAPI executor instead.
|
|
// mode="fallback": returns a wrapper that tries native first, falls back to CLIProxyAPI on 5xx/network errors.
|
|
|
|
// #6339: pass the resolved connection's providerSpecificData so a per-connection
|
|
// cliproxyapiMode="claude-native" override can deep-route this single connection
|
|
// through CLIProxyAPI regardless of the provider-level upstream_proxy_config mode.
|
|
const resolveExecutorWithProxy = (prov: string) =>
|
|
resolveExecutorWithProxyFor(
|
|
prov,
|
|
log,
|
|
(credentials?.providerSpecificData as Record<string, unknown> | null | undefined) ?? null
|
|
);
|
|
|
|
// === Quota Share enforcement PRE-hook (B/F7) ===
|
|
// Runs after provider/model/credentials/apiKeyInfo are fully resolved,
|
|
// before dispatcher. Fail-open per B16: errors → allow.
|
|
let quotaSoftDeprioritize = false;
|
|
if (apiKeyInfo?.id && credentials?.connectionId) {
|
|
try {
|
|
const { enforceQuotaShare } = await import("@/lib/quota/enforce");
|
|
const decision = await enforceQuotaShare({
|
|
apiKeyId: apiKeyInfo.id,
|
|
connectionId: credentials.connectionId,
|
|
provider: provider ?? "unknown",
|
|
// Resolved model id (post background-redirect / alias) — the same scope the
|
|
// router/log use. Operators configure per-(key,model) caps against THIS id.
|
|
model: model || undefined,
|
|
estimatedCost: {},
|
|
}).catch((err: unknown) => {
|
|
log?.warn?.(
|
|
"QUOTA_SHARE",
|
|
`enforceQuotaShare failed; fail-open: ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
return { kind: "allow" as const };
|
|
});
|
|
|
|
if (decision.kind === "block") {
|
|
const { buildErrorBody } = await import("../utils/error.ts");
|
|
log?.warn?.(
|
|
"QUOTA_SHARE",
|
|
`[quotaShare] blocked apiKeyId=${apiKeyInfo.id} provider=${provider ?? "unknown"}: ${decision.reason}`
|
|
);
|
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
if (decision.retryAfterSeconds) {
|
|
headers["Retry-After"] = String(decision.retryAfterSeconds);
|
|
}
|
|
return new Response(JSON.stringify(buildErrorBody(429, decision.reason)), {
|
|
status: 429,
|
|
headers,
|
|
});
|
|
}
|
|
|
|
if (decision.kind === "allow" && decision.deprioritize) {
|
|
quotaSoftDeprioritize = true;
|
|
log?.info?.(
|
|
"QUOTA_SHARE",
|
|
`[quotaShare] soft deprioritize active for apiKeyId=${apiKeyInfo.id} provider=${provider ?? "unknown"}`
|
|
);
|
|
}
|
|
} catch (err) {
|
|
// Outer fail-open guard — should not be reached (inner .catch covers it)
|
|
log?.warn?.(
|
|
"QUOTA_SHARE",
|
|
`[quotaShare] enforceQuotaShare unexpected error; fail-open: ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
}
|
|
}
|
|
// G2: Propagate soft penalty to the current candidate so combo scoring can deprioritize.
|
|
if (quotaSoftDeprioritize && isCombo && comboStepId) {
|
|
try {
|
|
const { setCandidateQuotaSoftPenalty } = await import("../services/combo");
|
|
setCandidateQuotaSoftPenalty(comboExecutionKey, comboStepId, true);
|
|
} catch (err) {
|
|
log?.warn?.(
|
|
"QUOTA_SHARE",
|
|
`[quotaShare] could not set soft penalty on candidate: ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
}
|
|
}
|
|
// === /Quota Share enforcement PRE-hook ===
|
|
|
|
// Get executor for this provider (with optional upstream proxy routing)
|
|
const executor = await resolveExecutorWithProxy(provider);
|
|
const getExecutionCredentials = () =>
|
|
resolveExecutionCredentialsFor({
|
|
credentials,
|
|
nativeCodexPassthrough,
|
|
endpointPath,
|
|
targetFormat,
|
|
provider,
|
|
ccSessionId,
|
|
});
|
|
|
|
let onPipelineStreamError: streamFailure.PipelineStreamErrorHandler | null = null;
|
|
let onClientDisconnectFinalize:
|
|
((event: { reason: string; duration: number }) => boolean) | null = null;
|
|
|
|
// Create stream controller for disconnect detection
|
|
const streamController = createStreamController({
|
|
onDisconnect: (event) => {
|
|
let finalized = false;
|
|
try {
|
|
finalized = onClientDisconnectFinalize?.(event) === true;
|
|
} catch {}
|
|
if (!finalized) {
|
|
try {
|
|
finalizePendingScope(pendingScope, {
|
|
status: 499,
|
|
error: `Client disconnected: ${event.reason}`,
|
|
errorCode: "client_disconnected",
|
|
});
|
|
finalized = true;
|
|
} catch {}
|
|
}
|
|
try {
|
|
onDisconnect?.(event);
|
|
} catch {}
|
|
return finalized;
|
|
},
|
|
onError: (event) => onPipelineStreamError?.(event),
|
|
provider,
|
|
model,
|
|
connectionId,
|
|
clientResponseFormat,
|
|
clientAbortSignal: clientRawRequest?.signal,
|
|
});
|
|
|
|
const dedupRequestBody = { ...translatedBody, model: `${provider}/${model}`, stream };
|
|
const dedupEnabled = shouldDeduplicate(dedupRequestBody);
|
|
const dedupHash = dedupEnabled ? computeRequestHash(dedupRequestBody) : null;
|
|
|
|
const executeProviderRequest = async (modelToCall = effectiveModel, allowDedup = false) => {
|
|
const execute = async () => {
|
|
// Upstream body preparation extracted to chatCore/upstreamBody.ts (#3501 — first internal
|
|
// sub-slice of executeProviderRequest); produces the body sent upstream (payload rules +
|
|
// tool-limit truncation + qwen oauth user backfill + prompt_cache_key injection).
|
|
let bodyToSend = await prepareUpstreamBody({
|
|
translatedBody,
|
|
modelToCall,
|
|
provider,
|
|
targetFormat,
|
|
credentials,
|
|
log,
|
|
bypassDefaultToolLimit: isOpencodeClient,
|
|
});
|
|
|
|
updatePendingScope(pendingScope, {
|
|
providerRequest: bodyToSend,
|
|
stage: "payload_prepared",
|
|
});
|
|
|
|
let releaseRawResultAccountSemaphore = () => {};
|
|
try {
|
|
const rawResult = await (async () => {
|
|
let attempts = 0;
|
|
const isModelScopeForRequest = isModelScope();
|
|
const maxAttempts = isModelScopeForRequest
|
|
? 3
|
|
: provider === "qwen"
|
|
? 3
|
|
: provider === "codex"
|
|
? 3
|
|
: 1;
|
|
|
|
// ── Codex 429 account-rotation state ─────────────────────────────────
|
|
// Track excluded connection IDs for codex failover across attempts.
|
|
const codexExcludedIds: string[] = [];
|
|
// Derive session affinity key once for codex failover (used to clear affinity on 429).
|
|
const codexSessionAffinityKey =
|
|
provider === "codex"
|
|
? (extractSessionAffinityKey(body, clientRawRequest?.headers) ?? null)
|
|
: null;
|
|
|
|
while (attempts < maxAttempts) {
|
|
trace("pre_executor", { attempt: attempts });
|
|
updatePendingScope(pendingScope, {
|
|
stage: "sending_to_provider",
|
|
});
|
|
const execCreds = getExecutionCredentials();
|
|
const attemptConnectionId = execCreds?.connectionId || connectionId;
|
|
const accountSemaphoreMaxConcurrency = resolveAccountSemaphoreMaxConcurrency(execCreds);
|
|
const accountSemaphoreKey = resolveAccountSemaphoreKey({
|
|
provider,
|
|
model: modelToCall,
|
|
connectionId: attemptConnectionId,
|
|
credentials: execCreds,
|
|
});
|
|
|
|
trace("pre_semaphore", {
|
|
semaphoreKey: accountSemaphoreKey,
|
|
max: accountSemaphoreMaxConcurrency,
|
|
});
|
|
if (accountSemaphoreKey && accountSemaphoreMaxConcurrency != null) {
|
|
updatePendingScope(pendingScope, {
|
|
stage: "waiting_account_slot",
|
|
});
|
|
}
|
|
const releaseAccountSemaphore =
|
|
accountSemaphoreKey && accountSemaphoreMaxConcurrency != null
|
|
? await acquireAccountSemaphore(accountSemaphoreKey, {
|
|
maxConcurrency: accountSemaphoreMaxConcurrency,
|
|
signal: streamController.signal,
|
|
})
|
|
: () => {};
|
|
trace("post_semaphore");
|
|
updatePendingScope(pendingScope, {
|
|
stage: "waiting_rate_limit",
|
|
});
|
|
|
|
try {
|
|
trace("pre_rate_limit", { connectionId: attemptConnectionId });
|
|
const rawExecutorResult = await withRateLimit(
|
|
provider,
|
|
attemptConnectionId,
|
|
modelToCall,
|
|
async () => {
|
|
trace("inside_rate_limit", { connectionId: attemptConnectionId });
|
|
updatePendingScope(pendingScope, {
|
|
stage: "rate_limit_slot_acquired",
|
|
});
|
|
return executeWithUpstreamStartTimeout({
|
|
executor,
|
|
provider,
|
|
model: modelToCall,
|
|
signal: streamController.signal,
|
|
log,
|
|
execute: (signal) =>
|
|
runWithCapture(providerRequestCapture, () =>
|
|
executor.execute({
|
|
model: modelToCall,
|
|
body: bodyToSend,
|
|
stream: upstreamStream,
|
|
credentials: execCreds,
|
|
signal,
|
|
log,
|
|
extendedContext,
|
|
upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall),
|
|
clientHeaders: buildExecutorClientHeaders(
|
|
clientRawRequest?.headers,
|
|
userAgent
|
|
),
|
|
onCredentialsRefreshed,
|
|
skipUpstreamRetry,
|
|
contextEditing: { enabled: contextEditingEnabled },
|
|
})
|
|
),
|
|
});
|
|
},
|
|
streamController.signal
|
|
);
|
|
const res = normalizeExecutorResult(rawExecutorResult);
|
|
trace("post_executor", { status: res?.response?.status });
|
|
|
|
// Track Gemini RPM + RPD request counts for 429 classification
|
|
if (provider === "gemini") {
|
|
incrementRequestCount(modelToCall);
|
|
}
|
|
|
|
updatePendingScope(pendingScope, {
|
|
stage: "provider_response_started",
|
|
});
|
|
|
|
if (res.response.status === 401 && execCreds?.connectionId) {
|
|
recordKeyHealthStatus(401, execCreds);
|
|
}
|
|
|
|
// Qwen 429 strict quota backoff (wait 1.5s, 3s and retry)
|
|
if (
|
|
provider === "qwen" &&
|
|
res.response.status === 429 &&
|
|
attempts < maxAttempts - 1
|
|
) {
|
|
const bodyPeek = await res.response
|
|
.clone()
|
|
.text()
|
|
.catch(() => "");
|
|
if (bodyPeek.toLowerCase().includes("exceeded your current quota")) {
|
|
const delay = 1500 * (attempts + 1);
|
|
log?.warn?.("QWEN_RETRY", `Quota 429 hit. Retrying in ${delay}ms...`);
|
|
releaseAccountSemaphore();
|
|
await new Promise((r) => setTimeout(r, delay));
|
|
attempts++;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (isModelScope() && res.response.status === 429 && attempts < maxAttempts - 1) {
|
|
const bodyPeek = await res.response
|
|
.clone()
|
|
.text()
|
|
.catch(() => "");
|
|
const normalizedHeaders = normalizeHeaders(res.response.headers);
|
|
const decision = classifyModelScope429(bodyPeek, normalizedHeaders);
|
|
if (decision.retryable) {
|
|
const delay = getModelScopeRetryDelayMs(normalizedHeaders, attempts);
|
|
log?.warn?.(
|
|
"MODELSCOPE_RETRY",
|
|
`429 ${decision.kind}; retrying in ${delay}ms (model remaining: ${decision.snapshot.modelRemaining ?? "unknown"})`
|
|
);
|
|
releaseAccountSemaphore();
|
|
await new Promise((r) => setTimeout(r, delay));
|
|
attempts++;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Codex 429 account-rotation failover (disabled for context-relay so combo.ts can inject handoff)
|
|
if (
|
|
provider === "codex" &&
|
|
comboStrategy !== "context-relay" &&
|
|
res.response.status === 429 &&
|
|
attempts < maxAttempts - 1
|
|
) {
|
|
const failedConnectionId =
|
|
execCreds?.connectionId || credentials?.connectionId || connectionId;
|
|
const normalizedHeaders = normalizeHeaders(res.response.headers);
|
|
const retryAfterHeader = normalizedHeaders["retry-after"] ?? null;
|
|
const retryAfterMs = retryAfterHeader
|
|
? Number.parseFloat(retryAfterHeader) * 1000
|
|
: null;
|
|
|
|
log?.warn?.(
|
|
"CODEX_FAILOVER",
|
|
`429 on connection ${String(failedConnectionId).slice(0, 8)} (attempt ${attempts + 1}/${maxAttempts}), rotating account`
|
|
);
|
|
|
|
// Mark only the current Codex model scope as rate-limited.
|
|
if (failedConnectionId) {
|
|
await markCodexScopeRateLimited({
|
|
failedConnectionId: String(failedConnectionId),
|
|
model: modelToCall || model || requestedModel || null,
|
|
rateLimitedUntil: new Date(Date.now() + (retryAfterMs || 60_000)).toISOString(),
|
|
credentials,
|
|
});
|
|
// Fix B: also persist the cooldown to
|
|
// `provider_connections.rate_limited_until`. Without this,
|
|
// the Codex 429 cascade survives the current request (via
|
|
// `markCodexScopeRateLimited`'s in-memory Map) but is lost
|
|
// on process restart — the same exhausted Codex key is
|
|
// re-picked on the very next request. Mirrors
|
|
// `open-sse/executors/antigravity.ts:343`.
|
|
// Best-effort: never crash the chat path on DB write failure.
|
|
try {
|
|
const { setConnectionRateLimitUntil } = await import("@/lib/db/providers");
|
|
const untilMs = Date.now() + (retryAfterMs || 60_000);
|
|
setConnectionRateLimitUntil(String(failedConnectionId), untilMs);
|
|
} catch {
|
|
// ignore — best effort
|
|
}
|
|
if (!codexExcludedIds.includes(String(failedConnectionId))) {
|
|
codexExcludedIds.push(String(failedConnectionId));
|
|
}
|
|
}
|
|
|
|
// Clear session affinity so next request won't be pinned to the failing account
|
|
if (codexSessionAffinityKey) {
|
|
try {
|
|
deleteSessionAccountAffinity(codexSessionAffinityKey, "codex");
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
}
|
|
|
|
// Fetch next available codex connection (excluding all previously failed ones)
|
|
const nextCreds = await getProviderCredentials(
|
|
"codex",
|
|
null,
|
|
null,
|
|
modelToCall || model || requestedModel || null,
|
|
{
|
|
excludeConnectionIds: [...codexExcludedIds],
|
|
}
|
|
).catch(() => null);
|
|
|
|
if (!nextCreds || nextCreds.allRateLimited) {
|
|
log?.warn?.("CODEX_FAILOVER", "No more codex accounts available — returning 429");
|
|
if (stream) {
|
|
releaseAccountSemaphore();
|
|
return {
|
|
...res,
|
|
_executionCredentials: execCreds,
|
|
};
|
|
}
|
|
return {
|
|
...res,
|
|
_accountSemaphoreRelease: releaseAccountSemaphore,
|
|
_executionCredentials: execCreds,
|
|
};
|
|
}
|
|
|
|
const newConnectionId = nextCreds.connectionId;
|
|
log?.info?.(
|
|
"CODEX_FAILOVER",
|
|
`Rotating codex account: ${String(failedConnectionId).slice(0, 8)} → ${newConnectionId.slice(0, 8)} (attempt ${attempts + 2}/${maxAttempts})`
|
|
);
|
|
|
|
logAuditEvent({
|
|
action: "codex.account_rotation",
|
|
actor: apiKeyInfo?.name || "system",
|
|
target: newConnectionId,
|
|
details: {
|
|
failed_connection_id: failedConnectionId,
|
|
new_connection_id: newConnectionId,
|
|
attempt: attempts + 1,
|
|
retry_after_ms: retryAfterMs,
|
|
},
|
|
});
|
|
|
|
// Update credentials in-place so getExecutionCredentials() picks up the new account
|
|
Object.assign(credentials, nextCreds);
|
|
|
|
releaseAccountSemaphore();
|
|
attempts++;
|
|
continue;
|
|
}
|
|
|
|
// For streaming: release the semaphore when the client drains or cancels the stream.
|
|
if (stream) {
|
|
const originalBody = res.response.body;
|
|
if (!originalBody) {
|
|
releaseAccountSemaphore();
|
|
return res;
|
|
}
|
|
|
|
// Opt-in transparent stream recovery (free-claude-code port, default OFF).
|
|
// Only engages for a successful (2xx) stream — an error body must never be
|
|
// held or replayed. Setting is read once here from the cached resolved
|
|
// resilience settings; the default path is byte-for-byte unchanged.
|
|
const okStatus = res.response.status >= 200 && res.response.status < 300;
|
|
let streamRecoveryEnabled = false;
|
|
let continueMidStreamEnabled = false;
|
|
if (okStatus) {
|
|
try {
|
|
// Reuse the request-consolidated settings read (see line ~2076) — no
|
|
// second DB/cache hit. Default OFF when the setting is absent.
|
|
const sr = resolveResilienceSettings(settings).streamRecovery;
|
|
// Fail-closed: the agent-goal-policy heuristic may only ADD recovery
|
|
// when the operator has no explicit configuration. If the operator
|
|
// explicitly configured stream recovery (env var or DB/settings
|
|
// override), that value always wins — the goal policy must never
|
|
// re-enable recovery the operator explicitly turned off.
|
|
const operatorExplicit = isStreamRecoveryExplicitlyConfigured(settings);
|
|
const goalOverride = !operatorExplicit && agentGoalPolicy.streamRecoveryEnabled;
|
|
streamRecoveryEnabled = sr.enabled || goalOverride;
|
|
continueMidStreamEnabled = sr.continueMidStream === true;
|
|
if (goalOverride && !sr.enabled) {
|
|
log?.info?.(
|
|
"AGENT_GOAL",
|
|
`agentGoalPolicy override: stream recovery enabled for goal request requestId=${traceId} model=${modelToCall || model || requestedModel || "unknown"}`
|
|
);
|
|
}
|
|
} catch {
|
|
streamRecoveryEnabled = false;
|
|
continueMidStreamEnabled = false;
|
|
}
|
|
}
|
|
|
|
let clientBody: ReadableStream<Uint8Array>;
|
|
if (streamRecoveryEnabled) {
|
|
// Run the SAME upstream (same account/creds) with a given body and return
|
|
// its 2xx stream, or null. Used both by the early-retry re-open (same body)
|
|
// and the mid-stream continuation (assistant-prefilled body).
|
|
const runUpstreamStream = async (
|
|
body: unknown
|
|
): Promise<ReadableStream<Uint8Array> | null> => {
|
|
try {
|
|
const retryRaw = await executeWithUpstreamStartTimeout({
|
|
executor,
|
|
provider,
|
|
model: modelToCall,
|
|
signal: streamController.signal,
|
|
log,
|
|
execute: (signal) =>
|
|
runWithCapture(providerRequestCapture, () =>
|
|
executor.execute({
|
|
model: modelToCall,
|
|
body,
|
|
stream: upstreamStream,
|
|
credentials: execCreds,
|
|
signal,
|
|
log,
|
|
extendedContext,
|
|
upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall),
|
|
clientHeaders: buildExecutorClientHeaders(
|
|
clientRawRequest?.headers,
|
|
userAgent
|
|
),
|
|
onCredentialsRefreshed,
|
|
skipUpstreamRetry,
|
|
contextEditing: { enabled: contextEditingEnabled },
|
|
})
|
|
),
|
|
});
|
|
const retryRes = normalizeExecutorResult(retryRaw);
|
|
const retryOk =
|
|
retryRes.response.status >= 200 && retryRes.response.status < 300;
|
|
if (retryOk && retryRes.response.body) {
|
|
return retryRes.response.body as ReadableStream<Uint8Array>;
|
|
}
|
|
await retryRes.response.body?.cancel().catch(() => {});
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Mid-stream continuation (Fase 4.4): re-request with the partial text as an
|
|
// assistant prefill. Gated by its own setting and only for OpenAI-compatible
|
|
// bodies (makeContinuationBody returns null otherwise).
|
|
const continueStream = continueMidStreamEnabled
|
|
? (assistantSoFar: string) => {
|
|
const continuationBody = makeContinuationBody(
|
|
bodyToSend as Record<string, unknown>,
|
|
assistantSoFar
|
|
);
|
|
return continuationBody
|
|
? runUpstreamStream(continuationBody)
|
|
: Promise.resolve(null);
|
|
}
|
|
: undefined;
|
|
|
|
clientBody = createRecoverableStream(
|
|
originalBody as ReadableStream<Uint8Array>,
|
|
() => runUpstreamStream(bodyToSend),
|
|
{
|
|
finalize: releaseAccountSemaphore,
|
|
onRetry: (attempt, err) =>
|
|
log?.warn?.(
|
|
"STREAM_RECOVERY",
|
|
`transparent early-retry ${attempt}/${STREAM_RECOVERY.EARLY_RETRY_MAX} after ${
|
|
(err as { name?: string })?.name || "truncation"
|
|
}`
|
|
),
|
|
continueStream,
|
|
onContinue: (attempt) =>
|
|
log?.warn?.(
|
|
"STREAM_RECOVERY",
|
|
`mid-stream continuation attempt ${attempt}/${STREAM_RECOVERY.EARLY_RETRY_MAX}`
|
|
),
|
|
}
|
|
);
|
|
} else {
|
|
clientBody = wrapReadableStreamWithFinalize(
|
|
originalBody,
|
|
releaseAccountSemaphore
|
|
);
|
|
}
|
|
|
|
return {
|
|
...res,
|
|
_executionCredentials: execCreds,
|
|
response: new Response(clientBody, {
|
|
status: res.response.status,
|
|
statusText: res.response.statusText,
|
|
headers: new Headers(normalizeHeaders(res.response.headers)),
|
|
}),
|
|
};
|
|
}
|
|
|
|
return {
|
|
...res,
|
|
_executionCredentials: execCreds,
|
|
_accountSemaphoreRelease: releaseAccountSemaphore,
|
|
};
|
|
} catch (error) {
|
|
releaseAccountSemaphore();
|
|
throw error;
|
|
}
|
|
}
|
|
})();
|
|
|
|
if (stream) {
|
|
return rawResult;
|
|
}
|
|
|
|
// Non-stream: release semaphore immediately after reading full response body.
|
|
const status = rawResult.response.status;
|
|
|
|
// Use execution credentials captured during request processing
|
|
if (
|
|
rawResult._executionCredentials?.connectionId &&
|
|
rawResult._executionCredentials?.apiKey
|
|
) {
|
|
recordKeyHealthStatus(status, rawResult._executionCredentials);
|
|
}
|
|
releaseRawResultAccountSemaphore =
|
|
typeof rawResult._accountSemaphoreRelease === "function"
|
|
? rawResult._accountSemaphoreRelease
|
|
: () => {};
|
|
|
|
const statusText = rawResult.response.statusText;
|
|
const headersObj = normalizeHeaders(rawResult.response.headers);
|
|
const responseHeaders = new Headers(headersObj);
|
|
stripStaleForwardingHeaders(responseHeaders);
|
|
stripNextMiddlewareControlHeaders(responseHeaders);
|
|
const contentType = (responseHeaders.get("content-type") || "").toLowerCase();
|
|
const payload = await readNonStreamingResponseBody(
|
|
rawResult.response,
|
|
contentType,
|
|
upstreamStream
|
|
);
|
|
releaseRawResultAccountSemaphore();
|
|
releaseRawResultAccountSemaphore = () => {};
|
|
|
|
return {
|
|
...rawResult,
|
|
response: new Response(payload, { status, statusText, headers: responseHeaders }),
|
|
_dedupSnapshot: {
|
|
status,
|
|
statusText,
|
|
headers: (() => {
|
|
const arr: [string, string][] = [];
|
|
responseHeaders.forEach((v, k) => arr.push([k, v]));
|
|
return arr;
|
|
})(),
|
|
payload,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
releaseRawResultAccountSemaphore();
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
if (allowDedup && dedupEnabled && dedupHash) {
|
|
const dedupResult = await deduplicate(dedupHash, execute);
|
|
if (dedupResult.wasDeduplicated) {
|
|
log?.debug?.("DEDUP", `Joined in-flight request hash=${dedupHash}`);
|
|
}
|
|
return materializeDeduplicatedExecutionResult(dedupResult.result);
|
|
}
|
|
|
|
return execute();
|
|
};
|
|
|
|
const registeredProviderRequest =
|
|
translatedBody && typeof translatedBody === "object" && !Array.isArray(translatedBody)
|
|
? {
|
|
...(translatedBody as Record<string, unknown>),
|
|
model:
|
|
typeof (translatedBody as Record<string, unknown>).model === "string"
|
|
? (translatedBody as Record<string, unknown>).model
|
|
: effectiveModel,
|
|
...(!Array.isArray((translatedBody as Record<string, unknown>).messages) &&
|
|
Array.isArray((body as Record<string, unknown>).messages)
|
|
? { messages: (body as Record<string, unknown>).messages }
|
|
: {}),
|
|
}
|
|
: translatedBody;
|
|
|
|
updatePendingScope(pendingScope, {
|
|
providerRequest: registeredProviderRequest,
|
|
});
|
|
// T5: track which models we've tried for intra-family fallback
|
|
const triedModels = new Set<string>([effectiveModel]);
|
|
let currentModel = effectiveModel;
|
|
|
|
// Log start
|
|
appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => {});
|
|
|
|
const msgCount =
|
|
translatedBody.messages?.length ||
|
|
translatedBody.contents?.length ||
|
|
translatedBody.request?.contents?.length ||
|
|
(translatedBody.conversationState?.history?.length ?? 0) +
|
|
(translatedBody.conversationState?.currentMessage ? 1 : 0) ||
|
|
0;
|
|
log?.debug?.("REQUEST", `${provider?.toUpperCase()} | ${model} | ${msgCount} msgs`);
|
|
|
|
// ── Tier 2: Authoritative per-model/provider token-limit check (provider now resolved) ──
|
|
if (apiKeyInfo?.id) {
|
|
try {
|
|
const tokenBreach = checkTokenLimits(
|
|
apiKeyInfo.id,
|
|
provider || undefined,
|
|
model || undefined
|
|
);
|
|
if (tokenBreach) {
|
|
const scopeLabel =
|
|
tokenBreach.scopeType === "global"
|
|
? "account"
|
|
: `${tokenBreach.scopeType} "${tokenBreach.scopeValue}"`;
|
|
// FIX 6: clear the pending request marker before the early return so we do
|
|
// not leak a phantom pending request (start was tracked at line ~1847).
|
|
trackPendingRequest(model, provider, connectionId, false);
|
|
// FIX 5: tag this as a per-API-key token-limit breach (errorCode
|
|
// TOKEN_LIMIT_EXCEEDED) so the combo loop can distinguish it from an
|
|
// upstream 429 and NOT cool shared accounts / retry it transiently.
|
|
return createErrorResult(
|
|
HTTP_STATUS.RATE_LIMITED,
|
|
`Token limit exceeded for ${scopeLabel}: ${tokenBreach.tokensUsed}/${tokenBreach.limitValue} tokens used in the current window. Please try again later.`,
|
|
null,
|
|
"TOKEN_LIMIT_EXCEEDED"
|
|
);
|
|
}
|
|
} catch (err) {
|
|
// Fail-open at Tier 2: Tier 1 already enforced the model/global limit pre-dispatch.
|
|
// A transient counter read error here must not break an otherwise-valid request.
|
|
log?.warn?.("TOKEN_LIMIT", "Tier 2 token-limit check failed; allowing request", { err });
|
|
}
|
|
}
|
|
|
|
// Execute request using executor (handles URL building, headers, fallback, transform)
|
|
let providerResponse;
|
|
let providerUrl;
|
|
let providerHeaders;
|
|
let finalBody;
|
|
let claudePromptCacheLogMeta = null;
|
|
|
|
try {
|
|
const result = await executeProviderRequest(effectiveModel, true);
|
|
|
|
providerResponse = result.response;
|
|
providerUrl = result.url;
|
|
providerHeaders = result.headers;
|
|
finalBody = providerRequestCapture.body(result.transformedBody);
|
|
const responseConnectionId = getCurrentConnectionId();
|
|
effectiveServiceTier = resolveEffectiveServiceTier(finalBody);
|
|
claudePromptCacheLogMeta = buildClaudePromptCacheLogMeta(
|
|
targetFormat,
|
|
finalBody,
|
|
providerHeaders,
|
|
clientRawRequest?.headers
|
|
);
|
|
|
|
// Log target request (final request to provider)
|
|
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
|
|
updatePendingScope(pendingScope, {
|
|
providerRequest: finalBody,
|
|
providerUrl,
|
|
stage: "provider_response_started",
|
|
});
|
|
// Update rate limiter from response headers (learn limits dynamically)
|
|
updateFromHeaders(
|
|
provider,
|
|
responseConnectionId,
|
|
providerResponse.headers,
|
|
providerResponse.status,
|
|
model
|
|
);
|
|
|
|
// Store rate-limit headers for quota saturation signals
|
|
try {
|
|
const { storeRateLimitHeaders } = await import("@/lib/quota/saturationSignals");
|
|
storeRateLimitHeaders(
|
|
responseConnectionId,
|
|
provider,
|
|
providerResponse.headers as Record<string, string>
|
|
);
|
|
} catch {
|
|
// fail-open: saturation signal is best-effort
|
|
}
|
|
} catch (error) {
|
|
trackPendingRequest(model, provider, connectionId, false);
|
|
if (isSemaphoreCapacityError(error)) {
|
|
appendRequestLog({
|
|
model,
|
|
provider,
|
|
connectionId,
|
|
status: `FAILED ${error.code}`,
|
|
}).catch(() => {});
|
|
const failureMessage = error.message || "Semaphore timeout";
|
|
persistAttemptLogs({
|
|
status: HTTP_STATUS.RATE_LIMITED,
|
|
error: failureMessage,
|
|
providerRequest: finalBody || translatedBody,
|
|
clientResponse: buildErrorBody(HTTP_STATUS.RATE_LIMITED, failureMessage),
|
|
claudeCacheMeta: claudePromptCacheLogMeta,
|
|
cacheSource: "upstream",
|
|
});
|
|
persistFailureUsage(HTTP_STATUS.RATE_LIMITED, error.code);
|
|
const result = stream
|
|
? createStreamingErrorResult(HTTP_STATUS.RATE_LIMITED, failureMessage, error.code)
|
|
: createErrorResult(HTTP_STATUS.RATE_LIMITED, failureMessage);
|
|
return {
|
|
...result,
|
|
errorType: "account_semaphore_capacity",
|
|
errorCode: error.code,
|
|
};
|
|
}
|
|
const failureStatus =
|
|
error.name === "AbortError"
|
|
? 499
|
|
: error.name === "TimeoutError" || error.name === "BodyTimeoutError"
|
|
? HTTP_STATUS.GATEWAY_TIMEOUT
|
|
: error.status && typeof error.status === "number"
|
|
? error.status
|
|
: HTTP_STATUS.BAD_GATEWAY;
|
|
const failureMessage =
|
|
error.name === "AbortError"
|
|
? "Request aborted"
|
|
: formatProviderError(error, provider, model, failureStatus);
|
|
const upstreamErrorCode = getUpstreamErrorIdentifier(error);
|
|
// Tag our own deadline timeouts (fetch-start TimeoutError / body BodyTimeoutError,
|
|
// both surfaced as a 504) as "upstream_timeout" so the cooldown layer can tell a
|
|
// slow-but-not-failed request apart from a real provider 5xx. (Antigravity already
|
|
// tags its pre-response timeout via the code below.)
|
|
const isOwnDeadlineTimeout =
|
|
failureStatus === HTTP_STATUS.GATEWAY_TIMEOUT &&
|
|
(error.name === "TimeoutError" || error.name === "BodyTimeoutError");
|
|
const upstreamErrorType =
|
|
upstreamErrorCode === ANTIGRAVITY_PRE_RESPONSE_TIMEOUT_CODE || isOwnDeadlineTimeout
|
|
? "upstream_timeout"
|
|
: failureStatus === 401
|
|
? "authentication_error"
|
|
: undefined;
|
|
appendRequestLog({
|
|
model,
|
|
provider,
|
|
connectionId,
|
|
status: `FAILED ${failureStatus}`,
|
|
}).catch(() => {});
|
|
persistAttemptLogs({
|
|
status: failureStatus,
|
|
error: failureMessage,
|
|
providerRequest: finalBody || translatedBody,
|
|
clientResponse: buildErrorBody(failureStatus, failureMessage),
|
|
claudeCacheMeta: claudePromptCacheLogMeta,
|
|
cacheSource: "upstream",
|
|
});
|
|
if (error.name === "AbortError") {
|
|
streamController.handleError(error);
|
|
return createErrorResult(499, "Request aborted");
|
|
}
|
|
persistFailureUsage(
|
|
failureStatus,
|
|
upstreamErrorCode || (error instanceof Error && error.name ? error.name : "upstream_error")
|
|
);
|
|
console.log(`${COLORS.red}[ERROR] ${failureMessage}${COLORS.reset}`);
|
|
if (stream && upstreamErrorCode) {
|
|
const result = createStreamingErrorResult(
|
|
failureStatus,
|
|
failureMessage,
|
|
upstreamErrorCode,
|
|
upstreamErrorType
|
|
);
|
|
return {
|
|
...result,
|
|
errorType: upstreamErrorType,
|
|
errorCode: upstreamErrorCode,
|
|
};
|
|
}
|
|
return createErrorResult(
|
|
failureStatus,
|
|
failureMessage,
|
|
null,
|
|
upstreamErrorCode,
|
|
upstreamErrorType
|
|
);
|
|
}
|
|
// We need to peek at the error text if it's 400 for Qwen
|
|
let upstreamErrorParsed = false;
|
|
let parsedStatusCode = providerResponse.status;
|
|
let parsedMessage = "";
|
|
let parsedRetryAfterMs: number | null = null;
|
|
let upstreamErrorBody: unknown = null;
|
|
|
|
if (provider === "qwen" && providerResponse.status === HTTP_STATUS.BAD_REQUEST) {
|
|
const errorDetails = await parseUpstreamError(providerResponse, provider);
|
|
parsedStatusCode = errorDetails.statusCode;
|
|
parsedMessage = errorDetails.message;
|
|
parsedRetryAfterMs = errorDetails.retryAfterMs;
|
|
upstreamErrorBody = errorDetails.responseBody;
|
|
upstreamErrorParsed = true;
|
|
}
|
|
|
|
const errorMessageForToolDetection =
|
|
typeof upstreamErrorBody === "string"
|
|
? upstreamErrorBody
|
|
: JSON.stringify(upstreamErrorBody ?? {});
|
|
if (shouldDetectLimit(errorMessageForToolDetection, parsedStatusCode)) {
|
|
const detectedLimit = parseToolLimitFromError(errorMessageForToolDetection);
|
|
if (detectedLimit) {
|
|
setDetectedToolLimit(provider, detectedLimit);
|
|
log?.info?.("TOOL_LIMIT", `Detected tool limit ${detectedLimit} for ${provider}`);
|
|
}
|
|
}
|
|
|
|
const isQwenExpiredError =
|
|
provider === "qwen" &&
|
|
parsedStatusCode === HTTP_STATUS.BAD_REQUEST &&
|
|
parsedMessage?.toLowerCase().includes("session has expired");
|
|
|
|
// Track whether stream_options was present and stripped — if so, 401/403 after
|
|
// that may be from the modification rather than a genuine auth failure, so we
|
|
// skip the credential refresh attempt in that case.
|
|
const hadStreamOptions =
|
|
targetFormat === FORMATS.OPENAI_RESPONSES && "stream_options" in translatedBody;
|
|
if (hadStreamOptions) {
|
|
delete translatedBody.stream_options;
|
|
}
|
|
|
|
// Handle 401/403 (and Qwen explicit expiration) - try token refresh using executor
|
|
if (
|
|
(providerResponse.status === HTTP_STATUS.UNAUTHORIZED ||
|
|
providerResponse.status === HTTP_STATUS.FORBIDDEN ||
|
|
isQwenExpiredError) &&
|
|
!hadStreamOptions // Skip refresh if failure may be from stream_options removal, not auth
|
|
) {
|
|
// Fix A: wrap refreshCredentials in runWithOnPersist so the persist callback
|
|
// executes INSIDE the per-connection mutex held by getAccessToken. This makes
|
|
// [network refresh + DB write + outer-state mutation] one atomic step and
|
|
// prevents concurrent requests from reading a stale refreshToken before the
|
|
// DB has been updated (refresh_token_reused on Codex/OpenAI).
|
|
//
|
|
// Not every executor routes refresh through getAccessToken (e.g. github.ts
|
|
// calls refreshCopilotToken directly). When the persistFn doesn't fire from
|
|
// inside getAccessToken, we still need to do the credentials mutation + user
|
|
// callback after refreshCredentials returns. The `persistFnRan` flag tracks
|
|
// which path executed so we don't double-fire (race-prone) or skip (regression).
|
|
// Front 3: remember the refresh_token we are about to present so that, if the
|
|
// refresh fails as unrecoverable, we can tell a genuine death apart from a
|
|
// stale-token reuse that a concurrent/sibling refresh already rotated past.
|
|
const attemptedRefreshToken =
|
|
typeof credentials?.refreshToken === "string" ? credentials.refreshToken : null;
|
|
let persistFnRan = false;
|
|
const persistFn = onCredentialsRefreshed
|
|
? async (refreshResult: Record<string, unknown>) => {
|
|
persistFnRan = true;
|
|
// Mutate the shared credentials object so subsequent executor calls
|
|
// in this request see the new tokens. Runs INSIDE the mutex.
|
|
Object.assign(credentials, refreshResult);
|
|
await onCredentialsRefreshed(refreshResult);
|
|
}
|
|
: undefined;
|
|
|
|
// #4038: build a compare-and-swap reread so getAccessToken can skip the persist if a
|
|
// concurrent writer (sibling request / HealthCheck / replica) already rotated this
|
|
// connection's refresh_token past the one we presented — overwriting would revert it
|
|
// and revoke the token family. No connectionId ⇒ no guard (behavior unchanged).
|
|
const casConnectionId =
|
|
typeof credentials?.connectionId === "string" ? credentials.connectionId.trim() : "";
|
|
const casReread = casConnectionId
|
|
? async () => {
|
|
const latest = await getProviderConnectionById(casConnectionId);
|
|
return typeof latest?.refreshToken === "string" ? latest.refreshToken : null;
|
|
}
|
|
: null;
|
|
|
|
const newCredentials = (await refreshWithRetry(
|
|
() =>
|
|
runWithCasGuard(
|
|
casReread ? { expectedRefreshToken: attemptedRefreshToken, reread: casReread } : null,
|
|
() => runWithOnPersist(persistFn, () => executor.refreshCredentials(credentials, log))
|
|
),
|
|
3,
|
|
log,
|
|
provider // Explicitly pass the provider to avoid universally tripping the "unknown" circuit breaker
|
|
)) as null | {
|
|
accessToken?: string;
|
|
copilotToken?: string;
|
|
};
|
|
|
|
if (newCredentials?.accessToken || newCredentials?.copilotToken) {
|
|
log?.info?.("TOKEN", `${provider?.toUpperCase()} | refreshed`);
|
|
|
|
// Fall back to post-mutex mutation only for executors that don't route
|
|
// through getAccessToken (and therefore never fire onPersist). For
|
|
// executors that DO route through it (Codex, Claude, Gemini, etc.) the
|
|
// mutation already happened atomically inside the mutex.
|
|
if (!persistFnRan) {
|
|
Object.assign(credentials, newCredentials);
|
|
if (onCredentialsRefreshed) {
|
|
await onCredentialsRefreshed(newCredentials);
|
|
}
|
|
}
|
|
|
|
// Retry with new credentials — model + extra headers follow translatedBody.model so they
|
|
// stay aligned if this block ever runs after a path that mutates body.model (e.g. fallback).
|
|
try {
|
|
const retryModelId = String(translatedBody.model || effectiveModel);
|
|
const retryResult = await runWithCapture(providerRequestCapture, () =>
|
|
executor.execute({
|
|
model: retryModelId,
|
|
body: translatedBody,
|
|
stream: upstreamStream,
|
|
credentials: getExecutionCredentials(),
|
|
signal: streamController.signal,
|
|
log,
|
|
extendedContext,
|
|
upstreamExtraHeaders: buildUpstreamHeadersForExecute(retryModelId),
|
|
clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent),
|
|
onCredentialsRefreshed,
|
|
skipUpstreamRetry: isCombo,
|
|
contextEditing: { enabled: contextEditingEnabled },
|
|
})
|
|
);
|
|
|
|
if (retryResult.response.ok) {
|
|
providerResponse = retryResult.response;
|
|
providerUrl = retryResult.url;
|
|
providerHeaders = new Headers(retryResult.headers || {});
|
|
finalBody = providerRequestCapture.body(retryResult.transformedBody);
|
|
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
|
|
updatePendingScope(pendingScope, {
|
|
providerRequest: finalBody,
|
|
providerUrl,
|
|
stage: "provider_response_started",
|
|
});
|
|
upstreamErrorParsed = false; // Reset since new response is OK
|
|
} else {
|
|
providerResponse = retryResult.response;
|
|
upstreamErrorParsed = false; // Let it be parsed downstream
|
|
}
|
|
} catch (retryErr) {
|
|
// Refresh succeeded but the retry leg failed (network blip, AbortError,
|
|
// executor throw). Don't swallow — the operator-visible signal "the user
|
|
// saw 401 even though auth was actually fixed" is much more confusing
|
|
// than the original 401 alone. Surface at error level with sanitization.
|
|
log?.error?.(
|
|
"TOKEN",
|
|
`${provider?.toUpperCase()} | retry after refresh failed: ${sanitizeErrorMessage(retryErr)}`
|
|
);
|
|
}
|
|
} else {
|
|
log?.warn?.("TOKEN", `${provider?.toUpperCase()} | refresh failed`);
|
|
if (isUnrecoverableRefreshError(newCredentials) && onCredentialsRefreshed) {
|
|
// Front 3 (reuse-race tolerance): before deactivating, re-read the DB.
|
|
// If a sibling/concurrent refresh already rotated this connection's
|
|
// refresh_token (common for Codex/OpenAI under one shared Auth0 client),
|
|
// the failure we saw was a stale-token reuse — the account is healthy
|
|
// with the newer token, so keep it active instead of killing it.
|
|
let alreadyRotated = false;
|
|
if (typeof connectionId === "string" && connectionId && attemptedRefreshToken) {
|
|
try {
|
|
const latest = await getProviderConnectionById(connectionId);
|
|
if (wasRefreshTokenRotated(attemptedRefreshToken, latest?.refreshToken)) {
|
|
alreadyRotated = true;
|
|
log?.warn?.(
|
|
"TOKEN",
|
|
`${provider.toUpperCase()} | refresh_token already rotated by a concurrent refresh — keeping connection active`
|
|
);
|
|
}
|
|
} catch {
|
|
// DB read failed — fall through to the safe default (deactivate).
|
|
}
|
|
}
|
|
if (!alreadyRotated) {
|
|
await onCredentialsRefreshed({ testStatus: "expired", isActive: false });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
await persistCodexQuotaState(normalizeHeaders(providerResponse.headers), providerResponse.status);
|
|
|
|
// Check provider response - return error info for fallback handling
|
|
if (!providerResponse.ok) {
|
|
trackPendingRequest(model, provider, connectionId, false);
|
|
|
|
let statusCode = providerResponse.status;
|
|
let message = "";
|
|
let retryAfterMs: number | null = null;
|
|
let upstreamErrorCode: string | undefined;
|
|
let upstreamErrorType: string | undefined;
|
|
|
|
if (upstreamErrorParsed) {
|
|
statusCode = parsedStatusCode;
|
|
message = parsedMessage;
|
|
retryAfterMs = parsedRetryAfterMs;
|
|
} else {
|
|
const details = await parseUpstreamError(providerResponse, provider);
|
|
statusCode = details.statusCode;
|
|
message = details.message;
|
|
retryAfterMs = details.retryAfterMs;
|
|
upstreamErrorBody = details.responseBody;
|
|
upstreamErrorCode = details.errorCode as string | undefined;
|
|
upstreamErrorType = details.errorType as string | undefined;
|
|
}
|
|
|
|
// T06/T10/T36: classify provider errors and persist terminal account states.
|
|
let errorType = classifyProviderError(statusCode, message, provider);
|
|
if (statusCode === 429 && isModelScope()) {
|
|
const decision = classifyModelScope429(message, normalizeHeaders(providerResponse.headers));
|
|
errorType =
|
|
decision.kind === "quota_exhausted"
|
|
? PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED
|
|
: PROVIDER_ERROR_TYPES.RATE_LIMITED;
|
|
log?.warn?.(
|
|
"MODELSCOPE_429",
|
|
`${decision.kind} (model remaining: ${decision.snapshot.modelRemaining ?? "unknown"}, total remaining: ${decision.snapshot.totalRemaining ?? "unknown"})`
|
|
);
|
|
}
|
|
const errorConnectionId = getCurrentConnectionId();
|
|
if (errorConnectionId && errorType) {
|
|
try {
|
|
if (errorType === PROVIDER_ERROR_TYPES.FORBIDDEN) {
|
|
await updateProviderConnection(errorConnectionId, {
|
|
isActive: false,
|
|
testStatus: "banned",
|
|
lastErrorType: errorType,
|
|
lastError: message,
|
|
errorCode: statusCode,
|
|
});
|
|
console.warn(
|
|
`[provider] Node ${errorConnectionId} banned (${statusCode}) — disabling permanently`
|
|
);
|
|
} else if (errorType === PROVIDER_ERROR_TYPES.ACCOUNT_DEACTIVATED) {
|
|
// Plan A: if connection has extra API keys, don't disable — only the failing key is affected.
|
|
// Single-key connections still get disabled as before.
|
|
if (
|
|
connectionHasExtraKeys(
|
|
errorConnectionId,
|
|
(credentials?.providerSpecificData as Record<string, unknown> | undefined)
|
|
?.extraApiKeys as string[] | undefined
|
|
)
|
|
) {
|
|
await updateProviderConnection(errorConnectionId, {
|
|
lastErrorType: errorType,
|
|
lastError: message,
|
|
errorCode: statusCode,
|
|
});
|
|
console.warn(
|
|
`[provider] Node ${errorConnectionId} account deactivated (${statusCode}) — has extra keys, keeping connection active`
|
|
);
|
|
} else {
|
|
await updateProviderConnection(errorConnectionId, {
|
|
isActive: false,
|
|
testStatus: "deactivated",
|
|
lastErrorType: errorType,
|
|
lastError: message,
|
|
errorCode: statusCode,
|
|
});
|
|
console.warn(
|
|
`[provider] Node ${errorConnectionId} account deactivated (${statusCode}) — disabling permanently`
|
|
);
|
|
}
|
|
} else if (errorType === PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED) {
|
|
// Providers with per-model quotas — lock the model only, not the connection
|
|
const quotaCooldownMs = retryAfterMs || COOLDOWN_MS.rateLimit;
|
|
const accountSemaphoreKey = resolveAccountSemaphoreKey({
|
|
provider,
|
|
model: currentModel,
|
|
connectionId: errorConnectionId,
|
|
credentials,
|
|
});
|
|
if (accountSemaphoreKey) {
|
|
markAccountSemaphoreBlocked(accountSemaphoreKey, quotaCooldownMs);
|
|
}
|
|
if (isModelScope() && errorConnectionId) {
|
|
lockModel(provider, errorConnectionId, model, "quota_exhausted", quotaCooldownMs);
|
|
console.warn(
|
|
`[provider] Node ${errorConnectionId} ModelScope model quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (connection stays active)`
|
|
);
|
|
} else if (
|
|
lockModelIfPerModelQuota(
|
|
provider,
|
|
errorConnectionId,
|
|
model,
|
|
"quota_exhausted",
|
|
quotaCooldownMs
|
|
)
|
|
) {
|
|
const quotaScope = getQuotaScopeLabelForProvider(provider, model);
|
|
console.warn(
|
|
`[provider] Node ${errorConnectionId} ${quotaScope}-only quota exhausted (${statusCode}) for ${model} - ${Math.ceil(quotaCooldownMs / 1000)}s (cooldown_scope=${quotaScope}, ttl_source=${retryAfterMs ? "upstream" : "inferred"}, connection stays active)`
|
|
);
|
|
} else {
|
|
await updateProviderConnection(errorConnectionId, {
|
|
testStatus: "credits_exhausted",
|
|
lastErrorType: errorType,
|
|
lastError: message,
|
|
errorCode: statusCode,
|
|
});
|
|
console.warn(`[provider] Node ${errorConnectionId} exhausted quota (${statusCode})`);
|
|
}
|
|
} else if (errorType === PROVIDER_ERROR_TYPES.UNAUTHORIZED) {
|
|
// Normal 401 (token/session auth issue): keep account active for refresh/re-auth.
|
|
await updateProviderConnection(errorConnectionId, {
|
|
lastErrorType: errorType,
|
|
lastError: message,
|
|
errorCode: statusCode,
|
|
});
|
|
} else if (errorType === PROVIDER_ERROR_TYPES.OAUTH_INVALID_TOKEN) {
|
|
// OAuth 401 with invalid credentials - token refresh can recover
|
|
await updateProviderConnection(errorConnectionId, {
|
|
lastErrorType: errorType,
|
|
lastError: message,
|
|
errorCode: statusCode,
|
|
});
|
|
console.warn(
|
|
`[provider] Node ${errorConnectionId} OAuth token invalid (${statusCode}) — token refresh available`
|
|
);
|
|
} else if (errorType === PROVIDER_ERROR_TYPES.PROJECT_ROUTE_ERROR) {
|
|
// Cloud Code 403 with stale project: not a ban, keep account active.
|
|
await updateProviderConnection(errorConnectionId, {
|
|
lastErrorType: errorType,
|
|
lastError: message,
|
|
errorCode: statusCode,
|
|
});
|
|
console.warn(
|
|
`[provider] Node ${errorConnectionId} project routing error (${statusCode}) — not banning`
|
|
);
|
|
}
|
|
} catch {
|
|
// Best-effort state update; request flow should continue with fallback handling.
|
|
}
|
|
}
|
|
|
|
appendRequestLog({
|
|
model,
|
|
provider,
|
|
connectionId: errorConnectionId,
|
|
status: `FAILED ${statusCode}`,
|
|
}).catch(() => {});
|
|
|
|
const errMsg = formatProviderError(new Error(message), provider, model, statusCode);
|
|
console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
|
|
|
|
// Log Antigravity retry time if available
|
|
if (retryAfterMs && provider === "antigravity") {
|
|
const retrySeconds = Math.ceil(retryAfterMs / 1000);
|
|
log?.debug?.("RETRY", `Antigravity quota reset in ${retrySeconds}s (${retryAfterMs}ms)`);
|
|
}
|
|
|
|
// Log error with full request body for debugging
|
|
reqLogger.logError(new Error(message), finalBody || translatedBody);
|
|
reqLogger.logProviderResponse(
|
|
providerResponse.status,
|
|
providerResponse.statusText,
|
|
providerResponse.headers,
|
|
upstreamErrorBody
|
|
);
|
|
|
|
// Update rate limiter from error response headers
|
|
updateFromHeaders(provider, errorConnectionId, providerResponse.headers, statusCode, model);
|
|
if (errorConnectionId && upstreamErrorBody !== null && upstreamErrorBody !== undefined) {
|
|
updateFromResponseBody(provider, errorConnectionId, upstreamErrorBody, statusCode, model);
|
|
}
|
|
|
|
// ── T5: Intra-family model fallback ──────────────────────────────────────
|
|
// Before returning a model-unavailable error upstream, try sibling models
|
|
// from the same family. This keeps the request alive on the same account
|
|
// instead of failing the entire combo.
|
|
if (isModelUnavailableError(statusCode, message)) {
|
|
const nextModel = getNextFamilyFallback(currentModel, triedModels);
|
|
if (nextModel) {
|
|
triedModels.add(nextModel);
|
|
currentModel = nextModel;
|
|
translatedBody.model = nextModel;
|
|
log?.info?.("MODEL_FALLBACK", `${model} unavailable (${statusCode}) → trying ${nextModel}`);
|
|
// Re-execute with the fallback model
|
|
try {
|
|
const fallbackResult = await executeProviderRequest(nextModel, false);
|
|
if (fallbackResult.response.ok) {
|
|
providerResponse = fallbackResult.response;
|
|
providerUrl = fallbackResult.url;
|
|
providerHeaders = fallbackResult.headers;
|
|
finalBody = providerRequestCapture.body(fallbackResult.transformedBody);
|
|
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
|
|
updatePendingScope(pendingScope, {
|
|
providerRequest: finalBody,
|
|
providerUrl,
|
|
stage: "provider_response_started",
|
|
});
|
|
// Continue processing with the fallback response — skip error return
|
|
log?.info?.("MODEL_FALLBACK", `Serving ${nextModel} as fallback for ${model}`);
|
|
// Jump to streaming/non-streaming handling below
|
|
// We fall through by NOT returning here
|
|
} else {
|
|
// Fallback also failed — return original error
|
|
persistAttemptLogs({
|
|
status: statusCode,
|
|
error: errMsg,
|
|
providerRequest: finalBody || translatedBody,
|
|
providerResponse: upstreamErrorBody,
|
|
clientResponse: buildErrorBody(statusCode, errMsg),
|
|
cacheSource: "upstream",
|
|
});
|
|
persistFailureUsage(statusCode, "model_unavailable");
|
|
return createErrorResult(
|
|
statusCode,
|
|
errMsg,
|
|
retryAfterMs,
|
|
upstreamErrorCode,
|
|
upstreamErrorType,
|
|
upstreamErrorBody
|
|
);
|
|
}
|
|
} catch {
|
|
persistAttemptLogs({
|
|
status: statusCode,
|
|
error: errMsg,
|
|
providerRequest: finalBody || translatedBody,
|
|
providerResponse: upstreamErrorBody,
|
|
clientResponse: buildErrorBody(statusCode, errMsg),
|
|
cacheSource: "upstream",
|
|
});
|
|
persistFailureUsage(statusCode, "model_unavailable");
|
|
return createErrorResult(
|
|
statusCode,
|
|
errMsg,
|
|
retryAfterMs,
|
|
upstreamErrorCode,
|
|
upstreamErrorType,
|
|
upstreamErrorBody
|
|
);
|
|
}
|
|
} else {
|
|
persistAttemptLogs({
|
|
status: statusCode,
|
|
error: errMsg,
|
|
providerRequest: finalBody || translatedBody,
|
|
providerResponse: upstreamErrorBody,
|
|
clientResponse: buildErrorBody(statusCode, errMsg),
|
|
cacheSource: "upstream",
|
|
});
|
|
persistFailureUsage(statusCode, "model_unavailable");
|
|
return createErrorResult(
|
|
statusCode,
|
|
errMsg,
|
|
retryAfterMs,
|
|
upstreamErrorCode,
|
|
upstreamErrorType,
|
|
upstreamErrorBody
|
|
);
|
|
}
|
|
} else if (isContextOverflowError(statusCode, message)) {
|
|
const familyCandidates = getModelFamily(currentModel).filter(
|
|
(m) => m !== currentModel && !triedModels.has(m)
|
|
);
|
|
const nextModel =
|
|
findLargerContextModel(currentModel, familyCandidates) ??
|
|
getNextFamilyFallback(currentModel, triedModels);
|
|
if (nextModel) {
|
|
triedModels.add(nextModel);
|
|
currentModel = nextModel;
|
|
translatedBody.model = nextModel;
|
|
log?.info?.("CONTEXT_OVERFLOW_FALLBACK", `${model} context overflow → trying ${nextModel}`);
|
|
try {
|
|
const fallbackResult = await executeProviderRequest(nextModel, false);
|
|
if (fallbackResult.response.ok) {
|
|
providerResponse = fallbackResult.response;
|
|
providerUrl = fallbackResult.url;
|
|
providerHeaders = fallbackResult.headers;
|
|
finalBody = providerRequestCapture.body(fallbackResult.transformedBody);
|
|
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
|
|
updatePendingScope(pendingScope, {
|
|
providerRequest: finalBody,
|
|
providerUrl,
|
|
stage: "provider_response_started",
|
|
});
|
|
log?.info?.(
|
|
"CONTEXT_OVERFLOW_FALLBACK",
|
|
`Serving ${nextModel} as fallback for ${model}`
|
|
);
|
|
} else {
|
|
persistAttemptLogs({
|
|
status: statusCode,
|
|
error: errMsg,
|
|
providerRequest: finalBody || translatedBody,
|
|
providerResponse: upstreamErrorBody,
|
|
clientResponse: buildErrorBody(statusCode, errMsg),
|
|
cacheSource: "upstream",
|
|
});
|
|
persistFailureUsage(statusCode, "context_overflow");
|
|
return createErrorResult(
|
|
statusCode,
|
|
errMsg,
|
|
retryAfterMs,
|
|
upstreamErrorCode,
|
|
upstreamErrorType,
|
|
upstreamErrorBody
|
|
);
|
|
}
|
|
} catch {
|
|
persistAttemptLogs({
|
|
status: statusCode,
|
|
error: errMsg,
|
|
providerRequest: finalBody || translatedBody,
|
|
providerResponse: upstreamErrorBody,
|
|
clientResponse: buildErrorBody(statusCode, errMsg),
|
|
cacheSource: "upstream",
|
|
});
|
|
persistFailureUsage(statusCode, "context_overflow");
|
|
return createErrorResult(
|
|
statusCode,
|
|
errMsg,
|
|
retryAfterMs,
|
|
upstreamErrorCode,
|
|
upstreamErrorType,
|
|
upstreamErrorBody
|
|
);
|
|
}
|
|
} else {
|
|
persistAttemptLogs({
|
|
status: statusCode,
|
|
error: errMsg,
|
|
providerRequest: finalBody || translatedBody,
|
|
providerResponse: upstreamErrorBody,
|
|
clientResponse: buildErrorBody(statusCode, errMsg),
|
|
cacheSource: "upstream",
|
|
});
|
|
persistFailureUsage(statusCode, "context_overflow");
|
|
return createErrorResult(
|
|
statusCode,
|
|
errMsg,
|
|
retryAfterMs,
|
|
upstreamErrorCode,
|
|
upstreamErrorType,
|
|
upstreamErrorBody
|
|
);
|
|
}
|
|
} else {
|
|
persistAttemptLogs({
|
|
status: statusCode,
|
|
error: errMsg,
|
|
providerRequest: finalBody || translatedBody,
|
|
providerResponse: upstreamErrorBody,
|
|
clientResponse: buildErrorBody(statusCode, errMsg),
|
|
cacheSource: "upstream",
|
|
});
|
|
persistFailureUsage(statusCode, `upstream_${statusCode}`);
|
|
|
|
// Emergency budget fallback is orchestrated exclusively by the routing layer
|
|
// (src/sse/handlers/chat.ts), which resolves credentials FOR the emergency
|
|
// provider through account selection. The executor-level hop that used to
|
|
// live here re-sent the FAILING provider's credentials to the emergency
|
|
// provider's endpoint (e.g. the OpenAI API key to integrate.api.nvidia.com)
|
|
// — a cross-provider credential leak that also never succeeded upstream.
|
|
return createErrorResult(
|
|
statusCode,
|
|
errMsg,
|
|
retryAfterMs,
|
|
upstreamErrorCode,
|
|
upstreamErrorType,
|
|
upstreamErrorBody
|
|
);
|
|
}
|
|
// ── End T5 ───────────────────────────────────────────────────────────────
|
|
}
|
|
|
|
// Non-streaming response
|
|
if (!stream) {
|
|
const parsed = await parseNonStreamingResponseBody({
|
|
providerResponse,
|
|
upstreamStream,
|
|
providerHeaders,
|
|
finalBody,
|
|
targetFormat,
|
|
model,
|
|
log,
|
|
});
|
|
const normalizedProviderPayload = parsed.normalizedProviderPayload;
|
|
const looksLikeSSE = parsed.looksLikeSSE;
|
|
|
|
if (parsed.kind === "invalid_sse") {
|
|
appendRequestLog({
|
|
model,
|
|
provider,
|
|
connectionId,
|
|
status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`,
|
|
}).catch(() => {});
|
|
const invalidSseMessage = parsed.message;
|
|
persistAttemptLogs({
|
|
status: HTTP_STATUS.BAD_GATEWAY,
|
|
error: invalidSseMessage,
|
|
providerRequest: finalBody || translatedBody,
|
|
providerResponse: normalizedProviderPayload,
|
|
clientResponse: buildErrorBody(HTTP_STATUS.BAD_GATEWAY, invalidSseMessage),
|
|
cacheSource: "upstream",
|
|
});
|
|
persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "invalid_sse_payload");
|
|
trackPendingRequest(model, provider, pendingConnId, false);
|
|
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, invalidSseMessage);
|
|
}
|
|
|
|
if (parsed.kind === "invalid_json") {
|
|
appendRequestLog({
|
|
model,
|
|
provider,
|
|
connectionId,
|
|
status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`,
|
|
}).catch(() => {});
|
|
const detailedError = parsed.detailedError;
|
|
const invalidJsonMessage = parsed.message;
|
|
persistAttemptLogs({
|
|
status: HTTP_STATUS.BAD_GATEWAY,
|
|
error: detailedError,
|
|
providerRequest: finalBody || translatedBody,
|
|
providerResponse: normalizedProviderPayload,
|
|
clientResponse: buildErrorBody(HTTP_STATUS.BAD_GATEWAY, invalidJsonMessage),
|
|
cacheSource: "upstream",
|
|
});
|
|
persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "invalid_json_payload");
|
|
trackPendingRequest(model, provider, connectionId, false);
|
|
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, invalidJsonMessage);
|
|
}
|
|
|
|
let responseBody = parsed.responseBody;
|
|
let responsePayloadFormat = parsed.responsePayloadFormat;
|
|
|
|
// ── ClinePass {success,data} envelope unwrap (before translation) ──────────
|
|
// ClinePass wraps non-streaming JSON in a {success, data} envelope; errors
|
|
// use {success:false, error}. Transient {success:false, error:"empty..."}
|
|
// responses get one 2s retry before surfacing. CLINEPASS-GATED — untouched
|
|
// for every other provider. Envelope errors route through createErrorResult
|
|
// (→ buildErrorBody/sanitizeErrorMessage, Rule #12).
|
|
if (provider === "clinepass") {
|
|
let { body: unwrapped, error: envError } = unwrapClinepassEnvelope(responseBody, provider);
|
|
if (envError && /empty/i.test(envError.message || "")) {
|
|
log?.warn?.("RETRY", "clinepass returned empty content, retrying once after 2s");
|
|
await new Promise((r) => setTimeout(r, 2000));
|
|
try {
|
|
const retryResult = await executeProviderRequest(effectiveModel, false);
|
|
if (retryResult?.response?.ok) {
|
|
const retryParsed = await parseNonStreamingResponseBody({
|
|
providerResponse: retryResult.response,
|
|
upstreamStream: undefined,
|
|
providerHeaders: retryResult.headers,
|
|
finalBody: retryResult.transformedBody,
|
|
targetFormat,
|
|
model,
|
|
log,
|
|
});
|
|
if (retryParsed.kind !== "invalid_sse" && retryParsed.kind !== "invalid_json") {
|
|
providerResponse = retryResult.response;
|
|
providerUrl = retryResult.url;
|
|
providerHeaders = retryResult.headers;
|
|
finalBody = providerRequestCapture.body(retryResult.transformedBody);
|
|
({ body: unwrapped, error: envError } = unwrapClinepassEnvelope(
|
|
retryParsed.responseBody,
|
|
provider
|
|
));
|
|
}
|
|
}
|
|
} catch (retryErr) {
|
|
log?.warn?.(
|
|
"RETRY",
|
|
`clinepass retry failed: ${
|
|
retryErr instanceof Error ? retryErr.message : String(retryErr)
|
|
}`
|
|
);
|
|
}
|
|
}
|
|
if (envError) {
|
|
appendRequestLog({
|
|
model,
|
|
provider,
|
|
connectionId,
|
|
status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`,
|
|
}).catch(() => {});
|
|
persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "clinepass_envelope_error");
|
|
trackPendingRequest(model, provider, connectionId, false);
|
|
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, envError.message);
|
|
}
|
|
responseBody = unwrapped;
|
|
}
|
|
responseBody = unwrapClineNonStreamingEnvelope(provider, responseBody);
|
|
|
|
// Check for empty content response (fake success) - trigger fallback
|
|
if (isEmptyContentResponse(responseBody)) {
|
|
appendRequestLog({
|
|
model,
|
|
provider,
|
|
connectionId,
|
|
status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`,
|
|
}).catch(() => {});
|
|
const emptyContentMessage = "Provider returned empty content";
|
|
persistAttemptLogs({
|
|
status: HTTP_STATUS.BAD_GATEWAY,
|
|
error: emptyContentMessage,
|
|
providerRequest: finalBody || translatedBody,
|
|
providerResponse: normalizedProviderPayload,
|
|
clientResponse: buildErrorBody(HTTP_STATUS.BAD_GATEWAY, emptyContentMessage),
|
|
cacheSource: "upstream",
|
|
});
|
|
persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "empty_content");
|
|
|
|
// Trigger non-recursive fallback for empty content
|
|
const nextModel = getNextFamilyFallback(currentModel, triedModels);
|
|
if (nextModel) {
|
|
triedModels.add(nextModel);
|
|
currentModel = nextModel;
|
|
translatedBody.model = nextModel;
|
|
log?.info?.(
|
|
"EMPTY_CONTENT_FALLBACK",
|
|
`${model} returned empty content → trying ${nextModel}`
|
|
);
|
|
try {
|
|
const fallbackResult = await executeProviderRequest(nextModel, false);
|
|
if (fallbackResult.response.ok) {
|
|
const fallbackRaw = await withBodyTimeout<string>(fallbackResult.response.text());
|
|
try {
|
|
responseBody = fallbackRaw ? JSON.parse(fallbackRaw) : {};
|
|
providerUrl = fallbackResult.url;
|
|
providerHeaders = fallbackResult.headers;
|
|
finalBody = providerRequestCapture.body(fallbackResult.transformedBody);
|
|
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
|
|
log?.info?.(
|
|
"EMPTY_CONTENT_FALLBACK",
|
|
`Serving ${nextModel} as fallback for ${model}`
|
|
);
|
|
// Fall through — continue processing with the new responseBody
|
|
} catch {
|
|
trackPendingRequest(model, provider, connectionId, false);
|
|
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, emptyContentMessage);
|
|
}
|
|
} else {
|
|
trackPendingRequest(model, provider, connectionId, false);
|
|
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, emptyContentMessage);
|
|
}
|
|
} catch {
|
|
trackPendingRequest(model, provider, connectionId, false);
|
|
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, emptyContentMessage);
|
|
}
|
|
} else {
|
|
trackPendingRequest(model, provider, connectionId, false);
|
|
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, emptyContentMessage);
|
|
}
|
|
}
|
|
|
|
const responseToolNameMap = mergeResponseToolNameMap(
|
|
toolNameMap,
|
|
(finalBody as Record<string, unknown> | null | undefined) ?? null
|
|
);
|
|
|
|
if (sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE) {
|
|
responseBody = restoreClaudePassthroughToolNames(responseBody, responseToolNameMap);
|
|
}
|
|
reqLogger.logProviderResponse(
|
|
providerResponse.status,
|
|
providerResponse.statusText,
|
|
providerResponse.headers,
|
|
looksLikeSSE
|
|
? {
|
|
_streamed: true,
|
|
_format: "sse-json",
|
|
summary: responseBody,
|
|
}
|
|
: responseBody
|
|
);
|
|
effectiveServiceTier = resolveReportedServiceTier(responseBody) ?? effectiveServiceTier;
|
|
|
|
// Notify success - caller can clear error status if needed
|
|
if (onRequestSuccess) {
|
|
await onRequestSuccess();
|
|
}
|
|
const successConnectionId = getCurrentConnectionId();
|
|
await maybeSyncClaudeExtraUsageState({
|
|
provider,
|
|
connectionId: successConnectionId,
|
|
providerSpecificData: credentials?.providerSpecificData,
|
|
log,
|
|
});
|
|
|
|
// Log usage for non-streaming responses
|
|
const usage = extractUsageFromResponse(responseBody, provider);
|
|
if (usage && typeof usage === "object") {
|
|
attachCompressionUsageReceiptAfterAnalytics(usage as Record<string, unknown>, "provider");
|
|
}
|
|
|
|
// Context Editing telemetry: when the delegated server-side clear actually ran,
|
|
// record the provider's cleared-token receipt under engine "context-editing" so
|
|
// it surfaces in compression analytics. Best-effort, Claude-only, non-streaming.
|
|
recordContextEditingTelemetryHook({
|
|
contextEditingEnabled,
|
|
provider,
|
|
responseBody,
|
|
skillRequestId,
|
|
log,
|
|
});
|
|
appendRequestLog({
|
|
model,
|
|
provider,
|
|
connectionId: successConnectionId,
|
|
tokens: usage,
|
|
status: "200 OK",
|
|
}).catch(() => {});
|
|
|
|
// Save structured call log with full payloads
|
|
const cacheUsageLogMeta = buildCacheUsageLogMeta(usage);
|
|
recordNonStreamingUsageStats(usage, {
|
|
traceEnabled,
|
|
provider,
|
|
connectionId: successConnectionId,
|
|
model,
|
|
startTime,
|
|
apiKeyInfo,
|
|
effectiveServiceTier,
|
|
isCombo,
|
|
comboStrategy,
|
|
endpoint: endpointPath,
|
|
});
|
|
|
|
// Translate response to client's expected format (usually OpenAI)
|
|
// Pass toolNameMap so Claude OAuth proxy_ prefix is stripped in tool_use blocks (#605)
|
|
let translatedResponse = needsTranslation(responsePayloadFormat, clientResponseFormat)
|
|
? translateNonStreamingResponse(
|
|
responseBody,
|
|
responsePayloadFormat,
|
|
clientResponseFormat,
|
|
responseToolNameMap
|
|
)
|
|
: responseBody;
|
|
const memoryExtractionResponse = translatedResponse;
|
|
|
|
// T26: Strip markdown code blocks if provider format is Claude
|
|
if (sourceFormat === "claude" && !stream) {
|
|
if (typeof translatedResponse?.choices?.[0]?.message?.content === "string") {
|
|
translatedResponse.choices[0].message.content = stripMarkdownCodeFence(
|
|
translatedResponse.choices[0].message.content
|
|
) as string;
|
|
}
|
|
}
|
|
|
|
// T18: Normalize finish_reason to 'tool_calls' if tool calls are present
|
|
if (translatedResponse?.choices) {
|
|
for (const choice of translatedResponse.choices) {
|
|
if (
|
|
choice.message?.tool_calls &&
|
|
choice.message.tool_calls.length > 0 &&
|
|
choice.finish_reason !== "tool_calls"
|
|
) {
|
|
choice.finish_reason = "tool_calls";
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reasoning Replay Cache (#1628): Capture reasoning_content from non-streaming responses
|
|
// with tool_calls so it can be replayed on subsequent turns (DeepSeek V4, Kimi K2, etc.)
|
|
try {
|
|
const firstChoice = translatedResponse?.choices?.[0];
|
|
const msg = firstChoice?.message;
|
|
cacheReasoningFromAssistantMessage(msg, provider, model, {
|
|
requestId: skillRequestId,
|
|
messageIndex: 0,
|
|
});
|
|
} catch {
|
|
// Cache capture is non-critical — never block the response
|
|
}
|
|
// Sanitize response for OpenAI SDK compatibility
|
|
// Strips non-standard fields (x_groq, usage_breakdown, service_tier, etc.)
|
|
// Extracts <think> and <thinking> tags into reasoning_content
|
|
// Source format determines output shape. If we are outputting OpenAI shape or pseudo-OpenAI shape, sanitize.
|
|
if (clientResponseFormat === FORMATS.OPENAI_RESPONSES) {
|
|
translatedResponse = sanitizeResponsesApiResponse(translatedResponse);
|
|
} else if (clientResponseFormat === FORMATS.OPENAI) {
|
|
// Port of decolua/9router#517: opt-in `x-omniroute-strip-reasoning` header
|
|
// unconditionally drops `reasoning_content` from the final non-streaming
|
|
// JSON for clients (e.g. Firecrawl AI SDK) whose JSON parsers break on
|
|
// that non-standard field. Reasoning replay cache is captured above this
|
|
// sanitize step, so the cache feature is unaffected.
|
|
const stripReasoning = isStripReasoningRequested(clientRawRequest?.headers ?? null);
|
|
translatedResponse = sanitizeOpenAIResponse(translatedResponse, {
|
|
stripReasoning,
|
|
parseTextualReasoningTags: shouldParseTextualReasoningTags(provider, model),
|
|
});
|
|
}
|
|
|
|
applyClientUsageBuffer(translatedResponse, body, clientResponseFormat);
|
|
|
|
if (memoryOwnerId && memorySettings?.enabled && memorySettings.maxTokens > 0) {
|
|
const requestMemoryText = extractMemoryTextFromRequestBody(body as Record<string, unknown>);
|
|
if (requestMemoryText) {
|
|
extractFacts(requestMemoryText, memoryOwnerId, pipelineSessionId);
|
|
}
|
|
|
|
const memoryText = extractMemoryTextFromResponse(memoryExtractionResponse);
|
|
if (memoryText) {
|
|
extractFacts(memoryText, memoryOwnerId, pipelineSessionId);
|
|
}
|
|
}
|
|
|
|
const customSkillExecutionEnabled =
|
|
Boolean(memoryOwnerId) && memorySettings?.skillsEnabled === true;
|
|
const builtinToolNames = webSearchFallbackPlan.toolName ? [webSearchFallbackPlan.toolName] : [];
|
|
if (customSkillExecutionEnabled || builtinToolNames.length > 0) {
|
|
const skillSessionId = pipelineSessionId;
|
|
|
|
translatedResponse = await handleToolCallExecution(
|
|
translatedResponse,
|
|
getSkillsModelIdForFormat(sourceFormat),
|
|
{
|
|
apiKeyId: memoryOwnerId || "local",
|
|
sessionId: skillSessionId,
|
|
requestId: skillRequestId,
|
|
builtinToolNames,
|
|
customSkillExecutionEnabled,
|
|
}
|
|
);
|
|
}
|
|
|
|
const guardrailContext = buildPostCallGuardrailContext({
|
|
apiKeyInfo,
|
|
body,
|
|
clientRawRequest,
|
|
log,
|
|
model,
|
|
provider,
|
|
responsePayloadFormat,
|
|
clientResponseFormat,
|
|
});
|
|
const postCallGuardrails = await guardrailRegistry.runPostCallHooks(
|
|
translatedResponse,
|
|
guardrailContext
|
|
);
|
|
translatedResponse = postCallGuardrails.response;
|
|
|
|
const responseUsage =
|
|
(usage && typeof usage === "object" ? usage : null) ||
|
|
(translatedResponse?.usage && typeof translatedResponse.usage === "object"
|
|
? translatedResponse.usage
|
|
: null);
|
|
const estimatedCost = responseUsage
|
|
? await calculateCost(provider, model, responseUsage, { serviceTier: effectiveServiceTier })
|
|
: 0;
|
|
|
|
if (postCallGuardrails.blocked) {
|
|
const guardrailMessage = postCallGuardrails.message || "Response blocked by guardrail";
|
|
persistAttemptLogs({
|
|
status: HTTP_STATUS.BAD_REQUEST,
|
|
tokens: usage,
|
|
responseBody,
|
|
providerRequest: finalBody || translatedBody,
|
|
providerResponse: looksLikeSSE
|
|
? {
|
|
_streamed: true,
|
|
_format: "sse-json",
|
|
summary: responseBody,
|
|
}
|
|
: responseBody,
|
|
clientResponse: buildErrorBody(HTTP_STATUS.BAD_REQUEST, guardrailMessage),
|
|
claudeCacheMeta: claudePromptCacheLogMeta,
|
|
claudeCacheUsageMeta: cacheUsageLogMeta,
|
|
cacheSource: "upstream",
|
|
});
|
|
if (apiKeyInfo?.id && estimatedCost > 0) {
|
|
recordCost(apiKeyInfo.id, estimatedCost);
|
|
}
|
|
log?.warn?.(
|
|
"GUARDRAIL",
|
|
`Response blocked by ${postCallGuardrails.guardrail || "guardrail"}: ${guardrailMessage}`
|
|
);
|
|
finalizePendingScope(pendingScope, {
|
|
providerResponse: responseBody,
|
|
clientResponse: translatedResponse,
|
|
});
|
|
return createErrorResult(HTTP_STATUS.BAD_REQUEST, guardrailMessage);
|
|
}
|
|
|
|
// Validate the *translated* response actually carries client-usable output.
|
|
// isEmptyContentResponse (above) runs on the raw responseBody before translation;
|
|
// this check runs after translation + sanitization + tool-call execution to catch
|
|
// cases where a provider returns a structurally valid raw body that translates into
|
|
// choices:[] or output:[] with no usable content (Responses API shape included).
|
|
const malformedTranslatedReason = detectMalformedNonStream(translatedResponse);
|
|
if (malformedTranslatedReason) {
|
|
const totalLatency = Date.now() - startTime;
|
|
const rawBytes = (() => {
|
|
try {
|
|
return JSON.stringify(responseBody || {}).length;
|
|
} catch {
|
|
return -1;
|
|
}
|
|
})();
|
|
reportMalformed200({
|
|
mode: "nonstream",
|
|
provider,
|
|
model,
|
|
connectionId,
|
|
reason: malformedTranslatedReason,
|
|
recvBytes: rawBytes,
|
|
recvLines: -1,
|
|
emitted: -1,
|
|
events: {},
|
|
ttftMs: totalLatency,
|
|
elapsedMs: totalLatency,
|
|
});
|
|
appendRequestLog({
|
|
model,
|
|
provider,
|
|
connectionId,
|
|
status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}`,
|
|
}).catch(() => {});
|
|
const malformedMessage = `[${provider}/${model}] returned an empty response (no usable choices/output)`;
|
|
persistAttemptLogs({
|
|
status: HTTP_STATUS.BAD_GATEWAY,
|
|
tokens: usage,
|
|
responseBody,
|
|
providerRequest: finalBody || translatedBody,
|
|
providerResponse: looksLikeSSE
|
|
? { _streamed: true, _format: "sse-json", summary: responseBody }
|
|
: responseBody,
|
|
clientResponse: buildErrorBody(HTTP_STATUS.BAD_GATEWAY, malformedMessage),
|
|
claudeCacheMeta: claudePromptCacheLogMeta,
|
|
claudeCacheUsageMeta: cacheUsageLogMeta,
|
|
cacheSource: "upstream",
|
|
});
|
|
persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "malformed_translated_response");
|
|
trackPendingRequest(model, provider, pendingConnId, false);
|
|
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, malformedMessage);
|
|
}
|
|
|
|
// ── Phase 9.1: Cache store (non-streaming, temp=0) ──
|
|
storeSemanticCacheResponse({
|
|
enabled: semanticCacheEnabled,
|
|
body,
|
|
headers: clientRawRequest?.headers,
|
|
translatedResponse,
|
|
model,
|
|
apiKeyId: apiKeyInfo?.id ?? undefined,
|
|
usage,
|
|
log,
|
|
});
|
|
|
|
// ── Phase 9.2: Save for idempotency ──
|
|
// Reuse the key resolved by checkIdempotencyCache() above (single derivation per
|
|
// request). (#3821-review LEDGER-6)
|
|
saveIdempotency(idempotencyKey, translatedResponse, 200);
|
|
reqLogger.logConvertedResponse(translatedResponse);
|
|
persistAttemptLogs({
|
|
status: 200,
|
|
tokens: usage,
|
|
responseBody,
|
|
providerRequest: finalBody || translatedBody,
|
|
providerResponse: looksLikeSSE
|
|
? {
|
|
_streamed: true,
|
|
_format: "sse-json",
|
|
summary: responseBody,
|
|
}
|
|
: responseBody,
|
|
clientResponse: translatedResponse,
|
|
claudeCacheMeta: claudePromptCacheLogMeta,
|
|
claudeCacheUsageMeta: cacheUsageLogMeta,
|
|
cacheSource: "upstream",
|
|
});
|
|
if (apiKeyInfo?.id && estimatedCost > 0) {
|
|
recordCost(apiKeyInfo.id, estimatedCost);
|
|
}
|
|
|
|
// === Quota Share POST-hook (B/F7) — fire-and-forget, fail-open ===
|
|
await scheduleQuotaShareConsumption({
|
|
apiKeyId: apiKeyInfo?.id,
|
|
connectionId: credentials?.connectionId,
|
|
provider,
|
|
model,
|
|
usage,
|
|
estimatedCost,
|
|
log,
|
|
});
|
|
// === /Quota Share POST-hook ===
|
|
|
|
// ── Gamification event (fire-and-forget) ──
|
|
await emitRequestGamificationEvent({ apiKeyId: apiKeyInfo?.id, model, provider });
|
|
|
|
finalizePendingScope(pendingScope, {
|
|
providerResponse: responseBody,
|
|
clientResponse: translatedResponse,
|
|
});
|
|
const responseHeaders = buildNonStreamingResponseHeaders({
|
|
provider,
|
|
model,
|
|
startTime,
|
|
responseUsage,
|
|
estimatedCost,
|
|
requestId: skillRequestId,
|
|
compressionResponseMeta,
|
|
});
|
|
// #6426: align response body `model` with the `X-OmniRoute-Model` header
|
|
// (both must be the resolved backend model). Some upstreams (notably legacy
|
|
// /v1/completions text-completion path) return a body `model` field that
|
|
// differs from the resolved backend id we advertised in the header, leaving
|
|
// strict clients unable to reconcile the two. Rewrite body.model to `model`
|
|
// FIRST, then let #1311 echo override it when the opt-in setting is on.
|
|
if (typeof model === "string" && model) echoModelInObject(translatedResponse, model);
|
|
// #1311: echo the requested alias/combo name in the non-streaming response model.
|
|
if (echoModel) echoModelInObject(translatedResponse, echoModel);
|
|
return {
|
|
success: true,
|
|
response: buildNonStreamingJsonResponse(translatedResponse, responseHeaders),
|
|
};
|
|
}
|
|
|
|
// Streaming response
|
|
// #3089 — some "reasoning" openai-compatible upstreams ignore a stream:true
|
|
// request and return a complete application/json chat-completion body instead
|
|
// of an SSE stream. The readiness check below only recognizes SSE `data:`
|
|
// frames, so that body produced a spurious STREAM_EARLY_EOF / HTTP 502 even
|
|
// though it carried valid content/reasoning_content. Detect a JSON (non-SSE)
|
|
// upstream body and synthesize an equivalent OpenAI SSE stream so the
|
|
// streaming pipeline (and the client) get a valid stream.
|
|
providerResponse = await maybeConvertJsonBodyToSse(providerResponse, { log, provider, model });
|
|
const streamReadinessPolicy = resolveStreamReadinessTimeout({
|
|
baseTimeoutMs: STREAM_READINESS_TIMEOUT_MS,
|
|
provider,
|
|
model,
|
|
body: (finalBody || translatedBody) as Record<string, unknown> | null | undefined,
|
|
maxTimeoutMs: agentGoalPolicy.detected
|
|
? Math.max(STREAM_READINESS_MAX_TIMEOUT_MS, agentGoalPolicy.readinessMaxTimeoutMs)
|
|
: STREAM_READINESS_MAX_TIMEOUT_MS,
|
|
});
|
|
if (streamReadinessPolicy.timeoutMs !== streamReadinessPolicy.baseTimeoutMs) {
|
|
log?.debug?.(
|
|
"STREAM",
|
|
`adaptive readiness timeout=${streamReadinessPolicy.timeoutMs}ms base=${streamReadinessPolicy.baseTimeoutMs}ms reason=${streamReadinessPolicy.reasons.join(",")}`
|
|
);
|
|
}
|
|
|
|
const streamReadiness = await ensureStreamReadiness(providerResponse, {
|
|
timeoutMs: streamReadinessPolicy.timeoutMs,
|
|
provider,
|
|
model,
|
|
log,
|
|
});
|
|
if (streamReadiness.ok === false) {
|
|
const { response: failureResponse, reason } = streamReadiness;
|
|
const failure = {
|
|
status: failureResponse.status,
|
|
message: reason,
|
|
code: streamReadiness.code,
|
|
type: streamReadiness.type,
|
|
};
|
|
trackPendingRequest(model, provider, connectionId, false);
|
|
appendRequestLog({
|
|
model,
|
|
provider,
|
|
connectionId,
|
|
status: `FAILED ${failureResponse.status}`,
|
|
}).catch(() => {});
|
|
persistAttemptLogs({
|
|
status: failureResponse.status,
|
|
error: reason,
|
|
providerRequest: finalBody || translatedBody,
|
|
clientResponse: buildErrorBody(failureResponse.status, reason),
|
|
claudeCacheMeta: claudePromptCacheLogMeta,
|
|
cacheSource: "upstream",
|
|
});
|
|
persistFailureUsage(failureResponse.status, streamReadiness.code);
|
|
// Do NOT call onStreamFailure — a stream stall is an upstream issue,
|
|
// not an account/quota failure. Marking the account unavailable here
|
|
// would lock out legitimate accounts when the upstream hangs.
|
|
return {
|
|
success: false,
|
|
status: failureResponse.status,
|
|
error: reason,
|
|
errorType: streamReadiness.type,
|
|
errorCode: streamReadiness.code,
|
|
response: failureResponse,
|
|
};
|
|
}
|
|
providerResponse = streamReadiness.response;
|
|
|
|
// Notify success - caller can clear error status if needed
|
|
if (onRequestSuccess) {
|
|
await onRequestSuccess();
|
|
}
|
|
|
|
const responseHeaders = assembleStreamingResponseHeaders({
|
|
providerHeaders: providerResponse.headers,
|
|
provider,
|
|
model,
|
|
pendingRequestId,
|
|
compressionResponseMeta,
|
|
});
|
|
|
|
// Create transform stream with logger for streaming response
|
|
let transformStream;
|
|
const responseToolNameMap = mergeResponseToolNameMap(
|
|
toolNameMap,
|
|
(finalBody as Record<string, unknown> | null | undefined) ?? null
|
|
);
|
|
|
|
let streamCompletionRecorded = false;
|
|
let streamFailureCompletionRecorded = false;
|
|
|
|
// Callback to save call log when stream completes (include responseBody when provided by stream)
|
|
const onStreamComplete = ({
|
|
status: streamStatus,
|
|
usage: streamUsage,
|
|
responseBody: streamResponseBody,
|
|
providerPayload,
|
|
clientPayload,
|
|
error: streamError,
|
|
errorCode: streamErrorCode,
|
|
ttft,
|
|
}) => {
|
|
const normalizedStreamStatus = streamStatus || 200;
|
|
if (streamCompletionRecorded) return;
|
|
streamCompletionRecorded = true;
|
|
if (normalizedStreamStatus !== 200) {
|
|
if (streamFailureCompletionRecorded) return;
|
|
streamFailureCompletionRecorded = true;
|
|
}
|
|
const cacheUsageLogMeta = buildCacheUsageLogMeta(streamUsage);
|
|
const streamConnectionId = getCurrentConnectionId();
|
|
|
|
if (normalizedStreamStatus === 200) {
|
|
void maybeSyncClaudeExtraUsageState({
|
|
provider,
|
|
connectionId: streamConnectionId,
|
|
providerSpecificData: credentials?.providerSpecificData,
|
|
log,
|
|
});
|
|
}
|
|
|
|
// Reasoning Replay Cache (#1628): Capture reasoning_content from streaming responses
|
|
// with tool_calls so it can be replayed on subsequent turns (DeepSeek V4, Kimi K2, etc.)
|
|
if (normalizedStreamStatus === 200 && streamResponseBody) {
|
|
try {
|
|
const body = streamResponseBody as Record<string, unknown>;
|
|
const choices = body.choices as { message?: Record<string, unknown> }[] | undefined;
|
|
const msg = choices?.[0]?.message;
|
|
cacheReasoningFromAssistantMessage(msg, provider, model, {
|
|
requestId: skillRequestId,
|
|
messageIndex: 0,
|
|
});
|
|
} catch {
|
|
// Cache capture is non-critical — never block the stream
|
|
}
|
|
}
|
|
effectiveServiceTier = resolveReportedServiceTier(streamResponseBody) ?? effectiveServiceTier;
|
|
|
|
// Context Editing telemetry (streaming): the reconstructed stream body now carries
|
|
// context_management.applied_edits from the final message_delta snapshot. Mirror the
|
|
// non-streaming hook so streaming context-clear savings also surface under engine
|
|
// "context-editing" in compression analytics. Best-effort, Claude-only.
|
|
if (normalizedStreamStatus === 200) {
|
|
recordContextEditingTelemetryHook({
|
|
contextEditingEnabled,
|
|
provider,
|
|
responseBody: streamResponseBody,
|
|
skillRequestId,
|
|
log,
|
|
});
|
|
}
|
|
|
|
streamFailure.finalizeStreamRequestLog({
|
|
pendingRequestId,
|
|
model,
|
|
provider,
|
|
connectionId: streamConnectionId,
|
|
providerResponse: providerPayload ?? streamResponseBody ?? undefined,
|
|
clientResponse: clientPayload ?? streamResponseBody ?? undefined,
|
|
status: normalizedStreamStatus,
|
|
error: streamError,
|
|
errorCode: streamErrorCode,
|
|
});
|
|
|
|
// Track cache token metrics for streaming responses
|
|
if (streamUsage && typeof streamUsage === "object") {
|
|
attachCompressionUsageReceiptAfterAnalytics(streamUsage as Record<string, unknown>, "stream");
|
|
}
|
|
recordStreamingUsageStats(streamUsage, {
|
|
provider,
|
|
model,
|
|
streamStatus: normalizedStreamStatus,
|
|
startTime,
|
|
ttft,
|
|
streamErrorCode,
|
|
connectionId: streamConnectionId,
|
|
apiKeyInfo,
|
|
effectiveServiceTier,
|
|
isCombo,
|
|
comboStrategy,
|
|
endpoint: endpointPath,
|
|
});
|
|
|
|
persistAttemptLogs({
|
|
status: normalizedStreamStatus,
|
|
error: streamError || undefined,
|
|
tokens: streamUsage || {},
|
|
responseBody: streamResponseBody ?? undefined,
|
|
providerRequest: finalBody || translatedBody,
|
|
providerResponse: providerPayload,
|
|
clientResponse: clientPayload ?? streamResponseBody ?? undefined,
|
|
claudeCacheMeta: claudePromptCacheLogMeta,
|
|
claudeCacheUsageMeta: cacheUsageLogMeta,
|
|
cacheSource: "upstream",
|
|
});
|
|
|
|
recordStreamingCost({
|
|
apiKeyId: apiKeyInfo?.id,
|
|
provider,
|
|
model,
|
|
streamUsage,
|
|
serviceTier: effectiveServiceTier,
|
|
calculateCost,
|
|
recordCost,
|
|
});
|
|
|
|
// === Quota Share POST-hook streaming (B/F7) — fire-and-forget, fail-open ===
|
|
// Resolve the real per-request cost (calculateCost) so USD-unit pools accrue
|
|
// on streaming traffic too; this previously recorded usd:0 hardcoded, which
|
|
// meant DeepSeek-style `usd/monthly` shared pools never blocked on streams.
|
|
scheduleStreamingQuotaShareConsumption({
|
|
apiKeyId: apiKeyInfo?.id,
|
|
connectionId: credentials?.connectionId,
|
|
provider,
|
|
model,
|
|
streamUsage,
|
|
streamStatus: normalizedStreamStatus,
|
|
serviceTier: effectiveServiceTier,
|
|
calculateCost,
|
|
log,
|
|
});
|
|
// === /Quota Share POST-hook streaming ===
|
|
|
|
if (
|
|
memoryOwnerId &&
|
|
memorySettings?.enabled &&
|
|
memorySettings.maxTokens > 0 &&
|
|
streamStatus === 200
|
|
) {
|
|
const requestMemoryText = extractMemoryTextFromRequestBody(body as Record<string, unknown>);
|
|
if (requestMemoryText) {
|
|
extractFacts(requestMemoryText, memoryOwnerId, pipelineSessionId);
|
|
}
|
|
|
|
const streamedMemoryText = extractMemoryTextFromResponse(
|
|
(streamResponseBody ?? null) as Record<string, unknown> | null
|
|
);
|
|
if (streamedMemoryText) {
|
|
extractFacts(streamedMemoryText, memoryOwnerId, pipelineSessionId);
|
|
}
|
|
}
|
|
|
|
// Semantic cache: store assembled streaming response for future cache hits
|
|
storeStreamingSemanticCacheResponse({
|
|
enabled: semanticCacheEnabled,
|
|
streamStatus,
|
|
streamResponseBody,
|
|
body,
|
|
headers: clientRawRequest?.headers,
|
|
model,
|
|
apiKeyId: apiKeyInfo?.id ?? undefined,
|
|
streamUsage,
|
|
log,
|
|
});
|
|
};
|
|
|
|
const streamFailureFinalizers = streamFailure.createStreamFailureFinalizers({
|
|
isFailureCompletionRecorded: () => streamFailureCompletionRecorded,
|
|
isStreamCompletionRecorded: () => streamCompletionRecorded,
|
|
onStreamComplete,
|
|
persistFailureUsage,
|
|
onStreamFailure,
|
|
});
|
|
const handleStreamFailure = streamFailureFinalizers.handleStreamFailure;
|
|
onPipelineStreamError = streamFailureFinalizers.onPipelineStreamError;
|
|
onClientDisconnectFinalize = (event) =>
|
|
handleStreamFailure({
|
|
status: 499,
|
|
message: `Client disconnected: ${event.reason}`,
|
|
code: "client_disconnected",
|
|
type: "client_disconnected",
|
|
});
|
|
|
|
// For providers using Responses API format, translate stream back to openai (Chat Completions) format
|
|
// UNLESS client is Droid CLI which expects openai-responses format back
|
|
const needsResponsesTranslation =
|
|
targetFormat === FORMATS.OPENAI_RESPONSES &&
|
|
clientResponseFormat === FORMATS.OPENAI &&
|
|
!isResponsesEndpoint &&
|
|
!isDroidCLI;
|
|
const streamStateBody = finalBody || body;
|
|
|
|
if (needsResponsesTranslation) {
|
|
// Provider returns openai-responses, translate to openai (Chat Completions) that clients expect
|
|
log?.debug?.("STREAM", `Responses translation mode: openai-responses → openai`);
|
|
transformStream = createSSETransformStreamWithLogger(
|
|
"openai-responses",
|
|
"openai",
|
|
provider,
|
|
reqLogger,
|
|
responseToolNameMap,
|
|
model,
|
|
connectionId,
|
|
streamStateBody,
|
|
onStreamComplete,
|
|
apiKeyInfo,
|
|
handleStreamFailure,
|
|
copilotCompatibleReasoning
|
|
);
|
|
} else if (needsTranslation(targetFormat, clientResponseFormat)) {
|
|
// Standard translation for other providers
|
|
log?.debug?.("STREAM", `Translation mode: ${targetFormat} → ${clientResponseFormat}`);
|
|
transformStream = createSSETransformStreamWithLogger(
|
|
targetFormat,
|
|
clientResponseFormat,
|
|
provider,
|
|
reqLogger,
|
|
responseToolNameMap,
|
|
model,
|
|
connectionId,
|
|
streamStateBody,
|
|
onStreamComplete,
|
|
apiKeyInfo,
|
|
handleStreamFailure,
|
|
copilotCompatibleReasoning,
|
|
// Suppress the `</think>` close marker for clients that render it verbatim
|
|
// (e.g. OpenCode by UA; any client via `x-omniroute-thinking-marker: off`);
|
|
// preserved for Claude Code / Cursor and unknown clients by default (#5245 /
|
|
// #5312). The header wins over the UA allowlist.
|
|
resolveSuppressThinkClose({
|
|
userAgent: streamUserAgent,
|
|
thinkingMarkerHeader,
|
|
})
|
|
);
|
|
} else {
|
|
log?.debug?.("STREAM", `Standard passthrough mode`);
|
|
transformStream = createPassthroughStreamWithLogger(
|
|
provider,
|
|
reqLogger,
|
|
responseToolNameMap,
|
|
model,
|
|
connectionId,
|
|
streamStateBody,
|
|
onStreamComplete,
|
|
apiKeyInfo,
|
|
handleStreamFailure,
|
|
clientResponseFormat
|
|
);
|
|
}
|
|
|
|
const finalStream = assembleStreamingPipeline({
|
|
providerResponse,
|
|
transformStream,
|
|
streamController,
|
|
createPiiTransform,
|
|
clientRawRequestHeaders: clientRawRequest?.headers,
|
|
clientResponseFormat,
|
|
echoModel,
|
|
responseHeaders,
|
|
});
|
|
|
|
// ── Gamification event (fire-and-forget) ──
|
|
await emitRequestGamificationEvent({ apiKeyId: apiKeyInfo?.id, model, provider });
|
|
|
|
// ── Plugin onResponse hook (fire-and-forget) ──
|
|
await runPluginOnResponseHook({ requestId: traceId, body, model, provider, apiKeyInfo });
|
|
|
|
return {
|
|
success: true,
|
|
response: new Response(finalStream, {
|
|
headers: responseHeaders,
|
|
}),
|
|
};
|
|
}
|
|
|
|
export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) {
|
|
if (!expiresAt) return false;
|
|
const expiresAtMs = new Date(expiresAt).getTime();
|
|
return expiresAtMs - Date.now() < bufferMs;
|
|
}
|