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:
diegosouzapw
2026-08-04 23:15:40 -03:00
355 changed files with 9313 additions and 1705 deletions

View File

@@ -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) ────────────────
{

View File

@@ -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;
}

View File

@@ -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",
];

View File

@@ -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": {

View File

@@ -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;
}

View File

@@ -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,
];
/**

View 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;
}