Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions bin/omniroute.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ const args = process.argv.slice(2);

if (args.includes("--help") || args.includes("-h")) {
console.log(`
\x1b[1m\x1b[36m⚡ OmniRoute\x1b[0m — Smart AI Router with Auto Fallback
\x1b[1m\x1b[36m⚡ ApexRoute\x1b[0m — Smart AI Router with Auto Fallback

\x1b[1mUsage:\x1b[0m
omniroute Start the server
Expand Down Expand Up @@ -293,7 +293,7 @@ server.on("exit", (code) => {

// ── Graceful shutdown ──────────────────────────────────────
function shutdown() {
console.log("\n\x1b[33m⏹ Shutting down OmniRoute...\x1b[0m");
console.log("\n\x1b[33m⏹ Shutting down ApexRoute...\x1b[0m");
server.kill("SIGTERM");
setTimeout(() => {
server.kill("SIGKILL");
Expand All @@ -310,7 +310,7 @@ async function onReady() {
const apiUrl = `http://localhost:${apiPort}`;

console.log(`
\x1b[32m✔ OmniRoute is running!\x1b[0m
\x1b[32m✔ ApexRoute is running!\x1b[0m

\x1b[1m Dashboard:\x1b[0m ${dashboardUrl}
\x1b[1m API Base:\x1b[0m ${apiUrl}/v1
Expand Down
6 changes: 3 additions & 3 deletions bin/reset-password.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,13 @@ function generateSecretDigest(input) {
return bcrypt.hashSync(input, 10);
}

console.log("\n🔑 OmniRoute — Password Reset\n");
console.log("\n🔑 ApexRoute — Password Reset\n");

async function main() {
// Check if database exists
if (!existsSync(DB_PATH)) {
console.error(`❌ Database not found at: ${DB_PATH}`);
console.error(` Make sure OmniRoute has been started at least once.`);
console.error(` Make sure ApexRoute has been started at least once.`);
console.error(` Or set DATA_DIR env var to your data directory.\n`);
process.exit(1);
}
Expand Down Expand Up @@ -108,7 +108,7 @@ async function main() {
rl.close();

console.log("\n✅ Password reset successfully!");
console.log(" Restart OmniRoute for changes to take effect.\n");
console.log(" Restart ApexRoute for changes to take effect.\n");
}

main().catch((err) => {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "3.6.1",
"version": "3.7.0",
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
"type": "module",
"bin": {
Expand Down
6 changes: 4 additions & 2 deletions src/app/(dashboard)/dashboard/combos/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ import Tooltip from "@/shared/components/Tooltip";
import ModelRoutingSection from "@/shared/components/ModelRoutingSection";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { useNotificationStore } from "@/store/notificationStore";
import { ROUTING_STRATEGIES } from "@/shared/constants/routingStrategies";
import { ROUTING_STRATEGIES, COMBO_STRATEGY_VALUES } from "@/shared/constants/routingStrategies";
import { useTranslations } from "next-intl";

// Validate combo name: letters, numbers, -, _, /, .
const VALID_NAME_REGEX = /^[a-zA-Z0-9_/.-]+$/;

const STRATEGY_OPTIONS = ROUTING_STRATEGIES.map((strategy) => ({
const STRATEGY_OPTIONS = ROUTING_STRATEGIES.filter((strategy) =>
COMBO_STRATEGY_VALUES.includes(strategy.value)
).map((strategy) => ({
value: strategy.value,
labelKey: strategy.labelKey,
descKey: strategy.combosDescKey,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useState, useEffect } from "react";
import { Card, Button, Input, Toggle } from "@/shared/components";
import { cn } from "@/shared/utils/cn";
import { ROUTING_STRATEGIES } from "@/shared/constants/routingStrategies";
import { ROUTING_STRATEGIES, COMBO_STRATEGY_VALUES } from "@/shared/constants/routingStrategies";
import { useTranslations } from "next-intl";

const STRATEGY_LABEL_FALLBACKS: Record<string, string> = {
Expand Down Expand Up @@ -37,7 +37,9 @@ export default function ComboDefaultsTab() {
const [saving, setSaving] = useState(false);
const t = useTranslations("settings");
const tc = useTranslations("common");
const strategyOptions = ROUTING_STRATEGIES.map((strategy) => ({
const strategyOptions = ROUTING_STRATEGIES.filter((strategy) =>
COMBO_STRATEGY_VALUES.includes(strategy.value)
).map((strategy) => ({
value: strategy.value,
label: translateOrFallback(
t,
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/providers/[id]/test/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const OAUTH_TEST_CONFIG = {
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
extraHeaders: { "User-Agent": "OmniRoute", Accept: "application/vnd.github+json" },
extraHeaders: { "User-Agent": "ApexRoute", Accept: "application/vnd.github+json" },
},
iflow: {
// iFlow's getUserInfo endpoint returns 400 without a specific format.
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/settings/favicon/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export async function GET() {
const response = await fetch(customFaviconUrl, {
signal: controller.signal,
headers: {
"User-Agent": "OmniRoute/1.0",
"User-Agent": "ApexRoute/1.0",
},
});
clearTimeout(timeoutId);
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2055,6 +2055,8 @@
"costOptDesc": "Prefer cheapest available account",
"strictRandom": "Strict Random",
"strictRandomDesc": "Shuffle deck — uses each account once before reshuffling",
"earliestResetFirst": "Earliest Reset First",
"earliestResetFirstDesc": "Burn down accounts whose quota resets soonest, with 5-min cache affinity",
"stickyLimit": "Sticky Limit",
"stickyLimitDesc": "Calls per account before switching",
"modelAliases": "Model Aliases",
Expand Down
2 changes: 1 addition & 1 deletion src/lib/catalog/openrouterCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ function writeCache(data: CatalogEntry[]): void {
async function fetchFromAPI(): Promise<CatalogEntry[]> {
const res = await fetch(OPENROUTER_API_URL, {
headers: {
"User-Agent": "OmniRoute/2.0",
"User-Agent": "ApexRoute/2.0",
Accept: "application/json",
},
signal: AbortSignal.timeout(15_000),
Expand Down
2 changes: 1 addition & 1 deletion src/lib/webhookDispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export async function deliverWebhook(
const body = JSON.stringify(payload);
const headers: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": "OmniRoute-Webhook/1.0",
"User-Agent": "ApexRoute-Webhook/1.0",
"X-Webhook-Event": payload.event,
"X-Webhook-Timestamp": payload.timestamp,
};
Expand Down
2 changes: 1 addition & 1 deletion src/shared/constants/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import pkg from "../../../package.json" with { type: "json" };

// App configuration
export const APP_CONFIG = {
name: "OmniRoute",
name: "ApexRoute",
description: "AI Gateway for Multi-Provider LLMs",
version: pkg.version,
};
Expand Down
30 changes: 29 additions & 1 deletion src/shared/constants/routingStrategies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export type RoutingStrategyValue =
| "strict-random"
| "auto"
| "context-optimized"
| "lkgp";
| "lkgp"
| "earliest-reset-first";

type RoutingStrategyOption = {
value: RoutingStrategyValue;
Expand Down Expand Up @@ -113,6 +114,13 @@ export const ROUTING_STRATEGIES: RoutingStrategyOption[] = [
settingsDescKey: "contextOptDesc",
icon: "text_snippet",
},
{
value: "earliest-reset-first",
labelKey: "earliestResetFirst",
combosDescKey: "earliestResetFirstDesc",
settingsDescKey: "earliestResetFirstDesc",
icon: "hourglass_bottom",
},
];

export const SETTINGS_FALLBACK_STRATEGY_VALUES: RoutingStrategyValue[] = [
Expand All @@ -128,4 +136,24 @@ export const SETTINGS_FALLBACK_STRATEGY_VALUES: RoutingStrategyValue[] = [
"auto",
"context-optimized",
"lkgp",
"earliest-reset-first",
];

// Combo strategy is a separate dispatch path inside `combo.ts` and only
// supports a curated subset of values. `earliest-reset-first` is per-provider
// account selection (auth.ts dispatch) and is intentionally excluded here.
export const COMBO_STRATEGY_VALUES: RoutingStrategyValue[] = [
"priority",
"weighted",
"round-robin",
"context-relay",
"fill-first",
"p2c",
"random",
"least-used",
"cost-optimized",
"strict-random",
"auto",
"context-optimized",
"lkgp",
];
1 change: 1 addition & 0 deletions src/shared/validation/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ export const updateSettingsSchema = z.object({
"least-used",
"cost-optimized",
"strict-random",
"earliest-reset-first",
])
.optional(),
wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(),
Expand Down
11 changes: 10 additions & 1 deletion src/shared/validation/settingsSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,16 @@ export const updateSettingsSchema = z.object({
hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(),
// Routing settings (#134)
fallbackStrategy: z
.enum(["fill-first", "round-robin", "p2c", "random", "least-used", "cost-optimized"])
.enum([
"fill-first",
"round-robin",
"p2c",
"random",
"least-used",
"cost-optimized",
"strict-random",
"earliest-reset-first",
])
.optional(),
wildcardAliases: z.array(z.object({ pattern: z.string(), target: z.string() })).optional(),
stickyRoundRobinLimit: z.number().int().min(0).max(1000).optional(),
Expand Down
6 changes: 4 additions & 2 deletions src/sse/handlers/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,8 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
provider,
null,
apiKeyInfo?.allowedConnections ?? null,
modelInfo.model || modelString
modelInfo.model || modelString,
{ sessionId }
);
if (!creds || creds.allRateLimited) return false;

Expand Down Expand Up @@ -491,8 +492,9 @@ async function handleSingleModelChat(
? {
allowSuppressedConnections: true,
bypassQuotaPolicy: true,
sessionId: runtimeOptions.sessionId || null,
}
: undefined
: { sessionId: runtimeOptions.sessionId || null }
);

if (!credentials || credentials.allRateLimited) {
Expand Down
15 changes: 15 additions & 0 deletions src/sse/services/accountTerminalStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Canonical terminal-status helpers. Originally inlined in src/sse/services/auth.ts;
// extracted so other modules (e.g. earliestResetFirst.ts) reuse the same logic.

export interface TerminalStatusBearer {
testStatus?: string | null;
}

export function normalizeStatus(value: string | null | undefined): string {
return (value || "").trim().toLowerCase();
}

export function isTerminalConnectionStatus(connection: TerminalStatusBearer): boolean {
const status = normalizeStatus(connection.testStatus);
return status === "credits_exhausted" || status === "banned" || status === "expired";
}
37 changes: 28 additions & 9 deletions src/sse/services/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
lockModel,
hasPerModelQuota,
} from "@omniroute/open-sse/services/accountFallback.ts";
import { isTerminalConnectionStatus } from "@/sse/services/accountTerminalStatus";
import { selectByEarliestResetFirst } from "@/sse/services/strategies/earliestResetFirst";
import {
isLocalProvider,
getPassthroughProviders,
Expand Down Expand Up @@ -64,6 +66,7 @@ interface RecoverableConnectionState {
interface CredentialSelectionOptions {
allowSuppressedConnections?: boolean;
bypassQuotaPolicy?: boolean;
sessionId?: string | null;
}

const CODEX_QUOTA_THRESHOLD_PERCENT = 90;
Expand Down Expand Up @@ -220,15 +223,6 @@ function getEarliestCodexScopeRateLimitedUntil(
return earliest;
}

function normalizeStatus(value: string | null): string {
return (value || "").trim().toLowerCase();
}

function isTerminalConnectionStatus(connection: ProviderConnectionView): boolean {
const status = normalizeStatus(connection.testStatus);
return status === "credits_exhausted" || status === "banned" || status === "expired";
}

export function resolveQuotaLimitPolicy(
provider: string,
providerSpecificData: JsonRecord
Expand Down Expand Up @@ -705,6 +699,31 @@ export async function getProviderCredentials(
const ids = orderedConnections.map((c) => c.id);
const selectedId = getNextFromDeckSync(`conn:${provider}`, ids);
connection = orderedConnections.find((c) => c.id === selectedId) || orderedConnections[0];
} else if (strategy === "earliest-reset-first") {
// Cache-aware burn-down: route to the account whose quota resets soonest,
// with affinity to the same account within a 5-min sliding window. See
// .sisyphus/plans/routing-strategy-v4.md and earliestResetFirst.ts.
const result = selectByEarliestResetFirst(
orderedConnections,
requestedModel,
options.sessionId || null
);
if ("allExcluded" in result) {
log.warn(
"AUTH",
`${provider} | earliest-reset-first: all candidates excluded`,
result.excludedBreakdown
);
return {
allRateLimited: true,
retryAfter: result.retryAfterIso,
retryAfterHuman: result.retryAfter ? formatRetryAfter(result.retryAfter) : "soon",
};
}
connection = result.selected as (typeof orderedConnections)[number];
await updateProviderConnection(connection.id, {
lastUsedAt: new Date().toISOString(),
});
} else {
// Default: fill-first (already sorted by priority in getProviderConnections)
connection = orderedConnections[0];
Expand Down
Loading
Loading