Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 136 additions & 2 deletions open-sse/handlers/chatCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ import {
formatProviderError,
} from "../utils/error.ts";
import { HTTP_STATUS, PROVIDER_MAX_TOKENS } from "../config/constants.ts";
import { classifyProviderError, PROVIDER_ERROR_TYPES } from "../services/errorClassifier.ts";
import {
classifyProviderError,
PROVIDER_ERROR_TYPES,
isEmptyContentResponse,
} from "../services/errorClassifier.ts";
import { updateProviderConnection } from "@/lib/db/providers";
import { isDetailedLoggingEnabled } from "@/lib/db/detailedLogs";
import { logAuditEvent } from "@/lib/compliance";
Expand Down Expand Up @@ -82,7 +86,12 @@ import {
} from "@/lib/semanticCache";
import { getIdempotencyKey, checkIdempotency, saveIdempotency } from "@/lib/idempotencyLayer";
import { createProgressTransform, wantsProgress } from "../utils/progressTracker.ts";
import { isModelUnavailableError, getNextFamilyFallback } from "../services/modelFamilyFallback.ts";
import {
isModelUnavailableError,
getNextFamilyFallback,
isContextOverflowError,
findLargerContextModel,
} from "../services/modelFamilyFallback.ts";
import { computeRequestHash, deduplicate, shouldDeduplicate } from "../services/requestDedup.ts";
import {
getBackgroundTaskReason,
Expand Down Expand Up @@ -1527,6 +1536,16 @@ export async function handleChatCore({
lastError: message,
errorCode: statusCode,
});
} else if (errorType === PROVIDER_ERROR_TYPES.OAUTH_INVALID_TOKEN) {
// OAuth 401 with invalid credentials - token refresh can recover
await updateProviderConnection(connectionId, {
lastErrorType: errorType,
lastError: message,
errorCode: statusCode,
});
console.warn(
`[provider] Node ${connectionId} 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(connectionId, {
Expand Down Expand Up @@ -1626,6 +1645,58 @@ export async function handleChatCore({
persistFailureUsage(statusCode, "model_unavailable");
return createErrorResult(statusCode, errMsg, retryAfterMs);
}
} else if (isContextOverflowError(statusCode, message)) {
const nextModel = 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 = fallbackResult.transformedBody;
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
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),
});
persistFailureUsage(statusCode, "context_overflow");
return createErrorResult(statusCode, errMsg, retryAfterMs);
}
} catch {
persistAttemptLogs({
status: statusCode,
error: errMsg,
providerRequest: finalBody || translatedBody,
providerResponse: upstreamErrorBody,
clientResponse: buildErrorBody(statusCode, errMsg),
});
persistFailureUsage(statusCode, "context_overflow");
return createErrorResult(statusCode, errMsg, retryAfterMs);
}
} else {
persistAttemptLogs({
status: statusCode,
error: errMsg,
providerRequest: finalBody || translatedBody,
providerResponse: upstreamErrorBody,
clientResponse: buildErrorBody(statusCode, errMsg),
});
persistFailureUsage(statusCode, "context_overflow");
return createErrorResult(statusCode, errMsg, retryAfterMs);
}
} else {
persistAttemptLogs({
status: statusCode,
Expand Down Expand Up @@ -1764,6 +1835,69 @@ export async function handleChatCore({
}
}

// 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),
});
persistFailureUsage(HTTP_STATUS.BAD_GATEWAY, "empty_content");

// Trigger 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) {
providerResponse = fallbackResult.response;
providerUrl = fallbackResult.url;
providerHeaders = fallbackResult.headers;
finalBody = fallbackResult.transformedBody;
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
log?.info("EMPTY_CONTENT_FALLBACK", `Serving ${nextModel} as fallback for ${model}`);
// Continue with the fallback response by re-processing
return handleChatCore({
body,
modelInfo: { provider, model: nextModel, extendedContext },
credentials,
log,
onCredentialsRefreshed,
onRequestSuccess,
onDisconnect,
clientRawRequest,
connectionId,
apiKeyInfo,
userAgent,
comboName,
comboStrategy,
isCombo,
});
}
} catch {
// Fallback failed, continue to return error
}
}

return createErrorResult(HTTP_STATUS.BAD_GATEWAY, emptyContentMessage);
}

if (sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE) {
responseBody = restoreClaudePassthroughToolNames(responseBody, toolNameMap);
}
Expand Down
18 changes: 18 additions & 0 deletions open-sse/services/accountFallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ export const CREDITS_EXHAUSTED_SIGNALS = [
"payment required",
];

// T11: Signals that indicate OAuth token is invalid/expired (not permanent deactivation)
export const OAUTH_INVALID_TOKEN_SIGNALS = [
"invalid authentication credentials",
"oauth 2",
"login cookie",
"valid authentication credential",
"invalid credentials",
];

/**
* T06: Returns true if response body indicates the account is permanently deactivated.
*/
Expand All @@ -52,6 +61,15 @@ export function isCreditsExhausted(errorText: string): boolean {
return CREDITS_EXHAUSTED_SIGNALS.some((sig) => lower.includes(sig));
}

/**
* T11: Returns true if response body indicates OAuth token is invalid/expired.
* This is different from permanent account deactivation - token refresh can recover.
*/
export function isOAuthInvalidToken(errorText: string): boolean {
const lower = String(errorText || "").toLowerCase();
return OAUTH_INVALID_TOKEN_SIGNALS.some((sig) => lower.includes(sig));
}

// ─── Provider Profile Helper ────────────────────────────────────────────────

/**
Expand Down
22 changes: 22 additions & 0 deletions open-sse/services/combo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { selectProvider as selectAutoProvider } from "./autoCombo/engine.ts";
import { selectWithStrategy } from "./autoCombo/routerStrategy.ts";
import { DEFAULT_WEIGHTS, scorePool } from "./autoCombo/scoring.ts";
import { supportsToolCalling } from "./modelCapabilities.ts";
import { getModelContextLimit } from "../../src/lib/modelsDevSync";

// Status codes that should mark semaphore + record circuit breaker failures
const TRANSIENT_FOR_BREAKER = [429, 502, 503, 504];
Expand Down Expand Up @@ -309,6 +310,24 @@ function sortModelsByUsage(models, comboName) {
return withUsage.map((e) => e.modelStr);
}

/**
* Sort models by context window size (largest first) for context-optimized strategy.
* Uses models.dev synced capabilities to get context limits.
* @param {Array<string>} models - Model strings in "provider/model" format
* @returns {Array<string>} Sorted model strings (largest context first)
*/
function sortModelsByContextSize(models) {
const withContext = models.map((modelStr) => {
const parsed = parseModel(modelStr);
const provider = parsed.provider || parsed.providerAlias || "unknown";
const model = parsed.model || modelStr;
const limit = getModelContextLimit(provider, model);
return { modelStr, context: limit ?? 0 };
});
withContext.sort((a, b) => b.context - a.context);
return withContext.map((e) => e.modelStr);
}

function toTextContent(content) {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
Expand Down Expand Up @@ -908,6 +927,9 @@ export async function handleComboChat({
} else if (strategy === "cost-optimized") {
orderedModels = await sortModelsByCost(orderedModels);
log.info("COMBO", `Cost-optimized ordering: cheapest first (${orderedModels[0]})`);
} else if (strategy === "context-optimized") {
orderedModels = sortModelsByContextSize(orderedModels);
log.info("COMBO", `Context-optimized ordering: largest first (${orderedModels[0]})`);
}

let lastError = null;
Expand Down
15 changes: 11 additions & 4 deletions open-sse/services/contextManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { REGISTRY } from "../config/providerRegistry.ts";
import { getModelContextLimit } from "../../src/lib/modelsDevSync";

// Default token limits per provider (fallbacks when not in registry)
const DEFAULT_LIMITS: Record<string, number> = {
Expand Down Expand Up @@ -47,20 +48,26 @@ export function estimateTokens(text) {

/**
* Get token limit for a provider/model combination
* Priority: Env override > Registry defaultContextLength > DEFAULT_LIMITS
* Priority: Env override > models.dev DB > Registry defaultContextLength > DEFAULT_LIMITS
*/
export function getTokenLimit(provider, model = null) {
// 1. Check environment variable override first
const envOverride = getEnvOverride(provider);
if (envOverride) return envOverride;

// 2. Check registry for provider default
// 2. Check models.dev synced DB for per-model context limit
if (model) {
const dbLimit = getModelContextLimit(provider, model);
if (dbLimit && dbLimit > 0) return dbLimit;
}

// 3. Check registry for provider default
const registryEntry = REGISTRY[provider];
if (registryEntry?.defaultContextLength) {
return registryEntry.defaultContextLength;
}

// 3. Check if model name hints at a known limit
// 4. Check if model name hints at a known limit
if (model) {
const lower = model.toLowerCase();
if (lower.includes("claude")) return DEFAULT_LIMITS.claude;
Expand All @@ -75,7 +82,7 @@ export function getTokenLimit(provider, model = null) {
return DEFAULT_LIMITS.codex;
}

// 4. Fallback to DEFAULT_LIMITS or default
// 5. Fallback to DEFAULT_LIMITS or default
return DEFAULT_LIMITS[provider] || DEFAULT_LIMITS.default;
}

Expand Down
Loading
Loading