chore: merge release/v3.8.5 to resolve conflicts

This commit is contained in:
diegosouzapw
2026-05-27 10:31:40 -03:00
19 changed files with 496 additions and 55 deletions

View File

@@ -37,6 +37,8 @@ INITIAL_PASSWORD=CHANGEME
# Base directory for all persistent data (SQLite DB, logs, backups).
# Used by: src/lib/db/core.ts — resolves the SQLite database file path.
# Default: ~/.omniroute/ | Override for Docker or custom installations.
# Hint: When running in Docker, consider mounting a host directory here for data persistence across container restarts
# also if you want to share the same database as "npm run dev" use "./data"
# DATA_DIR=/var/lib/omniroute
# Encryption key for SQLite database encryption at rest.

View File

@@ -15,15 +15,25 @@
- **docker:** rebuild `better-sqlite3` native bindings after hardened install to resolve container startup crash (#2772 — thanks @thanet-s)
- **combos:** make combo target timeout configurable, inheriting resolved request timeout by default and clamping values so they only shorten fallback latency (#2775 — thanks @rdself)
- **oauth:** use public callbacks for remote Google OAuth with custom creds (#2787 — thanks @akarray)
- **combos:** allow rate-limited provider connections after transient 429s (#2786 — thanks @JxnLexn)
- **logs:** keep database log settings in sync with the pipeline toggle (#2785 — thanks @JxnLexn)
- **docker:** speedup docker creation by reducing steps and bunch up copy operations (#2784 — thanks @hartmark)
- **codex:** apply global service tiers to combo request bodies (#2783 — thanks @JxnLexn)
### ⚡ Performance / CI
- **ci:** build Docker platforms on native runners (linux/amd64 on ubuntu-24.04 and linux/arm64 on ubuntu-24.04-arm) instead of emulated QEMU, reducing build times significantly (#2774 — thanks @thanet-s)
### 📝 Documentation
- **docs:** fix broken documentation links in README after Fumadocs migration (#2782 — thanks @kjhq)
### 🏆 Hall of Contributors
A special thanks to everyone who contributed code, reviews, and tests for this release:
@hartmark, @hijak, @rdself, @thanet-s
@akarray, @hartmark, @hijak, @JxnLexn, @kjhq, @rdself, @thanet-s
---

View File

@@ -1,10 +1,22 @@
FROM node:24-trixie-slim AS builder
# ── Common base with runtime deps ──────────────────────────────────────────
FROM node:24-trixie-slim AS base
WORKDIR /app
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
RUN --mount=type=cache,target=/var/cache/apt,sharing=shared \
--mount=type=cache,target=/var/lib/apt/lists,sharing=shared \
apt-get update \
&& apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates python3 make g++ \
&& apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# ── Builder ────────────────────────────────────────────────────────────────
FROM base AS builder
# Build tools for native module compilation
# apt-get update needed here because base's rm -rf clears the shared cache
RUN --mount=type=cache,target=/var/cache/apt,sharing=shared \
--mount=type=cache,target=/var/lib/apt/lists,sharing=shared \
apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json ./
@@ -26,12 +38,15 @@ RUN --mount=type=cache,target=/root/.npm \
&& npm rebuild better-sqlite3 \
&& node -e "require('better-sqlite3')(':memory:').close()"
# Use Turbopack for significant build speedup
ENV OMNIROUTE_USE_TURBOPACK=1
COPY . ./
RUN --mount=type=cache,target=/app/.next/cache \
mkdir -p /app/data && npm run build -- --webpack
mkdir -p /app/data && npm run build
FROM node:24-trixie-slim AS runner-base
WORKDIR /app
# ── Runner base ────────────────────────────────────────────────────────────
FROM base AS runner-base
LABEL org.opencontainers.image.title="omniroute" \
org.opencontainers.image.description="Unified AI proxy — route any LLM through one endpoint" \
@@ -46,15 +61,11 @@ ENV NODE_OPTIONS="--max-old-space-size=256"
# Data directory inside Docker — must match the volume mount in docker-compose.yml
ENV DATA_DIR=/app/data
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
apt-get update \
&& apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /app/data
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/static ./.next/static
# The standalone build + syncStandaloneExtraModules bundles all runtime files
# (.next, node_modules, migrations, scripts, docs, etc.) into .next/standalone/.
# Explicit overrides below cover modules that NFT tracing may miss.
COPY --from=builder /app/.next/standalone ./
# Explicitly copy @swc/helpers — not always traced by standalone output but needed at runtime
COPY --from=builder /app/node_modules/@swc/helpers ./node_modules/@swc/helpers
@@ -70,18 +81,6 @@ COPY --from=builder /app/node_modules/split2 ./node_modules/split2
# traced by Next.js standalone output — copy them explicitly.
COPY --from=builder /app/src/lib/db/migrations ./migrations
ENV OMNIROUTE_MIGRATIONS_DIR=/app/migrations
# MITM server.cjs is spawned at runtime via child_process — not traced by nft
COPY --from=builder /app/src/mitm/server.cjs ./src/mitm/server.cjs
# Runtime docs are pruned by .dockerignore to English markdown + OpenAPI.
# Next.js standalone tracing does not include docs read via fs.
COPY --from=builder /app/.next/standalone/docs ./docs
COPY --from=builder /app/scripts/dev/run-standalone.mjs ./dev/run-standalone.mjs
COPY --from=builder /app/scripts/build/runtime-env.mjs ./build/runtime-env.mjs
COPY --from=builder /app/scripts/build/bootstrap-env.mjs ./build/bootstrap-env.mjs
COPY --from=builder /app/scripts/dev/healthcheck.mjs ./healthcheck.mjs
RUN node -e "require('better-sqlite3')(':memory:').close()"
# Hand /app over to the baked-in `node` non-root user (UID/GID 1000) so the
# runtime process never holds root privileges. The chown happens after all

View File

@@ -190,7 +190,10 @@ type ComboLogger = {
};
export type SingleModelTarget =
| (ResolvedComboTarget & { modelAbortSignal?: AbortSignal | null })
| (ResolvedComboTarget & {
allowRateLimitedConnection?: boolean;
modelAbortSignal?: AbortSignal | null;
})
| { modelAbortSignal: AbortSignal };
type HandleSingleModel = (
@@ -201,7 +204,7 @@ type HandleSingleModel = (
type IsModelAvailable = (
modelStr: string,
target?: ResolvedComboTarget
target?: ResolvedComboTarget & { allowRateLimitedConnection?: boolean }
) => Promise<boolean> | boolean;
type ComboRelayOptions = {
@@ -3098,6 +3101,7 @@ export async function handleComboChat({
// #1731: Per-set-iteration set of providers whose quota is fully exhausted.
// Reset each retry so providers excluded in a previous attempt get another chance.
const exhaustedProviders = new Set<string>();
const transientRateLimitedProviders = new Set<string>();
if (setTry > 0) {
log.info("COMBO", `All targets failed — retrying set (${setTry}/${maxSetRetries})`);
await new Promise((resolve) => {
@@ -3129,6 +3133,11 @@ export async function handleComboChat({
const modelStr = target.modelStr;
const provider = target.provider;
const profile = await getRuntimeProviderProfile(provider);
const allowRateLimitedConnection =
Boolean(provider && provider !== "unknown") && transientRateLimitedProviders.has(provider);
const targetForAttempt = allowRateLimitedConnection
? { ...target, allowRateLimitedConnection: true }
: target;
// #1731: Skip targets from a provider that already signaled full quota exhaustion this request.
if (provider && exhaustedProviders.has(provider)) {
@@ -3142,7 +3151,7 @@ export async function handleComboChat({
// Pre-check: skip models where no credentials are available (excluded, rate-limited, or unavailable)
if (isModelAvailable) {
const available = await isModelAvailable(modelStr, target);
const available = await isModelAvailable(modelStr, targetForAttempt);
if (!available) {
log.info("COMBO", `Skipping ${modelStr} — no credentials available or model excluded`);
if (i > 0) fallbackCount++;
@@ -3232,7 +3241,7 @@ export async function handleComboChat({
}
}
const result = await handleSingleModelWithTimeout(attemptBody, modelStr, {
...target,
...targetForAttempt,
failoverBeforeRetry: config.failoverBeforeRetry,
});
@@ -3501,6 +3510,8 @@ export async function handleComboChat({
"COMBO",
`Provider ${provider} quota exhausted — marking for skip on remaining targets (#1731)`
);
} else if (result.status === 429 && provider && provider !== "unknown") {
transientRateLimitedProviders.add(provider);
}
// Trigger shared provider circuit breaker for 5xx errors and connection failures.
@@ -3690,6 +3701,7 @@ async function handleRoundRobinCombo({
// When a target returns a quota-exhausted 429, remaining targets from the same
// provider are skipped to avoid the cascade through N same-provider targets.
const exhaustedProviders = new Set<string>();
const transientRateLimitedProviders = new Set<string>();
// Try each model starting from the round-robin target
for (let offset = 0; offset < modelCount; offset++) {
@@ -3699,10 +3711,15 @@ async function handleRoundRobinCombo({
const provider = target.provider;
const profile = await getRuntimeProviderProfile(provider);
const semaphoreKey = `combo:${combo.name}:${target.executionKey}`;
const allowRateLimitedConnection =
Boolean(provider && provider !== "unknown") && transientRateLimitedProviders.has(provider);
const targetForAttempt = allowRateLimitedConnection
? { ...target, allowRateLimitedConnection: true }
: target;
// Pre-check availability
if (isModelAvailable) {
const available = await isModelAvailable(modelStr, target);
const available = await isModelAvailable(modelStr, targetForAttempt);
if (!available) {
log.info("COMBO-RR", `Skipping ${modelStr} — no credentials available or model excluded`);
if (offset > 0) fallbackCount++;
@@ -3766,7 +3783,7 @@ async function handleRoundRobinCombo({
);
const result = await handleSingleModel(body, modelStr, {
...target,
...targetForAttempt,
failoverBeforeRetry: config.failoverBeforeRetry,
});
@@ -3932,6 +3949,8 @@ async function handleRoundRobinCombo({
if (providerExhausted) {
exhaustedProviders.add(provider);
log.info("COMBO-RR", `Provider ${provider} quota exhausted — marking for skip (#1731)`);
} else if (result.status === 429 && provider && provider !== "unknown") {
transientRateLimitedProviders.add(provider);
}
// Transient errors → mark in semaphore so round-robin stops stampeding this target.

View File

@@ -147,6 +147,18 @@ export async function syncStandaloneNativeAssets(
sourcePath: path.join(rootDir, "node_modules", "wreq-js", "rust"),
destinationPath: path.join(rootDir, ".next", "standalone", "node_modules", "wreq-js", "rust"),
},
{
label: "better-sqlite3 native binary",
sourcePath: path.join(rootDir, "node_modules", "better-sqlite3", "build"),
destinationPath: path.join(
rootDir,
".next",
"standalone",
"node_modules",
"better-sqlite3",
"build"
),
},
];
let changed = false;
@@ -171,6 +183,90 @@ export async function syncStandaloneNativeAssets(
return changed;
}
export async function syncStandaloneExtraModules(
rootDir = projectRoot,
fsImpl = fs,
log = console
) {
const entries = [
{
label: "@swc/helpers",
sourcePath: path.join(rootDir, "node_modules", "@swc", "helpers"),
destRelative: path.join("node_modules", "@swc", "helpers"),
},
{
label: "pino-abstract-transport",
sourcePath: path.join(rootDir, "node_modules", "pino-abstract-transport"),
destRelative: path.join("node_modules", "pino-abstract-transport"),
},
{
label: "pino-pretty",
sourcePath: path.join(rootDir, "node_modules", "pino-pretty"),
destRelative: path.join("node_modules", "pino-pretty"),
},
{
label: "split2",
sourcePath: path.join(rootDir, "node_modules", "split2"),
destRelative: path.join("node_modules", "split2"),
},
{
label: "migrations",
sourcePath: path.join(rootDir, "src", "lib", "db", "migrations"),
destRelative: "migrations",
},
{
label: "MITM server",
sourcePath: path.join(rootDir, "src", "mitm", "server.cjs"),
destRelative: path.join("src", "mitm", "server.cjs"),
},
{
label: "run-standalone script",
sourcePath: path.join(rootDir, "scripts", "dev", "run-standalone.mjs"),
destRelative: path.join("dev", "run-standalone.mjs"),
},
{
label: "runtime-env script",
sourcePath: path.join(rootDir, "scripts", "build", "runtime-env.mjs"),
destRelative: path.join("build", "runtime-env.mjs"),
},
{
label: "bootstrap-env script",
sourcePath: path.join(rootDir, "scripts", "build", "bootstrap-env.mjs"),
destRelative: path.join("build", "bootstrap-env.mjs"),
},
{
label: "healthcheck script",
sourcePath: path.join(rootDir, "scripts", "dev", "healthcheck.mjs"),
destRelative: "healthcheck.mjs",
},
{
label: "public directory",
sourcePath: path.join(rootDir, "public"),
destRelative: "public",
},
{
label: "playwright-core (dynamic import by gemini-web executor)",
sourcePath: path.join(rootDir, "node_modules", "playwright-core"),
destRelative: path.join("node_modules", "playwright-core"),
},
];
let changed = false;
const standaloneRoot = path.join(rootDir, ".next", "standalone");
for (const entry of entries) {
if (!(await exists(entry.sourcePath))) continue;
const destPath = path.join(standaloneRoot, entry.destRelative);
await fsImpl.mkdir(path.dirname(destPath), { recursive: true });
await fsImpl.cp(entry.sourcePath, destPath, { recursive: true, force: true });
log.log(`[build-next-isolated] Synced standalone module: ${entry.label}`);
changed = true;
}
return changed;
}
export async function main() {
const movedPaths = [];
const transientBuildPaths = getTransientBuildPaths();
@@ -225,6 +321,15 @@ export async function main() {
nativeAssetErr
);
}
try {
await syncStandaloneExtraModules(projectRoot);
} catch (extraModuleErr) {
console.warn(
"[build-next-isolated] Non-fatal error syncing extra modules:",
extraModuleErr
);
}
}
process.exitCode = result.code;
} catch (error) {

View File

@@ -9,6 +9,7 @@ import {
getRequestDetailLogCount,
isDetailedLoggingEnabled,
} from "@/lib/db/detailedLogs";
import { getUserDatabaseSettings, updateDatabaseSettings } from "@/lib/db/databaseSettings";
import { updateSettings } from "@/lib/db/settings";
export const dynamic = "force-dynamic";
@@ -36,6 +37,14 @@ export async function POST(req: NextRequest) {
const enabled = body.enabled === true || body.enabled === "1";
await updateSettings({ call_log_pipeline_enabled: enabled });
const databaseSettings = getUserDatabaseSettings();
updateDatabaseSettings({
logs: {
...databaseSettings.logs,
detailedLogsEnabled: enabled,
callLogPipelineEnabled: enabled,
},
});
return NextResponse.json({
success: true,

View File

@@ -6,6 +6,7 @@ import {
exchangeTokens,
requestDeviceCode,
pollForToken,
resolveBrowserOAuthRedirectUri,
} from "@/lib/oauth/providers";
import {
createProviderConnection,
@@ -80,7 +81,9 @@ export async function GET(
const { searchParams } = new URL(request.url);
if (action === "authorize") {
const redirectUri = searchParams.get("redirect_uri") || "http://localhost:8080/callback";
const requestedRedirectUri =
searchParams.get("redirect_uri") || "http://localhost:8080/callback";
const redirectUri = resolveBrowserOAuthRedirectUri(provider, requestedRedirectUri);
const authData = generateAuthData(provider, redirectUri);
if (provider === "qoder" && !authData.authUrl) {
return NextResponse.json({

View File

@@ -83,6 +83,17 @@ function parseStoredValue(rawValue: unknown): unknown {
}
}
function toBooleanSetting(value: unknown): boolean | null {
if (typeof value === "boolean") return value;
if (typeof value === "number") return !Number.isNaN(value) && value !== 0;
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
if (["1", "true", "yes", "on"].includes(normalized)) return true;
if (["0", "false", "no", "off"].includes(normalized)) return false;
return null;
}
function readNamespace(namespace: string): Record<string, unknown> {
const db = getDbInstance();
const rows = db
@@ -119,6 +130,18 @@ function mergeTopLevelSections(target: UserDatabaseSettings, values: Record<stri
}
}
function mergeRuntimeLogSettings(target: UserDatabaseSettings, values: Record<string, unknown>) {
const pipelineEnabled = toBooleanSetting(values.call_log_pipeline_enabled);
if (pipelineEnabled !== null) {
target.logs.callLogPipelineEnabled = pipelineEnabled;
}
const legacyDetailedEnabled = toBooleanSetting(values.detailed_logs_enabled);
if (legacyDetailedEnabled !== null) {
target.logs.detailedLogsEnabled = legacyDetailedEnabled;
}
}
function mergeDatabaseSettingsNamespace(
target: UserDatabaseSettings,
values: Record<string, unknown>
@@ -195,6 +218,7 @@ export function getUserDatabaseSettings(): UserDatabaseSettings {
mergeTopLevelSections(settings, mainSettings);
mergeDatabaseSettingsNamespace(settings, readNamespace(DATABASE_SETTINGS_NAMESPACE));
mergeRuntimeLogSettings(settings, mainSettings);
return settings;
}
@@ -236,6 +260,14 @@ export function updateDatabaseSettings(
const insert = db.prepare(
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)"
);
const settingsInsert = db.prepare(
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', ?, ?)"
);
const requestedLogs = updates.logs as Partial<UserDatabaseSettings["logs"]> | undefined;
const pipelineEnabled = requestedLogs?.callLogPipelineEnabled;
const detailedEnabled = requestedLogs?.detailedLogsEnabled;
const tx = db.transaction(() => {
for (const section of DATABASE_SETTINGS_SECTIONS) {
const sectionValues = nextSettings[section] as Record<string, unknown>;
@@ -244,6 +276,10 @@ export function updateDatabaseSettings(
insert.run(DATABASE_SETTINGS_NAMESPACE, `${section}.${key}`, JSON.stringify(value));
}
}
if (pipelineEnabled !== undefined) {
settingsInsert.run("call_log_pipeline_enabled", JSON.stringify(Boolean(pipelineEnabled)));
}
});
tx();

View File

@@ -10,6 +10,66 @@
import { generatePKCE, generateState } from "./utils/pkce";
import { PROVIDERS } from "./providers/index";
const GOOGLE_BROWSER_PROVIDERS = new Set(["antigravity", "gemini-cli"]);
function normalizeBaseUrl(value) {
const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed) return "";
return trimmed.replace(/\/+$/, "");
}
function hasCustomGoogleOAuthCredentials(providerName, env = process.env) {
if (providerName === "antigravity") {
return !!env.ANTIGRAVITY_OAUTH_CLIENT_ID?.trim();
}
if (providerName === "gemini-cli") {
return !!env.GEMINI_CLI_OAUTH_CLIENT_ID?.trim() || !!env.GEMINI_OAUTH_CLIENT_ID?.trim();
}
return false;
}
/**
* Google providers default to localhost redirects so the embedded public
* credentials keep working on out-of-the-box local installs. When operators
* provide their own Google OAuth client IDs for a remote deployment, prefer the
* public callback URL documented in .env.example / docs/README so the popup can
* navigate back to OmniRoute instead of stalling on localhost.
*/
export function resolveBrowserOAuthRedirectUri(
providerName,
redirectUri,
env = process.env
) {
if (!GOOGLE_BROWSER_PROVIDERS.has(providerName)) {
return redirectUri;
}
if (!hasCustomGoogleOAuthCredentials(providerName, env)) {
return redirectUri;
}
const publicBaseUrl =
normalizeBaseUrl(env.NEXT_PUBLIC_BASE_URL) || normalizeBaseUrl(env.OMNIROUTE_PUBLIC_BASE_URL);
if (!publicBaseUrl) {
return redirectUri;
}
try {
const requested = new URL(redirectUri);
const isLocalhostRedirect = /^(localhost|127\.0\.0\.1)$/i.test(requested.hostname);
if (!isLocalhostRedirect) {
return redirectUri;
}
} catch {
return redirectUri;
}
return `${publicBaseUrl}/callback`;
}
/**
* Get provider handler
*/

View File

@@ -130,8 +130,8 @@ export interface ApplyCodexGlobalFastServiceTierOptions {
*/
model?: string | null;
/**
* Outbound request body. Per-request body.service_tier is left untouched if
* already set.
* Outbound request body. A valid per-request body.service_tier is left untouched
* when already set.
*/
body?: Record<string, unknown> | null;
}
@@ -188,12 +188,11 @@ export function applyCodexGlobalFastServiceTier<T extends JsonRecord | null | un
} as T;
}
if (resolved.tier === "flex") {
// Write the wire value directly to the outbound body when possible. The executor
// also accepts requestDefaults.serviceTier = "flex" for downstream accounting.
if (body && typeof body === "object" && !Array.isArray(body)) {
(body as JsonRecord).service_tier = "flex";
}
if (body && typeof body === "object" && !Array.isArray(body)) {
// Write the wire value directly to the outbound body when possible, so combo
// request previews and logs show the same effective tier that the executor sends.
// The executor also accepts requestDefaults.serviceTier for downstream accounting.
(body as JsonRecord).service_tier = resolved.tier;
}
// Intentional precedence: body service_tier > global mode > connection defaults.

View File

@@ -381,9 +381,9 @@ export default function OAuthModal({
// - Codex/OpenAI: always port 1455 (registered in OAuth app)
// - Windsurf/Devin CLI (remote fallback): use localhost with OmniRoute port + /auth/callback
// (on true localhost the callback server handles it; this is only reached on remote)
// - Google OAuth providers (antigravity, gemini-cli): always localhost, regardless of
// where OmniRoute is hosted — Google only accepts pre-registered localhost URIs with
// the built-in credentials. Remote users must configure their own credentials.
// - Google OAuth providers (antigravity, gemini-cli): default to localhost so the
// bundled credentials keep working. The authorize route upgrades this to the public
// callback when custom Google credentials + NEXT_PUBLIC_BASE_URL are configured.
// - Other providers on remote: use actual origin (supports PUBLIC_URL env var)
// - Localhost: use localhost:port
let redirectUri: string;
@@ -433,7 +433,7 @@ export default function OAuthModal({
);
}
setAuthData({ ...data, redirectUri });
setAuthData({ ...data, redirectUri: data.redirectUri || redirectUri });
// For non-true-localhost (LAN IPs, remote) or manual fallback: use manual input mode (user pastes callback URL)
if (!isTrueLocalhost || forceManual) {

View File

@@ -438,6 +438,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
const checkModelAvailable = async (
modelString: string,
target?: {
allowRateLimitedConnection?: boolean;
connectionId?: string | null;
allowedConnectionIds?: string[] | null;
executionKey?: string | null;
@@ -469,6 +470,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
resolvedModel,
{
sessionKey: sessionAffinityKey,
...(target?.allowRateLimitedConnection ? { allowRateLimitedConnections: true } : {}),
...(target?.connectionId ? { forcedConnectionId: target.connectionId } : {}),
}
);
@@ -496,6 +498,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
b: any,
m: string,
target?: {
allowRateLimitedConnection?: boolean;
connectionId?: string | null;
executionKey?: string | null;
stepId?: string | null;
@@ -520,6 +523,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
comboStepId: target?.stepId || null,
comboExecutionKey: target?.executionKey || target?.stepId || null,
skipUpstreamRetry: target?.failoverBeforeRetry ?? false,
allowRateLimitedConnection: target?.allowRateLimitedConnection === true,
preselectedCredentials: comboPreselectedCredentials.get(
getComboCredentialCacheKey(m, target)
),
@@ -651,6 +655,7 @@ async function handleSingleModelChat(
comboStepId?: string | null;
comboExecutionKey?: string | null;
skipUpstreamRetry?: boolean;
allowRateLimitedConnection?: boolean;
preselectedCredentials?: any;
cachedSettings?: any;
} = {},
@@ -811,6 +816,9 @@ async function handleSingleModelChat(
{
sessionKey: runtimeOptions.sessionAffinityKey ?? runtimeOptions.sessionId ?? null,
excludeConnectionIds: Array.from(excludedConnectionIds),
...(runtimeOptions.allowRateLimitedConnection
? { allowRateLimitedConnections: true }
: {}),
...(forceLiveComboTest
? {
allowSuppressedConnections: true,

View File

@@ -94,6 +94,7 @@ interface RecoverableConnectionState {
interface CredentialSelectionOptions {
allowSuppressedConnections?: boolean;
allowRateLimitedConnections?: boolean;
bypassQuotaPolicy?: boolean;
forcedConnectionId?: string | null;
excludeConnectionIds?: string[] | null;
@@ -846,6 +847,8 @@ export async function getProviderCredentials(
}
const allowSuppressedConnections = options.allowSuppressedConnections === true;
const allowRateLimitedConnections =
allowSuppressedConnections || options.allowRateLimitedConnections === true;
const bypassQuotaPolicy = options.bypassQuotaPolicy === true;
const forcedConnectionId =
typeof options.forcedConnectionId === "string" && options.forcedConnectionId.trim().length > 0
@@ -973,7 +976,7 @@ export async function getProviderCredentials(
return false;
}
if (!allowSuppressedConnections) {
if (isAccountUnavailable(c.rateLimitedUntil)) return false;
if (!allowRateLimitedConnections && isAccountUnavailable(c.rateLimitedUntil)) return false;
if (isTerminalConnectionStatus(c)) return false;
if (provider === "codex" && isCodexScopeUnavailable(c, requestedModel)) return false;
// Per-model lockout: if this specific model is locked on this connection, skip it

View File

@@ -606,6 +606,47 @@ test("chat pipeline persists Codex responses cache and reasoning tokens to call
assert.equal(callLog.tokens.reasoning, 13);
});
test("chat pipeline applies global Codex priority service tier inside combos", async () => {
await seedConnection("codex", { apiKey: "sk-codex-combo-priority" });
await settingsDb.updateSettings({
codexServiceTier: { enabled: true, tier: "priority" },
});
await combosDb.createCombo({
name: "codex-priority-combo",
strategy: "priority",
config: { maxRetries: 0, retryDelayMs: 0 },
models: ["codex/gpt-5.5"],
});
const fetchCalls = [];
globalThis.fetch = async (url, init: RequestInit = {}) => {
fetchCalls.push({
url: String(url),
headers: toPlainHeaders(init.headers),
body: init.body ? JSON.parse(String(init.body)) : null,
});
return buildOpenAIResponsesSSE({ text: "combo priority ok", model: "gpt-5.5" });
};
const response = await handleChat(
buildRequest({
body: {
model: "codex-priority-combo",
stream: false,
messages: [{ role: "user", content: "Use Codex combo priority" }],
},
})
);
const json = (await response.json()) as any;
assert.equal(response.status, 200);
assert.equal(fetchCalls.length, 1);
assert.match(fetchCalls[0].url, /\/responses$/);
assert.equal(fetchCalls[0].headers.Authorization, "Bearer sk-codex-combo-priority");
assert.equal(fetchCalls[0].body.service_tier, "priority");
assert.equal(json.choices[0].message.content, "combo priority ok");
});
test("chat pipeline applies Codex CLI fingerprint to OAuth responses requests", async () => {
setCliCompatProviders(["codex"]);
await seedConnection("codex", {
@@ -950,12 +991,7 @@ test("chat pipeline sends Gemini CLI OAuth requests with native Cloud Code trans
assert.equal(generateCall.body.requestId, undefined);
assert.equal(generateCall.body.user_prompt_id, generateCall.body.request.session_id);
const keys = Object.keys(generateCall.body).slice(0, 4);
assert.deepEqual(keys.sort(), [
"model",
"project",
"request",
"user_prompt_id",
]);
assert.deepEqual(keys.sort(), ["model", "project", "request", "user_prompt_id"]);
assert.equal(generateCall.body.request.sessionId, undefined);
assert.match(generateCall.body.request.session_id, /^[0-9a-f-]{36}$/i);
assert.equal(generateCall.body.request.contents.at(-1).parts[0].text, "Hello Gemini CLI");

View File

@@ -495,3 +495,79 @@ test.skip("round-robin path fast-skip: round-robin combo also skips exhausted pr
assert.equal(openaiCalls, 1, "round-robin should skip second openai target");
assert.equal(anthropicCalls, 1);
});
test("allow rate-limited connections after transient 429 on subsequent targets in same combo", async () => {
const now = Date.now();
await seedConnection("openai", {
name: "openai-rate-limited-reused",
apiKey: "sk-openai-rate-limited-reused",
rateLimitedUntil: new Date(now + 60000).toISOString(),
});
await seedConnection("openai", {
name: "openai-fresh-429",
apiKey: "sk-openai-fresh-429",
});
await settingsDb.updateSettings({
requestRetry: 0,
maxRetryIntervalSec: 0,
});
await combosDb.createCombo({
name: "rate-limit-reuse-combo",
strategy: "priority",
config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 },
models: [
"openai/gpt-4o-mini",
"openai/gpt-3.5-turbo",
],
});
let openaiCalls = 0;
let usedApiKeys: string[] = [];
globalThis.fetch = async (_url: string, init: any = {}) => {
const headers = toPlainHeaders(init.headers);
const authHeader = headers.authorization ?? headers.Authorization;
openaiCalls += 1;
if (authHeader === "Bearer sk-openai-fresh-429") {
usedApiKeys.push("fresh");
return new Response(JSON.stringify({ error: { message: "Too many requests" } }), {
status: 429,
headers: { "Content-Type": "application/json" },
});
}
if (authHeader === "Bearer sk-openai-rate-limited-reused") {
usedApiKeys.push("rate-limited");
return new Response(
JSON.stringify({ choices: [{ message: { content: "rate limited reuse success" } }] }),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
}
throw new Error(`unexpected upstream headers: ${JSON.stringify(headers)}`);
};
const response = await handleChat(
buildRequest({
body: {
model: "rate-limit-reuse-combo",
stream: false,
messages: [{ role: "user", content: "test rate limit reuse" }],
},
})
);
const body = (await response.json()) as any;
assert.equal(response.status, 200);
assert.equal(body.choices[0].message.content, "rate limited reuse success");
assert.equal(openaiCalls, 2);
assert.deepEqual(usedApiKeys, ["fresh", "rate-limited"]);
});

View File

@@ -106,15 +106,17 @@ test("Codex global service tier injects selected mode and can override connectio
});
test("Codex global service tier matches provider-prefixed combo model ids", () => {
const body: Record<string, unknown> = {};
assert.deepEqual(
applyCodexGlobalFastServiceTier(
"codex",
{ providerSpecificData: {} },
{ codexServiceTier: { enabled: true, tier: "priority" } },
{ model: "codex/gpt-5.5" }
{ model: "codex/gpt-5.5", body }
),
{ providerSpecificData: { requestDefaults: { serviceTier: "priority" } } }
);
assert.equal(body.service_tier, "priority");
const unsupported = { providerSpecificData: {} };
assert.equal(
@@ -155,7 +157,7 @@ test("Codex global service tier only short-circuits on valid body service_tier",
assert.deepEqual(injected, {
providerSpecificData: { requestDefaults: { serviceTier: "priority" } },
});
assert.equal(invalidBody.service_tier, "invalid");
assert.equal(invalidBody.service_tier, "priority");
const validBody: Record<string, unknown> = { service_tier: " Flex " };
const unchanged = { providerSpecificData: {} };

View File

@@ -11,6 +11,7 @@ process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
const core = await import("../../src/lib/db/core.ts");
const databaseSettings = await import("../../src/lib/db/databaseSettings.ts");
const databaseSettingsRoute = await import("../../src/app/api/settings/database/route.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
const cleanup = await import("../../src/lib/db/cleanup.ts");
const aggregateHistory = await import("../../src/lib/usage/aggregateHistory.ts");
@@ -101,6 +102,23 @@ test("database settings reader supports legacy flat keys and lets nested saves w
assert.equal(databaseSettings.getUserDatabaseSettings().retention.callLogs, 7);
});
test("database log settings mirror the runtime pipeline toggle", async () => {
await settingsDb.updateSettings({ call_log_pipeline_enabled: false });
assert.equal(databaseSettings.getUserDatabaseSettings().logs.callLogPipelineEnabled, false);
databaseSettings.updateDatabaseSettings({
logs: {
...databaseSettings.getUserDatabaseSettings().logs,
callLogPipelineEnabled: true,
},
});
const settings = await settingsDb.getSettings();
assert.equal(settings.call_log_pipeline_enabled, true);
assert.equal(databaseSettings.getUserDatabaseSettings().logs.callLogPipelineEnabled, true);
});
test("purgeDetailedLogs deletes request_detail_logs", async () => {
const db = core.getDbInstance();
db.prepare("INSERT INTO request_detail_logs (id, timestamp, duration_ms) VALUES (?, ?, ?)").run(

View File

@@ -18,8 +18,10 @@ const providersModule = await import("../../src/lib/oauth/providers/index.ts");
const oauthModule = await import("../../src/lib/oauth/constants/oauth.ts");
const registryModule = await import("../../open-sse/config/providerRegistry.ts");
const antigravityHeadersModule = await import("../../open-sse/services/antigravityHeaders.ts");
const oauthHelpersModule = await import("../../src/lib/oauth/providers.ts");
const PROVIDERS = providersModule.default;
const { resolveBrowserOAuthRedirectUri } = oauthHelpersModule;
const {
ANTIGRAVITY_CONFIG,
CLAUDE_CONFIG,
@@ -316,6 +318,44 @@ test("browser-based providers expose buildAuthUrl and return provider-specific a
assert.equal(clineUrl.origin, "https://api.cline.bot");
});
test("custom Google OAuth credentials switch Antigravity remote callbacks to NEXT_PUBLIC_BASE_URL", () => {
const redirectUri = resolveBrowserOAuthRedirectUri(
"antigravity",
"http://localhost:20128/callback",
{
NEXT_PUBLIC_BASE_URL: "https://omniroute.example.com/",
ANTIGRAVITY_OAUTH_CLIENT_ID: "custom-antigravity.apps.googleusercontent.com",
}
);
assert.equal(redirectUri, "https://omniroute.example.com/callback");
});
test("custom Google OAuth credentials switch Gemini remote callbacks to OMNIROUTE_PUBLIC_BASE_URL", () => {
const redirectUri = resolveBrowserOAuthRedirectUri(
"gemini-cli",
"http://127.0.0.1:20128/callback",
{
OMNIROUTE_PUBLIC_BASE_URL: "https://omniroute.example.com",
GEMINI_CLI_OAUTH_CLIENT_ID: "custom-gemini.apps.googleusercontent.com",
}
);
assert.equal(redirectUri, "https://omniroute.example.com/callback");
});
test("Google OAuth callbacks stay on localhost when no custom credentials are configured", () => {
const redirectUri = resolveBrowserOAuthRedirectUri(
"antigravity",
"http://localhost:20128/callback",
{
NEXT_PUBLIC_BASE_URL: "https://omniroute.example.com",
}
);
assert.equal(redirectUri, "http://localhost:20128/callback");
});
test("device and import-token providers expose the flow-specific fields expected by their configs", () => {
const deviceProviders = ["qwen", "kimi-coding", "github", "kiro", "amazon-q", "kilocode"];

View File

@@ -608,6 +608,22 @@ test("getProviderCredentials retains rate-limited accounts when allowSuppressedC
assert.equal(bypassed.connectionId, connection.id);
});
test("getProviderCredentials retains rate-limited accounts when allowRateLimitedConnections is enabled", async () => {
const connection = await seedConnection("openai", {
name: "allow-rate-limit-option",
rateLimitedUntil: futureIso(),
});
const blocked = await auth.getProviderCredentials("openai");
const bypassed = await auth.getProviderCredentials("openai", null, null, null, {
allowRateLimitedConnections: true,
});
assert.equal(blocked.allRateLimited, true);
assert.equal(bypassed.connectionId, connection.id);
});
test("getProviderCredentials retains terminal accounts for combo live tests", async () => {
const connection = await seedConnection("openai", {
name: "suppressed-terminal",