Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral
- **fix(startup):** an update/restart could crash the whole server at boot with `TypeError: Cannot create property 'message' on string 'Database closed'`, masking the real failure and 500-ing every request until manually restarted ([#6560](https://github.com/diegosouzapw/OmniRoute/issues/6560), plausibly the root cause of #6594's post-upgrade 500) — `driverFactory.ts::preInitSqlJs()` cached its sql.js WASM adapter per file path in a `globalThis`-backed map for idempotency, but never checked whether the cached adapter had since been closed (e.g. by `gracefulShutdown`/`resetDbInstance` racing a reload); reusing that dead handle made the very next query throw sql.js's own bare string `"Database closed"` (not an `Error`) straight out of `instrumentation-node.ts`'s previously-unguarded `ensureDbInitialized()` call, and Next.js's internal `registerInstrumentation()` wrapper unconditionally does `err.message = ...` on whatever `register()` rejects with — assigning `.message` on a primitive string throws in strict mode, so the secondary `TypeError` is what actually crashed the process. Fixed in two parts: `preInitSqlJs()` now evicts a closed cached adapter and creates a fresh one instead of returning it; a new `ensureDbReadyForBoot()` wraps the DB-init call, normalizes any non-Error throw via `normalizeBootError()`, and retries once specifically for a transient "database closed" message (now succeeding against the fresh adapter) before re-throwing anything else as a real `Error`. Regression guard: `tests/unit/instrumentation-database-closed-6560.test.ts`.
- **fix(api):** `POST /api/keys` no longer hangs 20–90+ seconds on a fresh install, even with valid auth ([#6570](https://github.com/diegosouzapw/OmniRoute/issues/6570)) — `cloudEnabled` defaults to `true` in `src/lib/db/settings.ts::getSettings()` on any install with no persisted settings row (i.e. every fresh install), so the create-key handler's unconditional `await syncKeysToCloudIfEnabled()` always attempted a real outbound `fetch()` to `CLOUD_URL` via `syncToCloud()`; when that endpoint is unset/unreachable/slow, the HTTP response blocked until the request settled or timed out — unlike sibling routes (`POST /api/keys/:id/regenerate`, `GET /api/combos`), which never touch this side effect at all. `src/app/api/keys/route.ts` now dispatches `syncKeysToCloudIfEnabled()` fire-and-forget (`void`) instead of awaiting it; its internal try/catch already logs failures, so cloud sync still runs, it just no longer blocks the response. Regression guard: `tests/unit/api-keys-create-no-hang-6570.test.ts` (asserts the route resolves in well under 2s even when the Cloud-sync `fetch()` is stubbed to never settle) + updated `tests/integration/api-keys.test.ts` (the pre-existing "triggers cloud sync"/"still succeeds when cloud sync fails" tests, which previously hung indefinitely on this exact path, now await the fire-and-forget sync tick before asserting).
- **fix(api):** editing any existing OpenAI Codex provider connection in the dashboard returned "Invalid request" and the edit could never be saved ([#6562](https://github.com/diegosouzapw/OmniRoute/issues/6562)) — `createProviderConnection()` (`src/lib/db/providers.ts`) auto-increments a new connection's `priority` to `MAX(priority)+1` per provider with no upper bound, and OAuth-imported connections (Codex `codex-auth/import` / `import-bulk`, up to 50 accounts per call, callable repeatedly — the standard Codex bulk-account-rotation workflow) never pass through `createProviderSchema`'s Zod validation at all, so nothing ever capped that value; `EditConnectionModal`'s `handleSubmit` always resends the connection's current `priority` unchanged on every save, and `updateProviderConnectionSchema` capped `priority`/`globalPriority` at `max(100)` — a UI-only ceiling the create path never enforced — so the first edit of any connection whose priority had already grown past 100 (routine once a Codex account count exceeds 100) failed validation regardless of which field the user actually changed. Raised the ceiling to `max(100_000)` on both fields — still bounded (a genuinely out-of-range value is still rejected), just wide enough to accept priorities the app itself already produces. Regression guard: `tests/unit/codex-connection-edit-6562.test.ts` (a Codex OAuth connection whose priority already exceeds the old 100 cap now validates + persists on edit; a control payload with a still-genuinely-invalid priority is still rejected with "Invalid request").
- **mimocode**: rotate accounts on MiMoCode's rate-limit-style 400s (body-classified) instead of failing on the first account; malformed 400s still fail fast with the real upstream error (#6648 — thanks @pizzav-xyz)

### 📝 Maintenance

Expand Down
182 changes: 139 additions & 43 deletions open-sse/executors/mimocode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,20 @@
*
* Only the "mimo-auto" model is supported (1M context, 128K output).
* Supports multiple accounts: N fingerprints → N JWTs → round-robin with cooldown.
* On 429, account enters cooldown (exponential backoff). On 401/403, JWT is re-bootstrapped.
* On 429 — or a 400 carrying MiMoCode's rate-limit text — account enters cooldown
* (exponential backoff) and the next account is tried. On 401/403, JWT is
* re-bootstrapped. Any other 400 is a genuinely malformed request (#2101): it fails
* fast on the current account instead of being retried identically on every
* account, which would waste N round-trips, cooldown every account, and hide the
* real upstream diagnostic behind a generic "all accounts exhausted" error (#4976).
*/

import * as crypto from "node:crypto";
import * as os from "node:os";
import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts";
import { createProxyDispatcher } from "../utils/proxyDispatcher.ts";
import { RATE_LIMIT_TEXT_PATTERNS } from "../services/accountFallback.ts";
import { buildErrorBody, sanitizeErrorMessage } from "../utils/error.ts";
import { fetch as undiciFetch, type Dispatcher } from "undici";

const BOOTSTRAP_PATH = "/api/free-ai/bootstrap";
Expand Down Expand Up @@ -350,6 +357,127 @@ export class MimocodeExecutor extends BaseExecutor {
account.consecutiveFails = 0;
}

/**
* POST the request with the account's JWT; on auth failure (401/403), re-bootstrap
* the account's JWT and retry once. Mutates `headers`' Authorization in place.
*/
private async fetchWithAuthRetry(
url: string,
headers: Record<string, string>,
reqBody: unknown,
signal: AbortSignal | null | undefined,
account: AccountState,
log: ExecuteInput["log"]
): Promise<Response> {
const jwt = await this.getJwtForAccount(account, signal);
headers["Authorization"] = `Bearer ${jwt}`;

const resp = await this.fetchWithProxy(
url,
{
method: "POST",
headers,
body: JSON.stringify(reqBody),
signal: signal ?? undefined,
},
account.fingerprint
);
if (resp.status !== 401 && resp.status !== 403) return resp;

// On auth failure, re-bootstrap this account and retry once
log?.warn?.(
"MIMOCODE",
`Auth failed (${resp.status}) on account ${account.fingerprint.slice(0, 8)}…`
);
account.jwt = "";
account.expiresAt = 0;
account.consecutiveFails = 0;
const freshJwt = await this.getJwtForAccount(account, signal);
headers["Authorization"] = `Bearer ${freshJwt}`;
return this.fetchWithProxy(
url,
{
method: "POST",
headers,
body: JSON.stringify(reqBody),
signal: signal ?? undefined,
},
account.fingerprint
);
}

/**
* Gate 429/400 statuses before the success path: a 429 — or a 400 carrying
* MiMoCode's rate-limit text — puts the account on cooldown and rotates; any other
* 400 fails fast with the sanitized upstream error (#2101/#4976, see
* handleBadRequest). Returns "rotate", a fail-fast Response, or null to proceed.
*/
private async gateRetryableStatus(
resp: Response,
account: AccountState,
log: ExecuteInput["log"]
): Promise<"rotate" | Response | null> {
if (resp.status === 429) {
this.markCooldown(account);
log?.warn?.(
"MIMOCODE",
`Rate limited on account ${account.fingerprint.slice(0, 8)}, trying next…`
);
return "rotate";
}
if (resp.status !== 400) return null;
return (await this.handleBadRequest(resp, account, log)) ?? "rotate";
}

/**
* Classify a 400 response body (#2101/#4976).
*
* #4976: MiMoCode signals throttling via a non-standard 400 whose body carries
* rate-limit semantics (e.g. "Detected high-frequency non-compliant requests from
* you.") instead of a 429 — same RATE_LIMIT_TEXT_PATTERNS as accountFallback.ts's
* checkFallbackError(), so the two call sites never disagree on what counts as
* throttling. That case puts the account on cooldown and returns `null` (rotate).
*
* #2101: any other 400 is a genuinely malformed request that fails identically on
* every account — rotating would waste N round-trips, cooldown every account (a
* provider-wide outage for parallel requests), and hide the real diagnostic behind
* a generic exhaustion error. That case returns a fail-fast 400 Response carrying
* the sanitized upstream message, without touching cooldown/success state.
*/
private async handleBadRequest(
resp: Response,
account: AccountState,
log: ExecuteInput["log"]
): Promise<Response | null> {
const bodyText = await resp.text().catch(() => "");

if (RATE_LIMIT_TEXT_PATTERNS.some((p) => p.test(bodyText))) {
this.markCooldown(account);
log?.warn?.(
"MIMOCODE",
`Rate-limit-style 400 on account ${account.fingerprint.slice(0, 8)}, trying next…`
);
return null;
}

log?.warn?.(
"MIMOCODE",
`Malformed request (400) on account ${account.fingerprint.slice(0, 8)}, not retrying`
);
let upstreamMessage = bodyText;
try {
const parsed = JSON.parse(bodyText) as { error?: { message?: string } };
if (parsed?.error?.message) upstreamMessage = parsed.error.message;
} catch {
/* body wasn't JSON — use raw text */
}
const errorBody = buildErrorBody(400, sanitizeErrorMessage(upstreamMessage || "Bad request"));
return new Response(MimocodeExecutor.encoder.encode(JSON.stringify(errorBody)), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}

buildUrl(
_model: string,
_stream: boolean,
Expand Down Expand Up @@ -457,51 +585,19 @@ export class MimocodeExecutor extends BaseExecutor {
for (let attempt = 0; attempt < this.accounts.length; attempt++) {
const account = this.pickAccount();
try {
const jwt = await this.getJwtForAccount(account, signal);
const headers = this.buildHeaders(input.credentials, stream);
headers["Authorization"] = `Bearer ${jwt}`;
const resp = await this.fetchWithAuthRetry(url, headers, reqBody, signal, account, log);

let resp = await this.fetchWithProxy(
url,
{
method: "POST",
headers,
body: JSON.stringify(reqBody),
signal: signal ?? undefined,
},
account.fingerprint
);

// On auth failure, re-bootstrap this account and retry once
if (resp.status === 401 || resp.status === 403) {
log?.warn?.(
"MIMOCODE",
`Auth failed (${resp.status}) on account ${account.fingerprint.slice(0, 8)}…`
);
account.jwt = "";
account.expiresAt = 0;
account.consecutiveFails = 0;
const freshJwt = await this.getJwtForAccount(account, signal);
headers["Authorization"] = `Bearer ${freshJwt}`;
resp = await this.fetchWithProxy(
// 429/400 gating (#2101/#4976): cooldown+rotate, fail fast, or proceed.
const gate = await this.gateRetryableStatus(resp, account, log);
if (gate === "rotate") continue;
if (gate) {
return {
response: gate,
url,
{
method: "POST",
headers,
body: JSON.stringify(reqBody),
signal: signal ?? undefined,
},
account.fingerprint
);
}

if (resp.status === 429) {
this.markCooldown(account);
log?.warn?.(
"MIMOCODE",
`Rate limited on account ${account.fingerprint.slice(0, 8)}, trying next…`
);
continue;
headers: this.buildHeaders(input.credentials, stream),
transformedBody: reqBody,
};
}

this.markSuccess(account);
Expand Down
4 changes: 2 additions & 2 deletions open-sse/services/accountFallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,8 @@ const MALFORMED_REQUEST_PATTERNS = [
// non-standard 400 status whose body carries rate-limit semantics instead of a 429
// (#4976). When detected, the request is fallback-worthy at connection-cooldown scope
// (NOT a whole-provider breaker) so combo routing can fail over to another free target.
// Bounded, non-overlapping patterns only (ReDoS-safe — no nested quantifiers).
const RATE_LIMIT_TEXT_PATTERNS = [
// Exported: mimocode.ts's executor reuses this list directly (single source of truth).
export const RATE_LIMIT_TEXT_PATTERNS = [
/high.?frequency/i,
/non-compliant/i,
/too many requests/i,
Expand Down
140 changes: 140 additions & 0 deletions tests/unit/mimocode-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,3 +485,143 @@ describe("mimocode per-account proxy", () => {
assert.strictEqual((testExec as any).proxyUrlMap.get(fp), "socks5://second.proxy:1080");
});
});

// #2101/#4976 regression guard: a 400 from MiMoCode must be classified by body text
// before deciding whether to rotate accounts. A rate-limit-style 400 (throttling
// disguised as a 400, #4976) is rotation-worthy; a genuinely malformed 400 (#2101)
// must fail fast on the FIRST account instead of being retried identically on every
// account (which would waste N round-trips, cooldown every account, and hide the
// real upstream diagnostic behind a generic "all accounts exhausted" error).
interface TestAccountState {
fingerprint: string;
jwt: string;
expiresAt: number;
cooldownUntil: number;
consecutiveFails: number;
}

interface ExecutorAccountAccess {
accounts: TestAccountState[];
nextAccountIdx: number;
}

function accountAccess(exec: MimocodeExecutor): ExecutorAccountAccess {
return exec as unknown as ExecutorAccountAccess;
}

describe("mimocode 400 classification (#2101/#4976)", () => {
function makeJwt(): string {
const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url");
const payload = Buffer.from(
JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 })
).toString("base64url");
return `${header}.${payload}.sig`;
}

function twoAccountExecutor(): MimocodeExecutor {
const exec = new MimocodeExecutor();
const access = accountAccess(exec);
access.accounts = [
{ fingerprint: "acct-a", jwt: "", expiresAt: 0, cooldownUntil: 0, consecutiveFails: 0 },
{ fingerprint: "acct-b", jwt: "", expiresAt: 0, cooldownUntil: 0, consecutiveFails: 0 },
];
access.nextAccountIdx = 0;
return exec;
}

it("rotates to the next account on a rate-limit-text 400 (#4976)", async () => {
const testExec = twoAccountExecutor();
let chatCalls = 0;
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: unknown) => {
const urlStr = String(url);
if (urlStr.includes("/api/free-ai/bootstrap")) {
return new Response(JSON.stringify({ jwt: makeJwt() }), { status: 200 });
}
if (urlStr.includes("/api/free-ai/openai/chat")) {
chatCalls++;
if (chatCalls === 1) {
// MiMoCode's non-standard rate-limit signal on a 400 status (#4976).
return new Response(
JSON.stringify({
error: { message: "Detected high-frequency non-compliant requests from you." },
}),
{ status: 400 }
);
}
return new Response(JSON.stringify({ id: "ok", choices: [] }), { status: 200 });
}
throw new Error(`unexpected fetch: ${urlStr}`);
}) as typeof fetch;

try {
const result = await testExec.execute({
model: "mimo-auto",
body: { messages: [{ role: "user", content: "hi" }], stream: false },
stream: false,
signal: null,
credentials: {},
log: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
});

assert.strictEqual(chatCalls, 2, "should retry on the next account after the 400");
assert.strictEqual(result.response.status, 200);
const acctA = accountAccess(testExec).accounts[0];
assert.ok(acctA.cooldownUntil > Date.now(), "first account should be in cooldown");
assert.strictEqual(acctA.consecutiveFails, 1);
} finally {
globalThis.fetch = originalFetch;
}
});

it("fails fast without rotating on a malformed/generic 400 (#2101)", async () => {
const testExec = twoAccountExecutor();
let chatCalls = 0;
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: unknown) => {
const urlStr = String(url);
if (urlStr.includes("/api/free-ai/bootstrap")) {
return new Response(JSON.stringify({ jwt: makeJwt() }), { status: 200 });
}
if (urlStr.includes("/api/free-ai/openai/chat")) {
chatCalls++;
return new Response(
JSON.stringify({ error: { message: "Invalid field: foo is not a recognized field" } }),
{ status: 400 }
);
}
throw new Error(`unexpected fetch: ${urlStr}`);
}) as typeof fetch;

try {
const result = await testExec.execute({
model: "mimo-auto",
body: { messages: [{ role: "user", content: "hi" }], stream: false },
stream: false,
signal: null,
credentials: {},
log: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
});

assert.strictEqual(chatCalls, 1, "must NOT rotate to another account on a malformed 400");
const acctA = accountAccess(testExec).accounts[0];
assert.strictEqual(acctA.cooldownUntil, 0, "malformed 400 must not trigger cooldown");
assert.strictEqual(acctA.consecutiveFails, 0);

const response = result.response;
assert.strictEqual(response.status, 400);
const parsed = (await response.json()) as { error: { message: string; code?: string } };
assert.notStrictEqual(
parsed.error.code,
"NO_ACCOUNTS",
"must surface the real upstream 400, not a generic exhaustion error"
);
assert.ok(
parsed.error.message.toLowerCase().includes("invalid field"),
`expected the real upstream diagnostic in the error message, got: ${parsed.error.message}`
);
} finally {
globalThis.fetch = originalFetch;
}
});
});
Loading