mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-14 19:22:32 +03:00
chore: merge release/v3.8.50 into integrate/free-tier-providers-phase3-v3850
Resolves conflicts in AGENTS.md (ceded to the release's slimmed structure from #8839, which supersedes the PR's count bumps to the old verbose format) and scripts/check/check-docs-counts-sync.mjs (kept the PR's new live-AI_PROVIDERS source-of-truth + localized-doc coverage checks, dropped AGENTS.md from the tracked file lists to match its new slim shape). Regenerates docs/reference/PROVIDER_REFERENCE.md and syncs the DB modules/migrations/services counts (110->111, 130->131, 178->179) across README/CLAUDE/ARCHITECTURE/CONTRIBUTING and their 42 i18n copies, which drifted by one because the release added a module, a migration and a service since this branch's merge-base.
This commit is contained in:
@@ -409,6 +409,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [
|
||||
requiresRestart: false,
|
||||
warningLevel: "info",
|
||||
},
|
||||
{
|
||||
key: "EXPOSE_FUNCTIONAL_GATEWAY_MIRRORS",
|
||||
label: "Functional Gateway Mirrors",
|
||||
description:
|
||||
"Advertise <gateway-alias>/<model> mirror ids on /v1/models for models whose canonical owner has no active credential but a passthrough gateway with an active credential routes them. Warning: adds catalog entries for all clients when enabled globally.",
|
||||
descriptionI18nKey: "featureFlagExposeFunctionalGatewayMirrorsDescription",
|
||||
category: "runtime",
|
||||
defaultValue: "false",
|
||||
type: "boolean",
|
||||
requiresRestart: false,
|
||||
warningLevel: "info",
|
||||
},
|
||||
|
||||
// ──────────────── CLI (5) ────────────────
|
||||
{
|
||||
|
||||
@@ -608,26 +608,83 @@ export const MODEL_SPECS: Record<string, ModelSpec> = {
|
||||
__default__: {},
|
||||
};
|
||||
|
||||
// #8697-adjacent: getCanonicalModelSpecId() re-scanned Object.keys/entries(MODEL_SPECS)
|
||||
// up to 3 times per call (exact ci, alias ci, prefix) — the top hotspot in a full
|
||||
// catalog-rebuild profile once the pricing-path bottlenecks were fixed. MODEL_SPECS is
|
||||
// a static module constant (never mutated at runtime), so the lowercase index below is
|
||||
// built once, lazily, on first use and never invalidated. Iteration order for the
|
||||
// prefix-match candidates is preserved exactly (same Object.keys() insertion order) so
|
||||
// resolution outcomes for ambiguous prefixes are unchanged.
|
||||
let modelSpecIndex: {
|
||||
exactCi: Map<string, string>;
|
||||
aliasCi: Map<string, string>;
|
||||
aliasExact: Map<string, string>;
|
||||
prefixCandidates: Array<[lowerKey: string, canonical: string]>;
|
||||
} | null = null;
|
||||
|
||||
function getModelSpecIndex() {
|
||||
if (modelSpecIndex) return modelSpecIndex;
|
||||
const exactCi = new Map<string, string>();
|
||||
const aliasCi = new Map<string, string>();
|
||||
const aliasExact = new Map<string, string>();
|
||||
const prefixCandidates: Array<[string, string]> = [];
|
||||
for (const [canonical, spec] of Object.entries(MODEL_SPECS)) {
|
||||
const lowerCanonical = canonical.toLowerCase();
|
||||
if (!exactCi.has(lowerCanonical)) exactCi.set(lowerCanonical, canonical);
|
||||
for (const alias of spec.aliases || []) {
|
||||
const lowerAlias = alias.toLowerCase();
|
||||
if (!aliasCi.has(lowerAlias)) aliasCi.set(lowerAlias, canonical);
|
||||
if (!aliasExact.has(alias)) aliasExact.set(alias, canonical);
|
||||
}
|
||||
if (canonical !== "__default__") prefixCandidates.push([lowerCanonical, canonical]);
|
||||
}
|
||||
modelSpecIndex = { exactCi, aliasCi, aliasExact, prefixCandidates };
|
||||
return modelSpecIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact + alias case-insensitive lookup only (no prefix phase) — shared by
|
||||
* modelCapabilities.ts's getStaticSpecCanonicalModelId(), which tries multiple id
|
||||
* candidates and never wanted prefix matching. Reuses the same lazy index as
|
||||
* getCanonicalModelSpecId() below instead of each caller maintaining its own cache
|
||||
* over the same static MODEL_SPECS table.
|
||||
*
|
||||
* Contract: returns `null` for `__default__` (never a real canonical id), for an
|
||||
* unrecognized `modelId`, or for an empty string. Matching is case-insensitive on
|
||||
* both the canonical id and its aliases; there is no prefix-matching phase (unlike
|
||||
* getCanonicalModelSpecId() below) — callers that need prefix matching should use
|
||||
* that function instead.
|
||||
*/
|
||||
export function findModelSpecIdByExactOrAlias(modelId: string): string | null {
|
||||
const lower = modelId.toLowerCase();
|
||||
const index = getModelSpecIndex();
|
||||
const exactHit = index.exactCi.get(lower);
|
||||
if (exactHit && exactHit !== "__default__") return exactHit;
|
||||
const aliasHit = index.aliasCi.get(lower);
|
||||
if (aliasHit && aliasHit !== "__default__") return aliasHit;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getCanonicalModelSpecId(modelId: string): string | null {
|
||||
if (MODEL_SPECS[modelId]) return modelId;
|
||||
|
||||
// Case-insensitive lookups: upstream model ids are often capitalized
|
||||
// (e.g. "MiniMax-M2.7") while specs/aliases use lowercase ids (#3141).
|
||||
const lower = modelId.toLowerCase();
|
||||
const index = getModelSpecIndex();
|
||||
|
||||
// Exact match (case-insensitive)
|
||||
for (const canonical of Object.keys(MODEL_SPECS)) {
|
||||
if (canonical.toLowerCase() === lower) return canonical;
|
||||
}
|
||||
const exactHit = index.exactCi.get(lower);
|
||||
if (exactHit) return exactHit;
|
||||
|
||||
// Buscas por alias (case-insensitive)
|
||||
for (const [canonical, spec] of Object.entries(MODEL_SPECS)) {
|
||||
if (spec.aliases?.some((alias) => alias.toLowerCase() === lower)) return canonical;
|
||||
}
|
||||
const aliasHit = index.aliasCi.get(lower);
|
||||
if (aliasHit) return aliasHit;
|
||||
|
||||
// Prefix matching (case-insensitive)
|
||||
for (const key of Object.keys(MODEL_SPECS)) {
|
||||
if (key !== "__default__" && lower.startsWith(key.toLowerCase())) return key;
|
||||
// Prefix matching (case-insensitive) — same insertion-order iteration as before,
|
||||
// first match wins.
|
||||
for (const [lowerKey, canonical] of index.prefixCandidates) {
|
||||
if (lower.startsWith(lowerKey)) return canonical;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -721,9 +778,12 @@ export function capThinkingBudget(modelId: string, budget: number): number {
|
||||
return Math.min(budget, cap);
|
||||
}
|
||||
|
||||
// #8697-adjacent: rescanned Object.entries(MODEL_SPECS) on every call, unconditionally
|
||||
// once per model in a catalog rebuild — verified 1:1 call ratio (no early
|
||||
// short-circuit). Case-sensitive exact match (Array.includes(), no .toLowerCase()) —
|
||||
// deliberately NOT reusing the case-insensitive aliasCi index above, which would
|
||||
// silently broaden matches and change behavior.
|
||||
export function resolveModelAlias(modelId: string): string {
|
||||
for (const [canonical, spec] of Object.entries(MODEL_SPECS)) {
|
||||
if (spec.aliases?.includes(modelId)) return canonical;
|
||||
}
|
||||
return modelId;
|
||||
const hit = getModelSpecIndex().aliasExact.get(modelId);
|
||||
return hit ?? modelId;
|
||||
}
|
||||
|
||||
@@ -481,6 +481,8 @@ export const USAGE_SUPPORTED_PROVIDERS = [
|
||||
// xAI OAuth (Grok) weekly quota (id + public alias, same pattern as ha/agy)
|
||||
"xai-oauth",
|
||||
"xao",
|
||||
// Grok Build subscription, billing credits, and auto top-up status
|
||||
"grok-cli",
|
||||
// Firecrawl team credits (GET /v2/team/credit-usage)
|
||||
"firecrawl",
|
||||
];
|
||||
|
||||
@@ -16,9 +16,9 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
|
||||
icon: "savings",
|
||||
color: "#31f889",
|
||||
textIcon: "CI",
|
||||
website: "https://cheaperinference.com",
|
||||
website: "https://cheaperinference.com/?utm_source=omniroute",
|
||||
apiHint:
|
||||
"Create an API key at https://cheaperinference.com (needs the `inference` scope), then paste the ir_live_… token here.",
|
||||
"Create an API key at https://cheaperinference.com/?utm_source=omniroute (needs the `inference` scope), then paste the ir_live_… token here.",
|
||||
passthroughModels: true,
|
||||
},
|
||||
"charm-hyper": {
|
||||
|
||||
@@ -31,8 +31,15 @@ export const MAX_BODY_BYTES_FILE = 500 * 1024 * 1024;
|
||||
/** Larger limit for LLM request payloads: 50 MB */
|
||||
export const MAX_BODY_BYTES_LLM_API = 50 * 1024 * 1024;
|
||||
|
||||
/** Allows one 20 MiB image as multipart or base64 JSON plus envelope overhead. */
|
||||
export const MAX_BODY_BYTES_IMAGE_EDIT = 30 * 1024 * 1024;
|
||||
/**
|
||||
* Media (image generate / edit / upscale / video) is not capped by OmniRoute.
|
||||
* JSON + base64 inflates payloads by roughly 33%, and provider limits vary by model,
|
||||
* so the provider should decide whether a media request is too large.
|
||||
*/
|
||||
export const MAX_BODY_BYTES_MEDIA = Number.POSITIVE_INFINITY;
|
||||
|
||||
/** @deprecated Use MAX_BODY_BYTES_MEDIA — kept as alias for any external imports. */
|
||||
export const MAX_BODY_BYTES_IMAGE_EDIT = MAX_BODY_BYTES_MEDIA;
|
||||
|
||||
/** Configured limit — reads from env or falls back to 10 MB */
|
||||
export const MAX_BODY_BYTES = parseRequestBodyLimitBytes(process.env.MAX_BODY_SIZE_BYTES);
|
||||
@@ -43,11 +50,14 @@ const ROUTE_LIMITS: BodySizeRule[] = [
|
||||
{ prefix: "/api/db-backups/import", limit: MAX_BODY_BYTES_IMPORT },
|
||||
{ prefix: "/api/v1/chat/completions", limit: MAX_BODY_BYTES_LLM_API },
|
||||
{ prefix: "/api/v1/responses", limit: MAX_BODY_BYTES_LLM_API },
|
||||
{ prefix: "/api/v1/images/edits", limit: MAX_BODY_BYTES_IMAGE_EDIT },
|
||||
{ prefix: "/api/v1/images", limit: MAX_BODY_BYTES_MEDIA },
|
||||
{ prefix: "/api/v1/videos", limit: MAX_BODY_BYTES_MEDIA },
|
||||
{ prefix: "/api/v1/audio/transcriptions", limit: MAX_BODY_BYTES_AUDIO },
|
||||
{ prefix: "/api/v1/files", limit: MAX_BODY_BYTES_FILE },
|
||||
];
|
||||
|
||||
const PROVIDER_IMAGE_GENERATION_ROUTE = /^\/api\/v1\/providers\/[^/]+\/images\/generations(?:\/|$)/;
|
||||
|
||||
export function getConfiguredBodySizeLimitBytes(settings?: Record<string, unknown>): number {
|
||||
const configuredMb = normalizeRequestBodyLimitMb(settings?.maxBodySizeMb);
|
||||
return configuredMb === null ? MAX_BODY_BYTES : requestBodyLimitMbToBytes(configuredMb);
|
||||
@@ -58,6 +68,7 @@ export function getConfiguredBodySizeLimitBytes(settings?: Record<string, unknow
|
||||
*/
|
||||
export function getBodySizeLimit(pathname: string, settings?: Record<string, unknown>): number {
|
||||
const configuredLimit = getConfiguredBodySizeLimitBytes(settings);
|
||||
if (PROVIDER_IMAGE_GENERATION_ROUTE.test(pathname)) return MAX_BODY_BYTES_MEDIA;
|
||||
const customRule = ROUTE_LIMITS.find((rule) => pathname.startsWith(rule.prefix));
|
||||
return customRule ? Math.max(customRule.limit, configuredLimit) : configuredLimit;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,23 @@ const QUOTA_PATTERNS: ReadonlyArray<RegExp> = [
|
||||
// the 429 is misclassified as transient rate_limit and retried every
|
||||
// ~60s against a budget that only resets at UTC midnight.
|
||||
/daily free allocation/i,
|
||||
|
||||
// Modal-hosted OpenAI-compatible endpoints (e.g. self-hosted Kimi K3).
|
||||
// Body: {"error":"usage limit reached"}, no nested "message"/"quota"/
|
||||
// "daily" wording. Without this pattern the 429 falls through to
|
||||
// "rate_limit" (short cooldown), so combo round-robin's per-conversation
|
||||
// session stickiness (#3825) keeps re-targeting the same exhausted
|
||||
// connection every turn instead of a long lockout that lets the sticky
|
||||
// target fail over to another account.
|
||||
//
|
||||
// Matches the "error" JSON key with "usage limit reached" as its value.
|
||||
// Extra sibling fields (e.g. {"error":"usage limit reached", "code":"..."})
|
||||
// still match. A different key like {"detail":"..."} or a qualified value
|
||||
// like {"error":"Per-minute usage limit reached"} does NOT match. Bare
|
||||
// string bodies without a JSON wrapper also do NOT match.
|
||||
// Trailing punctuation/whitespace before the closing quote is tolerated
|
||||
// because real API responses may include a period or trailing space.
|
||||
/"error"\s*:\s*"usage limit reached[.\s]*"/i,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
161
src/shared/utils/grokBilling.ts
Normal file
161
src/shared/utils/grokBilling.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
export const GROK_BUILD_ADDITIONAL_CREDITS_URL = "https://grok.com/build?_s=usage";
|
||||
|
||||
export interface GrokAutoTopUpStatus {
|
||||
available: boolean;
|
||||
enabled?: boolean;
|
||||
thresholdMinorUnits?: number;
|
||||
amountMinorUnits?: number;
|
||||
maxMonthlyMinorUnits?: number;
|
||||
}
|
||||
|
||||
export interface GrokBillingStatus {
|
||||
currency: "USD";
|
||||
extraCreditsMinorUnits?: number;
|
||||
autoTopUp: GrokAutoTopUpStatus;
|
||||
additionalCreditsUrl: typeof GROK_BUILD_ADDITIONAL_CREDITS_URL;
|
||||
}
|
||||
|
||||
export type GrokBillingTranslationKey =
|
||||
| "grokExtraUsageCredits"
|
||||
| "grokAutoTopUp"
|
||||
| "grokAutoTopUpUnavailable"
|
||||
| "grokAutoTopUpEnabled"
|
||||
| "grokAutoTopUpDisabled"
|
||||
| "grokAutoTopUpAt"
|
||||
| "grokAutoTopUpAdd"
|
||||
| "grokAutoTopUpMax"
|
||||
| "grokAutoTopUpMonth"
|
||||
| "grokAdditionalCredits";
|
||||
|
||||
export type GrokBillingTranslator = (key: GrokBillingTranslationKey, fallback: string) => string;
|
||||
|
||||
export type GrokBillingCardRow =
|
||||
| { kind: "balance" | "status"; label: string; value: string }
|
||||
| {
|
||||
kind: "link";
|
||||
label: string;
|
||||
href: typeof GROK_BUILD_ADDITIONAL_CREDITS_URL;
|
||||
target: "_blank";
|
||||
rel: "noreferrer noopener";
|
||||
};
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function toRecord(value: unknown): JsonRecord | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null;
|
||||
}
|
||||
|
||||
function minorUnits(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export function sanitizeGrokBillingStatus(value: unknown): GrokBillingStatus | undefined {
|
||||
const billing = toRecord(value);
|
||||
if (!billing || billing.currency !== "USD") return undefined;
|
||||
if (billing.additionalCreditsUrl !== GROK_BUILD_ADDITIONAL_CREDITS_URL) return undefined;
|
||||
|
||||
const rawAutoTopUp = toRecord(billing.autoTopUp);
|
||||
if (!rawAutoTopUp || typeof rawAutoTopUp.available !== "boolean") return undefined;
|
||||
|
||||
const available = rawAutoTopUp.available;
|
||||
const enabled =
|
||||
available && typeof rawAutoTopUp.enabled === "boolean" ? rawAutoTopUp.enabled : undefined;
|
||||
const extraCreditsMinorUnits = minorUnits(billing.extraCreditsMinorUnits);
|
||||
const thresholdMinorUnits =
|
||||
enabled === true ? minorUnits(rawAutoTopUp.thresholdMinorUnits) : undefined;
|
||||
const amountMinorUnits = enabled === true ? minorUnits(rawAutoTopUp.amountMinorUnits) : undefined;
|
||||
const maxMonthlyMinorUnits =
|
||||
enabled === true ? minorUnits(rawAutoTopUp.maxMonthlyMinorUnits) : undefined;
|
||||
|
||||
return {
|
||||
currency: "USD",
|
||||
...(extraCreditsMinorUnits !== undefined ? { extraCreditsMinorUnits } : {}),
|
||||
autoTopUp: {
|
||||
available,
|
||||
...(enabled !== undefined ? { enabled } : {}),
|
||||
...(thresholdMinorUnits !== undefined ? { thresholdMinorUnits } : {}),
|
||||
...(amountMinorUnits !== undefined ? { amountMinorUnits } : {}),
|
||||
...(maxMonthlyMinorUnits !== undefined ? { maxMonthlyMinorUnits } : {}),
|
||||
},
|
||||
additionalCreditsUrl: GROK_BUILD_ADDITIONAL_CREDITS_URL,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatGrokMinorUnits(
|
||||
value: number | undefined,
|
||||
currency: GrokBillingStatus["currency"],
|
||||
locales?: Intl.LocalesArgument
|
||||
): string | null {
|
||||
if (value === undefined) return null;
|
||||
return new Intl.NumberFormat(locales, {
|
||||
style: "currency",
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value / 100);
|
||||
}
|
||||
|
||||
const fallbackTranslation: GrokBillingTranslator = (_key, fallback) => fallback;
|
||||
|
||||
export function buildGrokBillingCardRows(
|
||||
billing: GrokBillingStatus,
|
||||
locales?: Intl.LocalesArgument,
|
||||
translate: GrokBillingTranslator = fallbackTranslation
|
||||
): GrokBillingCardRow[] {
|
||||
const rows: GrokBillingCardRow[] = [];
|
||||
const extraCredits = formatGrokMinorUnits(
|
||||
billing.extraCreditsMinorUnits,
|
||||
billing.currency,
|
||||
locales
|
||||
);
|
||||
if (extraCredits !== null) {
|
||||
rows.push({
|
||||
kind: "balance",
|
||||
label: translate("grokExtraUsageCredits", "Extra Usage Credits"),
|
||||
value: extraCredits,
|
||||
});
|
||||
}
|
||||
|
||||
const autoTopUp = billing.autoTopUp;
|
||||
let autoTopUpValue: string;
|
||||
if (!autoTopUp.available) {
|
||||
autoTopUpValue = translate("grokAutoTopUpUnavailable", "Unavailable");
|
||||
} else if (!autoTopUp.enabled) {
|
||||
autoTopUpValue = translate("grokAutoTopUpDisabled", "Disabled");
|
||||
} else {
|
||||
const threshold = formatGrokMinorUnits(
|
||||
autoTopUp.thresholdMinorUnits,
|
||||
billing.currency,
|
||||
locales
|
||||
);
|
||||
const amount = formatGrokMinorUnits(autoTopUp.amountMinorUnits, billing.currency, locales);
|
||||
const maximum = formatGrokMinorUnits(autoTopUp.maxMonthlyMinorUnits, billing.currency, locales);
|
||||
autoTopUpValue = [
|
||||
translate("grokAutoTopUpEnabled", "Enabled"),
|
||||
threshold ? `${translate("grokAutoTopUpAt", "at")} ${threshold}` : null,
|
||||
amount ? `${translate("grokAutoTopUpAdd", "add")} ${amount}` : null,
|
||||
maximum
|
||||
? `${translate("grokAutoTopUpMax", "max")} ${maximum}/${translate(
|
||||
"grokAutoTopUpMonth",
|
||||
"month"
|
||||
)}`
|
||||
: null,
|
||||
]
|
||||
.filter((part): part is string => part !== null)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
rows.push({
|
||||
kind: "status",
|
||||
label: translate("grokAutoTopUp", "Auto Top-Up"),
|
||||
value: autoTopUpValue,
|
||||
});
|
||||
rows.push({
|
||||
kind: "link",
|
||||
label: translate("grokAdditionalCredits", "Additional Credits"),
|
||||
href: billing.additionalCreditsUrl,
|
||||
target: "_blank",
|
||||
rel: "noreferrer noopener",
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
Reference in New Issue
Block a user