Files
OmniRoute/open-sse/services/modelFamilyFallback.ts
Diego Rodrigues de Sa e Souza b100325fe0 chore(release): v3.5.2 — Qoder DashScope Native Integration & Stability (#999)
* feat(qoder): native cosy integration

* feat(qoder): implement native COSY encryption algorithm and remove CLI child instances, plus workflow bumps

* feat(resilience): context overflow fallback, OAuth token detection, empty content guard & context-optimized combo strategy

- Add isContextOverflowError + isContextOverflow detectors (400 + token-limit signals)
- Auto-fallback to next family model on context overflow in chatCore
- Add isEmptyContentResponse to catch fake-success empty responses, trigger fallback + recursive retry
- Add OAUTH_INVALID_TOKEN error type (T11) with isOAuthInvalidToken signal matching; warn instead of deactivating node
- Add getModelContextLimit helper in modelsDevSync (reads limit_context from synced capabilities)
- Upgrade getTokenLimit in contextManager to check models.dev DB before registry (fixes gemini-2.5-pro: 1000000→1048576)
- Add findLargerContextModel in modelFamilyFallback for context-aware model selection
- Add sortModelsByContextSize + context-optimized combo strategy in combo.ts
- Update context-manager unit test for corrected gemini-2.5-pro limit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(review): address Gemini code review — tool_calls path, infinite recursion, dedup signals, findLargerContextModel

- Fix isEmptyContentResponse: check message.tool_calls/delta.tool_calls instead
  of firstChoice.tool_calls (wrong OpenAI API path, caused tool-call responses
  to be falsely flagged as empty)
- Fix empty content fallback: replace recursive handleChatCore call (infinite
  recursion risk + wrong model due to original body.model) with non-recursive
  pattern — call executeProviderRequest, parse fallback response body, reassign
  responseBody and fall through to existing processing
- Fix context overflow: use findLargerContextModel over family candidates first,
  fall back to getNextFamilyFallback — ensures we pick a model with actually
  larger context window on overflow
- Fix signal dedup: export CONTEXT_OVERFLOW_SIGNALS + CONTEXT_OVERFLOW_REGEX
  from errorClassifier.ts; import shared regex in modelFamilyFallback.ts,
  removing duplicate signal list and per-call RegExp construction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(UI): add context-optimized strategy to frontend schema and options

* fix(sse): preserve Responses API events in stream translation

When translating Claude-format responses (e.g. GLM) to Responses API
format for Codex CLI, the sanitizer stripped {event, data} structured
items to {"object":"chat.completion.chunk"}, losing all content and
the critical response.completed event.

Only run sanitizeStreamingChunk on OpenAI Chat Completions chunks,
skipping items that have the Responses API {event, data} structure.

* test(sse): add regression test for Claude→Responses stream sanitization

Verifies that {event,data} structured items from the Responses API
translator bypass sanitizeStreamingChunk when translating Claude-format
providers (e.g. GLM) to Responses API format for Codex CLI.

* fix(sse): strengthen Responses API event detection with response. prefix check

Use explicit `response.` prefix check instead of generic `event && data`
presence check, as recommended in PR review.

* fix: pin Next.js to 16.0.10 to prevent Turbopack hashed module bug

Remove ^ prefix from next and eslint-config-next to prevent
automatic upgrades to 16.1.x+ which introduced content-based
hashing for external module references in Turbopack.

Also remove duplicate Material Symbols @import from globals.css
(font already loaded via <link> in layout.tsx).

Fixes #509

* align cc-compatible cache handling with client passthrough

* chore: integrate resilience and turbopack fixes (PRs #992, #990, #987)

* chore(release): bump to v3.5.2 — changelog, docs, version sync

* docs(i18n): sync documentation updates to 33 languages

* fix(qoder): replace any with unknown to comply with strict any-budget

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: oyi77 <oyi77@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Chris Staley <christopher-s@users.noreply.github.com>
Co-authored-by: Ivan <shanin-i2011@yandex.ru>
Co-authored-by: R.D. <rogerproself@gmail.com>
2026-04-05 02:54:44 -03:00

200 lines
6.3 KiB
TypeScript

/**
* Model Family Fallback — Phase 2 Feature (T5)
*
* Implements two-phase model resolution:
* Phase 1 (static, pre-request): already done by model.ts alias resolution.
* Phase 2 (dynamic, post-error): when a provider returns a model-not-available
* error (400 with specific message or 404), we try sibling models within the
* same "family" before giving up.
*
* Inspired by Antigravity Manager's account-aware dynamic model remapping
* (commit 6cea566, Mar 8 2026).
*/
import { getModelContextLimit } from "../../src/lib/modelsDevSync";
import { parseModel } from "./model.ts";
import { CONTEXT_OVERFLOW_REGEX } from "./errorClassifier.ts";
// ── Model Family Definitions ─────────────────────────────────────────────────
/**
* Ordered candidate lists per model family.
* First entry is the most preferred; fallback proceeds in order.
*/
const MODEL_FAMILIES: Record<string, string[]> = {
// Gemini 3 / 3.1 Pro family — ordered by preference
"gemini-3-pro": [
"gemini-3.1-pro-preview",
"gemini-3-pro-preview",
"gemini-3.1-pro-high",
"gemini-3-pro-high",
"gemini-3.1-pro-low",
"gemini-3-pro-low",
],
"gemini-3.1-pro": [
"gemini-3.1-pro-preview",
"gemini-3-pro-preview",
"gemini-3.1-pro-high",
"gemini-3-pro-high",
"gemini-3.1-pro-low",
"gemini-3-pro-low",
],
"gemini-3-pro-preview": [
"gemini-3.1-pro-preview",
"gemini-3-pro-high",
"gemini-3.1-pro-high",
"gemini-3-pro-low",
"gemini-3.1-pro-low",
],
"gemini-3.1-pro-preview": [
"gemini-3-pro-preview",
"gemini-3.1-pro-high",
"gemini-3-pro-high",
"gemini-3.1-pro-low",
"gemini-3-pro-low",
],
"gemini-3-pro-high": [
"gemini-3.1-pro-high",
"gemini-3-pro-preview",
"gemini-3.1-pro-preview",
"gemini-3-pro-low",
"gemini-3.1-pro-low",
],
"gemini-3.1-pro-high": [
"gemini-3-pro-high",
"gemini-3.1-pro-preview",
"gemini-3-pro-preview",
"gemini-3.1-pro-low",
"gemini-3-pro-low",
],
// Gemini 2.5 Pro family
"gemini-2.5-pro": ["gemini-2.5-pro-preview-06-05", "gemini-2.5-pro-exp-03-25"],
"gemini-2.5-pro-preview-06-05": ["gemini-2.5-pro", "gemini-2.5-pro-exp-03-25"],
// Claude Opus family
"claude-opus-4-6": ["claude-opus-4-6-thinking", "claude-opus-4-5-20251101", "claude-sonnet-4-6"],
"claude-opus-4-6-thinking": ["claude-opus-4-6", "claude-opus-4-5-20251101"],
// Claude Sonnet family
"claude-sonnet-4-6": ["claude-sonnet-4-5-20250929", "claude-sonnet-4-20250514"],
"claude-sonnet-4-5-20250929": ["claude-sonnet-4-6", "claude-sonnet-4-20250514"],
// GPT-5 family
"gpt-5": ["gpt-5-mini", "gpt-4o"],
"gpt-5.1": ["gpt-5.1-mini", "gpt-5", "gpt-4o"],
};
// ── Error Detection ──────────────────────────────────────────────────────────
/**
* Error message fragments that indicate the requested model is unavailable
* for the current account/provider, as opposed to a transient error.
*/
const MODEL_UNAVAILABLE_FRAGMENTS = [
"model not found",
"model_not_found",
"model not available",
"model is not available",
"no such model",
"unsupported model",
"unknown model",
"this model does not exist",
"invalid model",
"model not supported",
"does not support",
"not enabled for",
"access to model",
"improperly formed request", // Kiro 400 (model unavailable)
];
/**
* Returns true if the HTTP status + error message indicates the model
* itself is not available, not a transient server error.
*/
export function isModelUnavailableError(status: number, errorMessage: string): boolean {
if (status === 404) return true;
if (status !== 400 && status !== 403) return false;
const msg = errorMessage.toLowerCase();
return MODEL_UNAVAILABLE_FRAGMENTS.some((fragment) => msg.includes(fragment));
}
export function isContextOverflowError(status: number, errorMessage: string): boolean {
if (status !== 400) return false;
return CONTEXT_OVERFLOW_REGEX.test(errorMessage);
}
// ── Fallback Resolution ──────────────────────────────────────────────────────
/**
* Get the next fallback model from the same family.
*
* @param currentModel The model that just failed
* @param triedModels Set of model IDs already tried (to avoid cycles)
* @returns Next model to try, or null if family exhausted
*/
export function getNextFamilyFallback(
currentModel: string,
triedModels: Set<string>
): string | null {
const family = MODEL_FAMILIES[currentModel];
if (!family) return null;
for (const candidate of family) {
if (!triedModels.has(candidate)) {
return candidate;
}
}
return null; // family exhausted
}
/**
* Check if a model belongs to any registered family.
*/
export function isInModelFamily(model: string): boolean {
return model in MODEL_FAMILIES;
}
/**
* Get all members of a model's family (including itself).
*/
export function getModelFamily(model: string): string[] {
const family = MODEL_FAMILIES[model];
if (!family) return [model];
return [model, ...family];
}
/**
* Find a model with larger context window from a list of candidate models.
* Uses models.dev synced capabilities to compare context limits.
*/
export function findLargerContextModel(
currentModel: string,
availableModels: string[]
): string | null {
const currentParsed = parseModel(currentModel);
const currentProvider = currentParsed.provider || currentParsed.providerAlias || "unknown";
const currentModelId = currentParsed.model || currentModel;
const currentLimit = getModelContextLimit(currentProvider, currentModelId) ?? 0;
let bestModel: string | null = null;
let bestLimit = currentLimit;
for (const candidate of availableModels) {
if (candidate === currentModel) continue;
const parsed = parseModel(candidate);
const provider = parsed.provider || parsed.providerAlias || "unknown";
const modelId = parsed.model || candidate;
const limit = getModelContextLimit(provider, modelId) ?? 0;
if (limit > bestLimit) {
bestLimit = limit;
bestModel = candidate;
}
}
return bestModel;
}