Skip to content
Merged
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
1 change: 1 addition & 0 deletions changelog.d/fixes/16223-family-fallback-eligibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Family fallback now skips hidden models and active connection-scoped model lockouts while preserving eligible sibling order (#16223, fixes #16227; thanks @turbolego).
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(routing):** require explicit opt-in for internal fallback across model versions, preserve same-version effort recovery and report the dispatched model after fallback; keep API-key model restrictions effective (Refs #16406).
3 changes: 3 additions & 0 deletions open-sse/config/reasoningEfforts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
/** Shared routing vocabulary; both engine recovery and the rules editor consume it. */
export const STANDARD_REASONING_EFFORTS = ["none", "low", "medium", "high", "xhigh"] as const;
export const EXTENDED_REASONING_EFFORTS = ["max", "ultra"] as const;
21 changes: 10 additions & 11 deletions open-sse/handlers/chatCore/nonStreamingProviderLeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,10 @@ import { unwrapClinepassEnvelope } from "../../utils/clinepassEnvelope.ts";
import { unwrapClineNonStreamingEnvelope } from "./clineResponseEnvelope.ts";
import {
isModelUnavailableError,
getNextFamilyFallback,
isContextOverflowError,
findLargerContextModel,
getModelFamily,
} from "../../services/modelFamilyFallback.ts";
import { isEmptyContentResponse } from "../../services/errorClassifier.ts";
import { createRequestFamilyFallback } from "../../services/modelFamilyFallbackPolicy.ts";
import { hasTrustedEmptyTurn } from "../../utils/emptyTurnPolicy.ts";
import { FORMATS } from "../../translator/formats.ts";
import { hasActiveClaudeThinking } from "../../utils/thinkingBudget.ts";
Expand Down Expand Up @@ -62,6 +60,7 @@ export interface ProviderLegInput {
expectedConnectionId?: string;
allowAccountRotation: boolean;
allowModelFallback: boolean;
hasApiKeyMetadata?: boolean;
executeProviderRequest: (
model: string,
allowDedup: boolean,
Expand Down Expand Up @@ -357,6 +356,11 @@ export async function runNonStreamingProviderLeg(
const targetFormat = input.targetFormat ?? "openai";
const clientResponseFormat = input.clientResponseFormat ?? "openai";
const connectionId = input.connectionId ?? "unknown";
const familyFallback = createRequestFamilyFallback(provider, {
headers: input.clientHeaders,
hasApiKeyMetadata: input.hasApiKeyMetadata,
connectionId: () => input.getCurrentConnectionId?.() ?? connectionId,
});
const log = input.log;

// -- Phase policy: follow-up blocks rotation and fallback -------------------
Expand Down Expand Up @@ -596,7 +600,7 @@ export async function runNonStreamingProviderLeg(
// -- Model-unavailable -> family fallback (initial only) --------------------
if (allowModelFallback && isModelUnavailableError(statusCode, message, provider)) {
const triedModels = new Set<string>([currentModel]);
const nextModel = getNextFamilyFallback(currentModel, triedModels, provider);
const nextModel = await familyFallback.getNext(currentModel, triedModels);
if (nextModel) {
triedModels.add(nextModel);
input.setRequestWireState({
Expand Down Expand Up @@ -670,12 +674,7 @@ export async function runNonStreamingProviderLeg(
// -- Context overflow -> family fallback (initial only) ---------------------
if (allowModelFallback && isContextOverflowError(statusCode, message)) {
const triedModels = new Set<string>([currentModel]);
const familyCandidates = getModelFamily(currentModel, provider).filter(
(m) => m !== currentModel && !triedModels.has(m)
);
const nextModel =
findLargerContextModel(currentModel, familyCandidates, provider) ??
getNextFamilyFallback(currentModel, triedModels, provider);
const nextModel = await familyFallback.getForContextOverflow(currentModel, triedModels);
if (nextModel) {
triedModels.add(nextModel);
input.setRequestWireState({
Expand Down Expand Up @@ -992,7 +991,7 @@ export async function runNonStreamingProviderLeg(
const errMsg = "Provider returned empty content";
if (allowModelFallback) {
const triedModels = new Set<string>([currentModel]);
const nextModel = getNextFamilyFallback(currentModel, triedModels, provider);
const nextModel = await familyFallback.getNext(currentModel, triedModels);
if (nextModel) {
triedModels.add(nextModel);
input.setRequestWireState({
Expand Down
16 changes: 14 additions & 2 deletions open-sse/handlers/chatCore/nonStreamingResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ import { buildPostCallGuardrailContext } from "../chatCore/postCallGuardrailCont
import { buildNonStreamingResponseHeaders } from "../chatCore/nonStreamingResponseHeaders.ts";
import { maybeWrapForcedNonStreamingResponsesJson } from "../chatCore/responsesJsonToSse.ts";
import { runProviderExecutionPipeline } from "../chatCore/providerExecutionPipeline.ts";
import {
createRequestFamilyFallback,
modelAfterFamilyFallback,
} from "../../services/modelFamilyFallbackPolicy.ts";
import { runNonStreamingProviderLeg } from "../chatCore/nonStreamingProviderLeg.ts";
import type { NonStreamingProviderLegResult } from "@/lib/skills/toolLoopTypes.ts";
import { finalizeToolLoopError } from "../chatCore/nonStreamingFinalization.ts";
Expand Down Expand Up @@ -197,6 +201,11 @@ export async function runNonStreamingResponse(deps: NonStreamingDeps) {
traceEnabled,
videoBridgeObserved,
} = deps;
const familyFallback = createRequestFamilyFallback(provider, {
headers: clientRawRequest?.headers,
hasApiKeyMetadata: Boolean(apiKeyInfo),
connectionId: () => String(getCurrentConnectionId() || connectionId || ""),
});

let claudePromptCacheLogMeta,
currentModel,
Expand Down Expand Up @@ -228,6 +237,7 @@ export async function runNonStreamingResponse(deps: NonStreamingDeps) {
currentModel = pipelineModel;
triedModels.add(pipelineModel);
return runProviderExecutionPipeline({
getNextFamilyFallback: familyFallback.getNext,
policy,
target: {
provider,
Expand Down Expand Up @@ -333,6 +343,7 @@ export async function runNonStreamingResponse(deps: NonStreamingDeps) {
: undefined,
allowAccountRotation: !managedLease && comboStrategy !== "context-relay",
allowModelFallback: true,
hasApiKeyMetadata: Boolean(apiKeyInfo),
executeProviderRequest: (modelToCall, allowDedup) =>
executeProviderRequest(modelToCall, allowDedup),
runProviderExecution: runNonStreamingPipeline,
Expand Down Expand Up @@ -995,9 +1006,10 @@ export async function runNonStreamingResponse(deps: NonStreamingDeps) {
providerResponse: responseBody,
clientResponse: translatedResponse,
});
const responseModel = modelAfterFamilyFallback(model, currentModel, effectiveModel, finalBody);
const responseHeaders = buildNonStreamingResponseHeaders({
provider,
model,
model: responseModel,
startTime,
responseUsage,
estimatedCost,
Expand All @@ -1012,7 +1024,7 @@ export async function runNonStreamingResponse(deps: NonStreamingDeps) {
// 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);
if (responseModel) echoModelInObject(translatedResponse, responseModel);
// #1311: echo the requested alias/combo name in the non-streaming response model.
if (echoModel) echoModelInObject(translatedResponse, echoModel);

Expand Down
36 changes: 31 additions & 5 deletions open-sse/handlers/chatCore/providerExecutionPipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { recoverAnthropicThinkingSignature } from "./thinkingSignatureRecovery.t
import { isAnthropicThinkingSignatureError } from "./passthroughHelpers.ts";
import {
isModelUnavailableError,
getNextFamilyFallback as defaultGetNextFamilyFallback,
getNextEligibleFamilyFallback,
isFamilyFallbackEligible,
} from "../../services/modelFamilyFallback.ts";
import { COOLDOWN_MS } from "../../config/errorConfig.ts";
import { normalizeHeaders } from "../../utils/headers.ts";
Expand Down Expand Up @@ -136,7 +137,7 @@ export interface ProviderExecutionPipelineInput {
currentModel: string,
triedModels: Set<string>,
providerHint?: string | null
) => string | null;
) => string | null | Promise<string | null>;
}

const LEASE_MISMATCH_STATUS = 409;
Expand Down Expand Up @@ -341,7 +342,29 @@ export async function runProviderExecutionPipeline(
let authRefreshPending = false;
let authRefreshed = false;
let modelFallbackPending = false;
const resolveFamilyFallback = input.getNextFamilyFallback ?? defaultGetNextFamilyFallback;
const resolveFamilyFallback = async (
currentModel: string,
triedModels: Set<string>,
providerHint?: string | null
) => {
const connectionId = currentConnectionId(connection);
if (!input.getNextFamilyFallback) {
return getNextEligibleFamilyFallback(
currentModel,
triedModels,
providerHint ?? target.provider,
connectionId
);
}

const excluded = new Set(triedModels);
while (true) {
const candidate = await input.getNextFamilyFallback(currentModel, excluded, providerHint);
if (!candidate || excluded.has(candidate)) return null;
if (isFamilyFallbackEligible(candidate, target.provider, connectionId)) return candidate;
excluded.add(candidate);
}
};

while (
attempts < maxAttempts ||
Expand Down Expand Up @@ -587,13 +610,16 @@ export async function runProviderExecutionPipeline(
// keep statusText
}
if (isModelUnavailableError(status, fallbackMessage, target.provider)) {
const nextModel = resolveFamilyFallback(
const nextModel = await resolveFamilyFallback(
wire.currentModel,
wire.triedModels,
target.provider
);
if (nextModel) {
wire.setBodyAndModel({ ...wire.body, model: nextModel }, nextModel);
wire.body = { ...wire.body, model: nextModel };
wire.currentModel = nextModel;
wire.triedModels.add(nextModel);
wire.setBodyAndModel(wire.body, nextModel);
modelFallbackPending = true;
continue;
}
Expand Down
19 changes: 9 additions & 10 deletions open-sse/handlers/chatCore/streamingResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,11 @@ import { lockModel, recordCoreOwnedAntigravityQuotaState } from "../../services/

import {
isModelUnavailableError,
getNextFamilyFallback,
isContextOverflowError,
findLargerContextModel,
getModelFamily,
} from "../../services/modelFamilyFallback.ts";

import { isLocalStreamLifecycleError } from "@/shared/utils/circuitBreaker";
import { createRequestFamilyFallback } from "../../services/modelFamilyFallbackPolicy.ts";
import { shouldIsolateProbeFailures } from "@/shared/utils/probeOrigin";
import { writeTerminalStatus } from "@/shared/utils/terminalStatus";

Expand Down Expand Up @@ -189,6 +187,11 @@ export async function runStreamingResponse(deps: StreamingDeps) {
upstreamStream,
reportSignatureFailure,
} = deps;
const familyFallback = createRequestFamilyFallback(provider, {
headers: clientRawRequest?.headers,
hasApiKeyMetadata: Boolean(apiKeyInfo),
connectionId: () => String(getCurrentConnectionId() || connectionId || ""),
});

let claudePromptCacheLogMeta,
currentModel,
Expand All @@ -214,6 +217,7 @@ export async function runStreamingResponse(deps: StreamingDeps) {
let upstreamDiagnostic: Record<string, unknown> | undefined;
try {
const pipelineOutcome = await runProviderExecutionPipeline({
getNextFamilyFallback: familyFallback.getNext,
policy: {
allowAccountRotation: !managedLease && comboStrategy !== "context-relay",
allowModelFallback: true,
Expand Down Expand Up @@ -1004,7 +1008,7 @@ export async function runStreamingResponse(deps: StreamingDeps) {
// from the same family. This keeps the request alive on the same account
// instead of failing the entire combo.
if (!pipelineRecovered && isModelUnavailableError(statusCode, message, provider)) {
const nextModel = getNextFamilyFallback(currentModel, triedModels, provider);
const nextModel = await familyFallback.getNext(currentModel, triedModels);
if (nextModel) {
triedModels.add(nextModel);
currentModel = nextModel;
Expand Down Expand Up @@ -1127,12 +1131,7 @@ export async function runStreamingResponse(deps: StreamingDeps) {
};
}
} else if (isContextOverflowError(statusCode, message)) {
const familyCandidates = getModelFamily(currentModel, provider).filter(
(m) => m !== currentModel && !triedModels.has(m)
);
const nextModel =
findLargerContextModel(currentModel, familyCandidates, provider) ??
getNextFamilyFallback(currentModel, triedModels, provider);
const nextModel = await familyFallback.getForContextOverflow(currentModel, triedModels);
if (nextModel) {
triedModels.add(nextModel);
currentModel = nextModel;
Expand Down
6 changes: 4 additions & 2 deletions open-sse/handlers/chatCore/streamingTail.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { inheritEmptyTurnPolicy } from "../../utils/emptyTurnPolicy.ts";
import { getCodexClientSessionId } from "../../config/codexIdentity.ts";
import { modelAfterFamilyFallback } from "../../services/modelFamilyFallbackPolicy.ts";
import {
noteCodexTurnStateProvenance,
readCodexTurnStateHeader,
Expand Down Expand Up @@ -309,10 +310,11 @@ export async function runStreamingTail(deps: StreamingTailDeps) {
await onRequestSuccess();
}

const responseModel = modelAfterFamilyFallback(model, currentModel, effectiveModel, finalBody);
const responseHeaders = assembleStreamingResponseHeaders({
providerHeaders: providerResponse.headers,
provider,
model,
model: responseModel,
pendingRequestId,
compressionResponseMeta,
comboStrategy,
Expand Down Expand Up @@ -745,7 +747,7 @@ export async function runStreamingTail(deps: StreamingTailDeps) {
createPiiTransform,
clientRawRequestHeaders: clientRawRequest?.headers,
clientResponseFormat,
echoModel,
echoModel: echoModel || (currentModel !== effectiveModel ? responseModel : null),
responseHeaders,
credentialRedactionEnabled: credentialGate.credentialRedactionEnabled,
credentialDisabledGuardrails: credentialGate.credentialDisabledGuardrails,
Expand Down
1 change: 1 addition & 0 deletions open-sse/services/combo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1142,6 +1142,7 @@ async function handleComboChatInner({
traceInvocationId,
clientRequestedStream,
handleSingleModelWithTimeout,
requestHeaders,
isModelAvailable,
perTargetAdmission,
signal,
Expand Down
1 change: 1 addition & 0 deletions open-sse/services/combo/attemptLoopTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export type AttemptLoopDeps = {
traceInvocationId: string;
clientRequestedStream: boolean;
handleSingleModelWithTimeout: HandleSingleModel;
requestHeaders?: Headers | Record<string, unknown> | null;
isModelAvailable?: IsModelAvailable;
perTargetAdmission?: PerTargetAdmissionHook | null;
/** Optional only to permit deterministic management-health gate tests. */
Expand Down
2 changes: 1 addition & 1 deletion open-sse/services/combo/comboPredicates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { isResourceNotFoundResponse } from "../errorClassifier.ts";
import { isOpencodeFreeTierRefusal } from "../../executors/opencodeGeoBlock.ts";
import { getTrustedLocalRateLimitResponse } from "../rateLimitManager/errors.ts";
import { TRANSLATION_FAILURE_CODE } from "../../handlers/chatCore/translationFailure.ts";
import { isLocalModelPolicyResponse } from "../../../src/shared/utils/resolvedModelAccess.ts";
import { isLocalModelPolicyResponse } from "../resolvedModelAccess.ts";
import type { ResolvedComboTarget } from "./types.ts";
import {
classifyComboOutcome,
Expand Down
7 changes: 7 additions & 0 deletions open-sse/services/combo/comboVisibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,10 @@ export function isComboModelVisible(
}
return !isHidden(providerId || parsed.provider || parsed.providerAlias, rawModel);
}

/** Share one hidden-model snapshot across all candidates of a recovery decision. */
export function createComboModelVisibility(
hiddenModelsByProvider: HiddenModelsByProvider = getHiddenModelsByProvider()
): (model: string, provider: string) => boolean {
return (model, provider) => isComboModelVisible(model, provider, hiddenModelsByProvider);
}
17 changes: 9 additions & 8 deletions open-sse/services/combo/executeTargetAttempt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import { recordStickyBinding } from "./sessionStickiness.ts";
import { recordStickyWeightedSuccess } from "./rrState.ts";
import { resolveReasoningBufferedMaxTokens, toPositiveInteger } from "../reasoningTokenBuffer.ts";
import { parseModel } from "../model.ts";
import { getNextFamilyFallback } from "../modelFamilyFallback.ts";
import { getNextComboFamilyFallback } from "../modelFamilyFallbackPolicy.ts";
import type { ProviderProfile } from "../accountFallback.ts";
import {
MAX_FALLBACK_WAIT_MS,
Expand Down Expand Up @@ -437,13 +437,14 @@ export async function executeTargetAttempt(
state.observeFailure(false, target.executionKey);
if (handlePreContentStreamRetry(quality, retry, deps, modelStr)) continue;
familyTried.add(modelStr);
// A request-scoped refusal (invalid request, context overflow) is a property of
// the request, not of the effort tier — replaying it on a sibling alias of the
// same model just repeats the refusal, so let the combo advance instead.
const familyNext =
provider && provider !== "unknown" && !quality.upstreamFailure?.requestScoped
? getNextFamilyFallback(modelStr, familyTried, provider)
: null;
const familyNext = await getNextComboFamilyFallback({
model: modelStr,
tried: familyTried,
provider,
connectionId: effectiveConnectionId,
headers: deps.requestHeaders,
requestScoped: quality.upstreamFailure?.requestScoped,
});
if (familyNext && familyNext !== modelStr) {
deps.log.info("COMBO", `Quality fail ${modelStr} -> family sibling ${familyNext}`);
modelStr = familyNext;
Expand Down
2 changes: 1 addition & 1 deletion open-sse/services/combo/targetExhaustion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
import { RateLimitReason } from "../../config/constants.ts";
import { isProviderCircuitOpenResult, isRequestScopedUpstreamFailure } from "./comboPredicates.ts";
import { isCloudflareFingerprintRejection } from "../errorClassifier.ts";
import { isLocalModelPolicyResponse } from "../../../src/shared/utils/resolvedModelAccess.ts";
import { isLocalModelPolicyResponse } from "../resolvedModelAccess.ts";
// #10334 — connection-scope predicate shared with the persistence layer
// (markAccountUnavailable) so the same-request combo skip and the persisted
// connection cooldown agree on exactly which fallbackResult shapes qualify.
Expand Down
Loading
Loading