From a87d64372f1d2cc43d7c22f42ea18c9bc8e6a313 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 12 Mar 2026 18:06:53 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=201=20&=202=20implementation=20pl?= =?UTF-8?q?an=20=E2=80=94=20T1-T10,=20T12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T1 (openai-to-claude.ts): response_format injection for json_schema/json_object T2 (base.ts): intra-URL retry for 429 errors (2x, 2s delay) T3 (gemini-cli.ts): CLI fingerprint headers (User-Agent, X-Goog-Api-Client) T5 (modelFamilyFallback.ts + chatCore.ts): intra-family model fallback on 400/404 T9 (pricing.ts): deepseek-3.1, deepseek-3.2, qwen3-coder-next pricing T10 (scoring.ts + modePacks.ts): tierPriority as 7th scoring factor (Ultra>Pro>Free) T12 (cliRuntime.ts): isSafePath() guard for CLI_*_BIN env var paths --- open-sse/executors/base.ts | 21 +++ open-sse/executors/gemini-cli.ts | 8 + open-sse/handlers/chatCore.ts | 53 +++++- open-sse/services/autoCombo/index.ts | 1 + open-sse/services/autoCombo/modePacks.ts | 16 +- open-sse/services/autoCombo/scoring.ts | 43 ++++- open-sse/services/modelFamilyFallback.ts | 157 ++++++++++++++++++ .../translator/request/openai-to-claude.ts | 18 ++ src/shared/constants/pricing.ts | 23 +++ src/shared/services/cliRuntime.ts | 19 +++ 10 files changed, 353 insertions(+), 6 deletions(-) create mode 100644 open-sse/services/modelFamilyFallback.ts diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 52807fb5f4..f607a17df1 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -158,6 +158,9 @@ export class BaseExecutor { return status === HTTP_STATUS.RATE_LIMITED && urlIndex + 1 < this.getFallbackCount(); } + // Intra-URL retry config: retry same URL before falling back to next node + static readonly RETRY_CONFIG = { maxAttempts: 2, delayMs: 2000 }; + // Override in subclass for provider-specific refresh async refreshCredentials(credentials: ProviderCredentials, log: ExecutorLog | null) { void credentials; @@ -179,6 +182,8 @@ export class BaseExecutor { const fallbackCount = this.getFallbackCount(); let lastError: unknown = null; let lastStatus = 0; + // Track per-URL intra-retry attempts to avoid infinite loops + const retryAttemptsByUrl: Record = {}; for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { const url = this.buildUrl(model, stream, urlIndex, credentials); @@ -236,6 +241,22 @@ export class BaseExecutor { const response = await fetch(url, fetchOptions); + // Intra-URL retry: if 429 and we haven't exhausted per-URL retries, wait and retry the same URL + if ( + response.status === HTTP_STATUS.RATE_LIMITED && + (retryAttemptsByUrl[urlIndex] ?? 0) < BaseExecutor.RETRY_CONFIG.maxAttempts + ) { + retryAttemptsByUrl[urlIndex] = (retryAttemptsByUrl[urlIndex] ?? 0) + 1; + const attempt = retryAttemptsByUrl[urlIndex]; + log?.debug?.( + "RETRY", + `429 intra-retry ${attempt}/${BaseExecutor.RETRY_CONFIG.maxAttempts} on ${url} — waiting ${BaseExecutor.RETRY_CONFIG.delayMs}ms` + ); + await new Promise((resolve) => setTimeout(resolve, BaseExecutor.RETRY_CONFIG.delayMs)); + urlIndex--; // re-run this urlIndex on the next loop iteration + continue; + } + if (this.shouldRetry(response.status, urlIndex)) { log?.debug?.("RETRY", `${response.status} on ${url}, trying fallback ${urlIndex + 1}`); lastStatus = response.status; diff --git a/open-sse/executors/gemini-cli.ts b/open-sse/executors/gemini-cli.ts index 42d14c4b95..1d3e064c76 100644 --- a/open-sse/executors/gemini-cli.ts +++ b/open-sse/executors/gemini-cli.ts @@ -2,6 +2,8 @@ import { BaseExecutor } from "./base.ts"; import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts"; export class GeminiCLIExecutor extends BaseExecutor { + private _currentModel: string = ""; + constructor() { super("gemini-cli", PROVIDERS["gemini-cli"]); } @@ -15,11 +17,17 @@ export class GeminiCLIExecutor extends BaseExecutor { return { "Content-Type": "application/json", Authorization: `Bearer ${credentials.accessToken}`, + // Fingerprint headers matching native GeminiCLI client (prevents upstream rejection) + "User-Agent": `GeminiCLI/0.31.0/${this._currentModel || "unknown"} (linux; x64)`, + "X-Goog-Api-Client": "google-genai-sdk/1.41.0 gl-node/v22.19.0", ...(stream && { Accept: "text/event-stream" }), }; } transformRequest(model, body, stream, credentials) { + // Capture model so buildHeaders (called after transformRequest) can include it in User-Agent + this._currentModel = model || ""; + const allowBodyProjectOverride = process.env.OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE === "1"; // Default: prefer OAuth-stored projectId. Incoming body.project can be stale diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 8ab89c2ad3..74df50683a 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -40,6 +40,7 @@ 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"; /** * Core chat handler - shared between SSE and Worker @@ -248,6 +249,10 @@ export async function handleChatCore({ // Track pending request trackPendingRequest(model, provider, connectionId, true); + // T5: track which models we've tried for intra-family fallback + const triedModels = new Set([model]); + let currentModel = model; + // Log start appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => {}); @@ -421,7 +426,53 @@ export async function handleChatCore({ // Update rate limiter from error response headers updateFromHeaders(provider, connectionId, providerResponse.headers, statusCode, model); - return createErrorResult(statusCode, errMsg, retryAfterMs); + // ── 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 withRateLimit(provider, connectionId, nextModel, () => + executor.execute({ + model: nextModel, + body: translatedBody, + stream, + credentials, + signal: streamController.signal, + log, + extendedContext, + }) + ); + if (fallbackResult.response.ok) { + providerResponse = fallbackResult.response; + providerUrl = fallbackResult.url; + providerHeaders = fallbackResult.headers; + finalBody = fallbackResult.transformedBody; + // 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 + return createErrorResult(statusCode, errMsg, retryAfterMs); + } + } catch { + return createErrorResult(statusCode, errMsg, retryAfterMs); + } + } else { + return createErrorResult(statusCode, errMsg, retryAfterMs); + } + } else { + return createErrorResult(statusCode, errMsg, retryAfterMs); + } + // ── End T5 ─────────────────────────────────────────────────────────────── } // Non-streaming response diff --git a/open-sse/services/autoCombo/index.ts b/open-sse/services/autoCombo/index.ts index 9cdacca9f1..045ffaaf8d 100644 --- a/open-sse/services/autoCombo/index.ts +++ b/open-sse/services/autoCombo/index.ts @@ -3,6 +3,7 @@ */ export { calculateScore, + calculateTierScore, scorePool, validateWeights, DEFAULT_WEIGHTS, diff --git a/open-sse/services/autoCombo/modePacks.ts b/open-sse/services/autoCombo/modePacks.ts index 74ca82d811..fe0d23ac95 100644 --- a/open-sse/services/autoCombo/modePacks.ts +++ b/open-sse/services/autoCombo/modePacks.ts @@ -11,37 +11,45 @@ import type { ScoringWeights } from "./scoring"; export const MODE_PACKS: Record = { + // Prioritize latency → health. tierPriority replaces 0.05 from stability. "ship-fast": { quota: 0.15, health: 0.3, costInv: 0.05, latencyInv: 0.35, taskFit: 0.1, - stability: 0.05, + stability: 0.0, + tierPriority: 0.05, }, + // Prioritize cost. tierPriority replaces 0.05 from stability. "cost-saver": { quota: 0.15, health: 0.2, costInv: 0.4, latencyInv: 0.05, taskFit: 0.1, - stability: 0.1, + stability: 0.05, + tierPriority: 0.05, }, + // Prioritize task fitness. tierPriority replaces 0.05 from latencyInv. "quality-first": { quota: 0.1, health: 0.2, costInv: 0.05, - latencyInv: 0.1, + latencyInv: 0.05, taskFit: 0.4, stability: 0.15, + tierPriority: 0.05, }, + // Prioritize quota availability. tierPriority replaces 0.05 from taskFit. "offline-friendly": { quota: 0.4, health: 0.3, costInv: 0.1, latencyInv: 0.05, - taskFit: 0.05, + taskFit: 0.0, stability: 0.1, + tierPriority: 0.05, }, }; diff --git a/open-sse/services/autoCombo/scoring.ts b/open-sse/services/autoCombo/scoring.ts index 988f8c0691..f8e8f23450 100644 --- a/open-sse/services/autoCombo/scoring.ts +++ b/open-sse/services/autoCombo/scoring.ts @@ -17,6 +17,7 @@ export interface ScoringFactors { latencyInv: number; taskFit: number; stability: number; + tierPriority: number; // T10: Ultra > Pro > Free account tier boost } export interface ScoringWeights { @@ -26,15 +27,18 @@ export interface ScoringWeights { latencyInv: number; taskFit: number; stability: number; + tierPriority: number; // T10 } +// T10: Rebalanced — stability 0.10→0.05, tierPriority 0.05 added. Sum = 1.0. export const DEFAULT_WEIGHTS: ScoringWeights = { quota: 0.2, health: 0.25, costInv: 0.2, latencyInv: 0.15, taskFit: 0.1, - stability: 0.1, + stability: 0.05, + tierPriority: 0.05, }; export interface ProviderCandidate { @@ -47,6 +51,10 @@ export interface ProviderCandidate { p95LatencyMs: number; latencyStdDev: number; errorRate: number; + /** T10: Optional account tier for priority boosting (Ultra > Pro > Free) */ + accountTier?: "ultra" | "pro" | "standard" | "free"; + /** T10: Optional quota reset interval in seconds (shorter = higher priority when same quota) */ + quotaResetIntervalSecs?: number; } export interface ScoredProvider { @@ -70,6 +78,38 @@ export function calculateScore(factors: ScoringFactors, weights: ScoringWeights) ); } +/** + * T10: Convert account tier string to a normalized score [0..1]. + * Ultra = 1.0 (most quota, fastest reset) + * Pro = 0.67 + * Standard = 0.33 + * Free = 0.0 + * Accounts with faster reset cycles (shorter quotaResetIntervalSecs) also get + * a small adjustment: monthly accounts are penalized vs. daily accounts. + */ +export function calculateTierScore( + tier: string | undefined, + quotaResetIntervalSecs: number | undefined +): number { + const BASE_TIER_SCORES: Record = { + ultra: 1.0, + pro: 0.67, + standard: 0.33, + free: 0.0, + }; + const baseScore = BASE_TIER_SCORES[tier?.toLowerCase() ?? ""] ?? 0.33; // unknown defaults to standard + + // Bonus for faster reset intervals (daily quota > weekly > monthly) + // maxInterval ~ 30 days (2_592_000s). Normalize: [0..1] where 0=monthly, 1=per-minute + const resetBonus = + quotaResetIntervalSecs != null && quotaResetIntervalSecs > 0 + ? Math.max(0, 1 - quotaResetIntervalSecs / 2_592_000) + : 0; + + // Blend: 80% tier level, 20% reset frequency + return Math.min(1, baseScore * 0.8 + resetBonus * 0.2); +} + /** * Calculate individual factors for a provider within its pool. */ @@ -96,6 +136,7 @@ export function calculateFactors( latencyInv: 1 - candidate.p95LatencyMs / maxLatency, taskFit: getTaskFitness(candidate.model, taskType), stability: 1 - candidate.latencyStdDev / maxStdDev, + tierPriority: calculateTierScore(candidate.accountTier, candidate.quotaResetIntervalSecs), }; } diff --git a/open-sse/services/modelFamilyFallback.ts b/open-sse/services/modelFamilyFallback.ts new file mode 100644 index 0000000000..41006d0a4d --- /dev/null +++ b/open-sse/services/modelFamilyFallback.ts @@ -0,0 +1,157 @@ +/** + * 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). + */ + +// ── Model Family Definitions ───────────────────────────────────────────────── + +/** + * Ordered candidate lists per model family. + * First entry is the most preferred; fallback proceeds in order. + */ +const MODEL_FAMILIES: Record = { + // 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", +]; + +/** + * 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)); +} + +// ── 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 | 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]; +} diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index a5757440cf..89fd9df0e8 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -24,6 +24,7 @@ type ClaudeTool = { description: string; input_schema: Record; cache_control?: { type: string; ttl?: string }; + defer_loading?: boolean; }; // Convert OpenAI request to Claude format @@ -193,6 +194,23 @@ export function openaiToClaudeRequest(model, body, stream) { result.tool_choice = convertOpenAIToolChoice(body.tool_choice); } + // response_format: inject JSON structured output instruction into system prompt. + // Claude doesn't natively support response_format, so we insert a system-level instruction. + // NOTE: systemParts are consumed later (after this block) — they're accumulated here. + if (body.response_format) { + const fmt = body.response_format; + if (fmt.type === "json_schema" && fmt.json_schema?.schema) { + const schemaJson = JSON.stringify(fmt.json_schema.schema, null, 2); + systemParts.push( + `You must respond with valid JSON that strictly follows this JSON schema:\n\`\`\`json\n${schemaJson}\n\`\`\`\nRespond ONLY with the JSON object, no other text.` + ); + } else if (fmt.type === "json_object") { + systemParts.push( + "You must respond with valid JSON. Respond ONLY with a JSON object, no other text." + ); + } + } + // Thinking configuration if (body.thinking) { result.thinking = { diff --git a/src/shared/constants/pricing.ts b/src/shared/constants/pricing.ts index dd90cc3ec2..167d3d1c12 100644 --- a/src/shared/constants/pricing.ts +++ b/src/shared/constants/pricing.ts @@ -138,6 +138,14 @@ export const DEFAULT_PRICING = { reasoning: 6.0, cache_creation: 1.0, }, + // Next-generation Qwen Coder tier (added Mar 2026 from decolua/9router catalog) + "qwen3-coder-next": { + input: 2.0, + output: 8.0, + cached: 1.0, + reasoning: 12.0, + cache_creation: 2.0, + }, "qwen3-coder-flash": { input: 0.5, output: 2.0, @@ -198,6 +206,21 @@ export const DEFAULT_PRICING = { reasoning: 4.5, cache_creation: 0.75, }, + // Short-form aliases used by decolua/9router catalog (Mar 2026) + "deepseek-3.1": { + input: 0.27, + output: 1.1, + cached: 0.07, + reasoning: 2.2, + cache_creation: 0.27, + }, + "deepseek-3.2": { + input: 0.27, + output: 1.1, + cached: 0.07, + reasoning: 2.2, + cache_creation: 0.27, + }, "minimax-m2": { input: 0.5, output: 2.0, diff --git a/src/shared/services/cliRuntime.ts b/src/shared/services/cliRuntime.ts index 7e568fe182..65ddabbb46 100644 --- a/src/shared/services/cliRuntime.ts +++ b/src/shared/services/cliRuntime.ts @@ -198,7 +198,26 @@ const resolveToolCommands = (toolId: string): string[] => { return tool.defaultCommand ? [tool.defaultCommand] : []; }; +/** + * T12: Validate a CLI executable path to prevent shell injection. + * Enforces: absolute path, no dangerous shell metacharacters, must exist and be a file. + * Inspired by Antigravity Manager commit 96732c2 (Mar 11, 2026). + */ +const DANGEROUS_PATH_CHARS = ["&", "|", ";", "<", ">", "(", ")", "`", "$", "^", "%", "!"]; + +const isSafePath = (execPath: string): boolean => { + if (!execPath || !path.isAbsolute(execPath)) return false; + if (DANGEROUS_PATH_CHARS.some((c) => execPath.includes(c))) return false; + // Allow path.sep and path.delimiter — no further character filtering needed + return true; +}; + const checkExplicitPath = async (commandPath: string) => { + // Reject paths that look like injection attempts + if (!isSafePath(commandPath)) { + return { installed: false, commandPath: null, reason: "unsafe_path" }; + } + try { await fs.access(commandPath, fs.constants.F_OK); } catch {