Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ _Development cycle in progress — entries are added as work merges into `releas
- **fix(startup):** correct the #3292 auto-refresh daemon import (`@/open-sse/...` → `@omniroute/open-sse/services/autoRefreshDaemon`); the `@/` alias maps to `src/`, so the daemon silently never ran in the built standalone (non-fatal "Cannot find module", caught at runtime). Adds a regression test banning `@/open-sse/*` imports in `src/`. ([#3335](https://github.com/diegosouzapw/OmniRoute/pull/3335) — thanks @diegosouzapw)
- **fix(electron):** wrap `autoUpdater.checkForUpdates()` so a 404/offline/rate-limited update check can no longer surface as an unhandled rejection (the `error` event still notifies the user); fixes the macOS-intel packaged-app smoke failure. ([#3339](https://github.com/diegosouzapw/OmniRoute/pull/3339) — thanks @diegosouzapw)

### 📝 Maintenance

- **fix(review):** harden the per-provider custom-headers feature surfaced by the `/review-reviews` battery — `updateProviderNode` no longer wipes stored `custom_headers_json` on a partial update that omits the field; `customHeadersSchema` reuses the canonical `upstreamHeadersRecordSchema` guards (CRLF/control-char/length/16-max) and rejects auth header names via a single shared `isForbiddenCustomHeaderName()` denylist (executor + schema no longer keep divergent copies); custom headers now reach the wire for `anthropic-compatible-cc-*` nodes and override the executor's own `Content-Type`/`Accept` case-insensitively instead of duplicating them; and `rowToCamel` normalizes a NULL `_json` column to `baseKey: null`. (thanks @diegosouzapw)
- **fix(catalog):** flag every `minimax-m3` registry entry `supportsVision` (not just the opencode free tier) so the vision-bridge guardrail and the compression layer agree the model is multimodal on all tiers (completes #3328). (thanks @diegosouzapw)
- **fix(oauth):** Kiro Builder ID import forwards the requested `region` to the OIDC validation refresh (no longer pinned to `us-east-1`), prefers the region-matching cached SSO client registration over the first file found, and falls `expiresIn` back to 3600 on the OIDC path. (thanks @diegosouzapw)
- **fix(db):** migration `095` gains an `isSchemaAlreadyApplied` guard so a fresh DB (where `SCHEMA_SQL` already creates `custom_headers_json`) skips it cleanly instead of throwing-then-catching a duplicate-column error. (thanks @diegosouzapw)
- **test:** align stale cycle tests with shipped behavior — NVIDIA `minimaxai/minimax-m3` removal (#3329), the 29th feature flag (`PROXY_AUTO_SELECT_ENABLED`, #3332), and the OpenCode `~/.config` path on Windows (#3330). (thanks @diegosouzapw)

---

## [3.8.13] — 2026-06-06
Expand Down
31 changes: 20 additions & 11 deletions open-sse/config/providerRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1035,7 +1035,12 @@ const _REGISTRY_EAGER: Record<string, RegistryEntry> = {
{ id: "gpt-5-mini", name: "GPT-5 Mini", targetFormat: "openai-responses" },
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex", targetFormat: "openai-responses" },
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini", targetFormat: "openai-responses" },
{ id: "gpt-5.4", name: "GPT-5.4", targetFormat: "openai-responses", supportsXHighEffort: true },
{
id: "gpt-5.4",
name: "GPT-5.4",
targetFormat: "openai-responses",
supportsXHighEffort: true,
},
{ id: "gpt-5.5", name: "GPT-5.5", ...GPT_5_5_CODEX_CAPABILITIES },
{
id: "claude-haiku-4.5",
Expand Down Expand Up @@ -1224,7 +1229,7 @@ const _REGISTRY_EAGER: Record<string, RegistryEntry> = {
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
{ id: "gemini-3-flash-solo", name: "Gemini 3 Flash" },
// #3110: MiniMax M3 via Trae
{ id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576 },
{ id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true },
{ id: "minimax-m2.7", name: "MiniMax M2.7" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "gpt-5.4", name: "GPT 5.4" },
Expand Down Expand Up @@ -1460,7 +1465,13 @@ const _REGISTRY_EAGER: Record<string, RegistryEntry> = {
{ id: "mimo-v2-pro", name: "MiMo-V2-Pro" },
{ id: "mimo-v2-omni", name: "MiMo-V2-Omni" },
// #3110: MiniMax M3 via OpenCode Go tier
{ id: "minimax-m3", name: "MiniMax M3", targetFormat: "claude", contextLength: 1048576 },
{
id: "minimax-m3",
name: "MiniMax M3",
targetFormat: "claude",
contextLength: 1048576,
supportsVision: true,
},
{ id: "minimax-m2.7", name: "MiniMax M2.7", targetFormat: "claude" },
{ id: "minimax-m2.5", name: "MiniMax M2.5", targetFormat: "claude" },
// Issue #2292: Qwen models on opencode-go reject oa-compat format
Expand Down Expand Up @@ -1546,7 +1557,7 @@ const _REGISTRY_EAGER: Record<string, RegistryEntry> = {

// ── MiniMax ────────────────────────────────────────────────
// #3110: MiniMax M3 — frontier coding model with 1M context
{ id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576 },
{ id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true },
{ id: "minimax-m2.5", name: "MiniMax M2.5" },
{ id: "minimax-m2.7", name: "MiniMax M2.7" },

Expand Down Expand Up @@ -2229,7 +2240,7 @@ const _REGISTRY_EAGER: Record<string, RegistryEntry> = {
models: [
// T12/T28: MiniMax default upgraded from M2.5 to M2.7
// #3110: MiniMax M3 — frontier coding model with 1M context
{ id: "MiniMax-M3", name: "MiniMax M3", contextLength: 1048576 },
{ id: "MiniMax-M3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true },
{ id: "MiniMax-M2.7", name: "MiniMax M2.7" },
{ id: "MiniMax-M2.7-highspeed", name: "MiniMax M2.7 Highspeed" },
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
Expand All @@ -2252,7 +2263,7 @@ const _REGISTRY_EAGER: Record<string, RegistryEntry> = {
models: [
// Keep parity with minimax to ensure model discovery works for minimax-cn connections.
// #3110: MiniMax M3 — frontier coding model with 1M context
{ id: "MiniMax-M3", name: "MiniMax M3", contextLength: 1048576 },
{ id: "MiniMax-M3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true },
{ id: "MiniMax-M2.7", name: "MiniMax M2.7" },
{ id: "MiniMax-M2.7-highspeed", name: "MiniMax M2.7 Highspeed" },
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
Expand Down Expand Up @@ -2711,7 +2722,7 @@ const _REGISTRY_EAGER: Record<string, RegistryEntry> = {
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro" },
{ id: "mimo-v2.5", name: "MiMo-V2.5" },
// #3110: MiniMax M3 via OpenCode Zen
{ id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576 },
{ id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true },
{ id: "minimax-m2.7", name: "MiniMax M2.7" },
{ id: "minimax-m2.5", name: "MiniMax M2.5" },
{ id: "llama-4-maverick", name: "Llama 4 Maverick" },
Expand Down Expand Up @@ -3184,7 +3195,7 @@ const _REGISTRY_EAGER: Record<string, RegistryEntry> = {
{ id: "kimi-k2.6", name: "Kimi K2.6" },
{ id: "glm-5.1", name: "GLM 5.1" },
// #3110: MiniMax M3 via Ollama
{ id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576 },
{ id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true },
{ id: "minimax-m2.7", name: "MiniMax M2.7" },
{ id: "gemma4:31b", name: "Gemma 4 31B" },
{ id: "nemotron-3-super", name: "NVIDIA Nemotron 3 Super" },
Expand Down Expand Up @@ -4338,9 +4349,7 @@ const _REGISTRY_EAGER: Record<string, RegistryEntry> = {
baseUrls: ["https://amelia.chipotle.com"],
authType: "none",
authHeader: "none",
models: [
{ id: "pepper-1", name: "Pepper (Chipotle AI 🌯)" },
],
models: [{ id: "pepper-1", name: "Pepper (Chipotle AI 🌯)" }],
passthroughModels: true,
},
};
Expand Down
75 changes: 49 additions & 26 deletions open-sse/executors/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,52 @@ import { buildOciChatUrl } from "../config/oci.ts";
import { buildSapChatUrl, getSapResourceGroup } from "../config/sap.ts";
import { buildMaritalkChatUrl } from "../config/maritalk.ts";
import { LOCAL_PROVIDERS } from "@/shared/constants/providers";
import { isForbiddenCustomHeaderName } from "@/shared/constants/upstreamHeaders";

import type { PoolConfig } from "../services/sessionPool/types.ts";

/**
* Apply operator-configured per-provider custom headers onto an outgoing header
* map. Defense-in-depth on top of the Zod `customHeadersSchema`:
* - skip hop-by-hop/framing AND auth header names (canonical denylist, so a row
* written before the schema tightening still can't override credential auth);
* - skip control-char (CR/LF/NUL) names/values before they reach undici;
* - assign case-insensitively, replacing any existing same-named header (e.g.
* the executor's own Content-Type/Accept) instead of emitting a duplicate.
* Used for every *-compatible node, INCLUDING anthropic-compatible-cc-* (whose
* header builder returns early, so custom headers must be merged in explicitly).
*/
function applyCustomHeaders(headers: Record<string, string>, rawCustomHeaders: unknown): void {
let customHeaders: Record<string, unknown> | null = null;
if (
rawCustomHeaders &&
typeof rawCustomHeaders === "object" &&
!Array.isArray(rawCustomHeaders)
) {
customHeaders = rawCustomHeaders as Record<string, unknown>;
} else if (typeof rawCustomHeaders === "string") {
try {
const parsed = JSON.parse(rawCustomHeaders);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
customHeaders = parsed as Record<string, unknown>;
}
} catch {
/* ignore invalid JSON */
}
}
if (!customHeaders) return;
for (const [k, v] of Object.entries(customHeaders)) {
if (typeof k !== "string" || typeof v !== "string") continue;
if (isForbiddenCustomHeaderName(k)) continue;
if (/[\r\n\0]/.test(k) || /[\r\n]/.test(v)) continue;
const lower = k.toLowerCase();
for (const existing of Object.keys(headers)) {
if (existing.toLowerCase() === lower) delete headers[existing];
}
headers[k] = v;
}
}

function normalizeBaseUrl(baseUrl) {
return (baseUrl || "").trim().replace(/\/$/, "");
}
Expand Down Expand Up @@ -375,11 +418,15 @@ export class DefaultExecutor extends BaseExecutor {
break;
default:
if (isClaudeCodeCompatible(this.provider)) {
return buildClaudeCodeCompatibleHeaders(
const ccHeaders = buildClaudeCodeCompatibleHeaders(
effectiveKey || credentials.accessToken || "",
stream,
credentials?.providerSpecificData?.ccSessionId
);
// CC nodes are also anthropic-compatible-*, so honor operator custom
// headers here (the early return skips the shared block below).
applyCustomHeaders(ccHeaders, credentials.providerSpecificData?.customHeaders);
return ccHeaders;
}
if (this.provider?.startsWith?.("anthropic-compatible-")) {
if (effectiveKey) {
Expand Down Expand Up @@ -424,31 +471,7 @@ export class DefaultExecutor extends BaseExecutor {
this.provider?.startsWith?.("anthropic-compatible-");

if (isCompatibleProvider) {
const rawCustomHeaders = credentials.providerSpecificData?.customHeaders;
let customHeaders: Record<string, string> | null = null;
if (rawCustomHeaders && typeof rawCustomHeaders === "object" && !Array.isArray(rawCustomHeaders)) {
customHeaders = rawCustomHeaders as Record<string, string>;
} else if (typeof rawCustomHeaders === "string") {
try {
const parsed = JSON.parse(rawCustomHeaders);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
customHeaders = parsed;
}
} catch { /* ignore invalid JSON */ }
}
if (customHeaders) {
const forbidden = new Set([
"host", "connection", "content-length", "keep-alive",
"proxy-connection", "transfer-encoding", "te", "trailer", "upgrade",
]);
const authHeaders = new Set(["authorization", "x-api-key", "x-goog-api-key", "api-key"]);
for (const [k, v] of Object.entries(customHeaders)) {
if (typeof k !== "string" || typeof v !== "string") continue;
if (forbidden.has(k.toLowerCase())) continue;
if (authHeaders.has(k.toLowerCase())) continue;
headers[k] = v;
}
}
applyCustomHeaders(headers, credentials.providerSpecificData?.customHeaders);
}

// Forward client request metadata headers (from OpenCode or similar clients)
Expand Down
18 changes: 12 additions & 6 deletions src/lib/db/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,15 +463,21 @@ export function rowToCamel(row: unknown): JsonRecord | null {
} catch {
result[camelKey] = v;
}
} else if (camelKey.endsWith("Json") && typeof v === "string") {
} else if (camelKey.endsWith("Json")) {
// Convention: any column with a `_json` suffix is JSON-encoded TEXT.
// Surface the parsed object under the friendlier name (key minus the
// "Json" suffix) — e.g. quotaWindowThresholdsJson → quotaWindowThresholds.
// A NULL/absent column normalizes to `baseKey: null` (not the suffixed
// key) so read and write paths expose a consistent shape.
const baseKey = camelKey.slice(0, -"Json".length);
try {
result[baseKey] = JSON.parse(v);
} catch {
result[baseKey] = null;
if (typeof v === "string") {
try {
result[baseKey] = JSON.parse(v);
} catch {
result[baseKey] = null;
}
} else {
result[baseKey] = v == null ? null : v;
}
} else {
result[camelKey] = v;
Expand Down Expand Up @@ -1273,7 +1279,7 @@ export function getDbInstance(): SqliteDatabase {
) {
throw e;
}
preservedCriticalState = captureCriticalDbState(sqliteFile);
preservedCriticalState = captureCriticalDbState(sqliteFile);

// SAFETY: Never delete the database — rename to backup so data can be recovered.
// The old code would silently destroy all user data on any probe failure.
Expand Down
10 changes: 8 additions & 2 deletions src/lib/db/migrationRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,8 @@ function isSchemaAlreadyApplied(
switch (migration.version) {
case "003":
return hasColumn(db, "provider_nodes", "chat_path");
case "095":
return hasColumn(db, "provider_nodes", "custom_headers_json");
case "005":
return hasColumn(db, "combos", "system_message");
case "007":
Expand Down Expand Up @@ -910,7 +912,9 @@ export function runMigrations(db: SqliteAdapter, options?: { isNewDb?: boolean }
}
return isMissing;
});
const deferredUnsupported = pending.filter((migration) => isDeferredUnsupportedMigration(db, migration));
const deferredUnsupported = pending.filter((migration) =>
isDeferredUnsupportedMigration(db, migration)
);
const actionablePending = pending.filter(
(migration) => !deferredUnsupported.some((deferred) => deferred.version === migration.version)
);
Expand All @@ -920,7 +924,9 @@ export function runMigrations(db: SqliteAdapter, options?: { isNewDb?: boolean }
}

if (deferredUnsupported.length > 0) {
const summary = deferredUnsupported.map((migration) => `${migration.version}_${migration.name}`).join(", ");
const summary = deferredUnsupported
.map((migration) => `${migration.version}_${migration.name}`)
.join(", ");
console.warn(
`[Migration] Deferring optional FTS5 migrations on driver ${db.driver}: ${summary}. ` +
`Memory search will fall back until a SQLite driver with FTS5 support is available.`
Expand Down
12 changes: 9 additions & 3 deletions src/lib/db/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,9 +372,7 @@ export async function createProviderConnection(data: JsonRecord) {
// Same sanitization for rateLimitOverrides — keep in-memory representation
// in sync with what gets persisted.
if ("rateLimitOverrides" in connection) {
connection.rateLimitOverrides = sanitizeRateLimitOverrides(
connection.rateLimitOverrides
);
connection.rateLimitOverrides = sanitizeRateLimitOverrides(connection.rateLimitOverrides);
}

_insertConnectionRow(db, encryptConnectionFields({ ...connection }));
Expand Down Expand Up @@ -835,6 +833,14 @@ export async function updateProviderNode(id: string, data: JsonRecord) {

if (data.customHeaders !== undefined) {
merged["customHeadersJson"] = data.customHeaders ? JSON.stringify(data.customHeaders) : null;
} else {
// Partial update that omits customHeaders must PRESERVE the stored value.
// rowToCamel surfaces the column under `customHeaders` (suffix stripped),
// never `customHeadersJson`, so read the raw stored JSON from `existing`
// directly instead of relying on the (absent) merged key — otherwise the
// UPDATE would bind null and silently wipe the saved headers.
const existingJson = (existing as JsonRecord).custom_headers_json;
merged["customHeadersJson"] = typeof existingJson === "string" ? existingJson : null;
}

db.prepare(
Expand Down
Loading