mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
feat: Phase 1 & 2 implementation plan — T1-T10, T12
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
This commit is contained in:
@@ -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<number, number> = {};
|
||||
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string>([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
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
export {
|
||||
calculateScore,
|
||||
calculateTierScore,
|
||||
scorePool,
|
||||
validateWeights,
|
||||
DEFAULT_WEIGHTS,
|
||||
|
||||
@@ -11,37 +11,45 @@
|
||||
import type { ScoringWeights } from "./scoring";
|
||||
|
||||
export const MODE_PACKS: Record<string, ScoringWeights> = {
|
||||
// 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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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<string, number> = {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
157
open-sse/services/modelFamilyFallback.ts
Normal file
157
open-sse/services/modelFamilyFallback.ts
Normal file
@@ -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<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",
|
||||
];
|
||||
|
||||
/**
|
||||
* 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>
|
||||
): 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];
|
||||
}
|
||||
@@ -24,6 +24,7 @@ type ClaudeTool = {
|
||||
description: string;
|
||||
input_schema: Record<string, unknown>;
|
||||
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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user