fix: resolve 5 bugs (#1712 #1719 #1707 #1706 #1704)

- #1712: Strip existing billing headers before injecting to fix prompt cache misses
- #1719: Strip output_config.format for non-Anthropic Claude endpoints
- #1707: Set terminal error state on quality validation failure (false ALL_ACCOUNTS_INACTIVE)
- #1706: Wrap proxy_assignments queries in try-catch for missing table on Electron
- #1704: Fix Windows file URL path resolution in migration runner with cwd fallback
This commit is contained in:
diegosouzapw
2026-04-28 07:43:40 -03:00
parent dc103318ab
commit edd6941de4
8 changed files with 205 additions and 115 deletions

View File

@@ -4,6 +4,18 @@
---
## [3.7.3] — 2026-04-28
### 🐛 Bug Fixes
- **fix(claude):** strip existing billing headers from system array before injecting to prevent Anthropic prompt cache misses — stacked `x-anthropic-billing-header` blocks invalidated prefix matching, causing ~100% cache_create instead of cache_read (#1712)
- **fix(claude):** strip `output_config.format` for non-Anthropic Claude-compatible providers during passthrough — third-party Claude endpoints (MiniMax, DeepSeek via aggregators) reject structured output fields with 400 errors (#1719)
- **fix(combo):** set terminal error state on response quality validation failure — prevents misleading `ALL_ACCOUNTS_INACTIVE` 503 when the real issue is response quality validation (#1707)
- **fix(proxy):** wrap proxy assignment queries in try-catch for missing `proxy_assignments` table — Electron installs where migration 004 hasn't run no longer crash with `no such table` error (#1706)
- **fix(migration):** improve Windows file URL path resolution in migration runner — adds direct URL path extraction and `process.cwd()` fallback for CI-built bundles with leaked build-time paths (#1704)
---
## [3.7.2] — 2026-04-28
### ✨ New Features

View File

@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 3.7.2
version: 3.7.3
description: |
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
endpoint that routes requests to multiple AI providers with load balancing,

View File

@@ -482,6 +482,18 @@ export class BaseExecutor {
if (Array.isArray(tb.system)) {
const sysBlocks = tb.system as Array<Record<string, unknown>>;
// Fix #1712: Remove any existing billing headers from the client
// to prevent stacking that breaks Anthropic prompt cache prefix matching.
for (let i = sysBlocks.length - 1; i >= 0; i--) {
const block = sysBlocks[i];
if (
block &&
typeof block.text === "string" &&
block.text.startsWith("x-anthropic-billing-header:")
) {
sysBlocks.splice(i, 1);
}
}
const firstSystemCacheControl =
sysBlocks[0] &&
typeof sysBlocks[0] === "object" &&

View File

@@ -1819,6 +1819,21 @@ export async function handleChatCore({
normalizeClaudeUpstreamMessages(translatedBody, { preserveToolResultBlocks: true });
log?.debug?.("FORMAT", `claude passthrough (preserveCache=${preserveCacheControl})`);
// Fix #1719: Strip output_config.format for non-Anthropic Claude-compatible providers.
// Third-party Claude endpoints (MiniMax, DeepSeek via aggregators) reject this field
// with 400 errors since they don't support Anthropic's structured output / json_schema.
if (
provider !== "claude" &&
translatedBody.output_config &&
typeof translatedBody.output_config === "object"
) {
const oc = translatedBody.output_config as Record<string, unknown>;
delete oc.format;
if (Object.keys(oc).length === 0) {
delete translatedBody.output_config;
}
}
} else {
translatedBody = { ...body };

View File

@@ -1586,6 +1586,10 @@ export async function handleComboChat({
target: toRecordedTarget(target),
});
recordedAttempts++;
// Fix #1707: Set terminal state so the fallback doesn't emit
// misleading ALL_ACCOUNTS_INACTIVE when the real issue is quality.
lastError = `Upstream response failed quality validation: ${quality.reason}`;
if (!lastStatus) lastStatus = 502;
if (i > 0) fallbackCount++;
break; // move to next model
}
@@ -1967,6 +1971,10 @@ async function handleRoundRobinCombo({
target: toRecordedTarget(target),
});
recordedAttempts++;
// Fix #1707: Set terminal state so the fallback doesn't emit
// misleading ALL_ACCOUNTS_INACTIVE when the real issue is quality.
lastError = `Upstream response failed quality validation: ${quality.reason}`;
if (!lastStatus) lastStatus = 502;
if (offset > 0) fallbackCount++;
break; // move to next model
}

View File

@@ -33,12 +33,33 @@ function resolveMigrationsDir(): string {
try {
return path.join(path.dirname(fileURLToPath(import.meta.url)), "migrations");
} catch {
// Fall through to a more defensive URL parse below.
// Fall through to more defensive URL parsing below.
}
// Fix #1704: On Windows with global npm installs, import.meta.url may contain
// CI build-time paths (e.g., /home/runner/work/...) that are not valid file://
// URLs on Windows. Extract the path portion directly and normalize it.
const metaUrl = import.meta.url;
if (typeof metaUrl === "string" && metaUrl.startsWith("file://")) {
return path.join(path.dirname(fileURLToPath(metaUrl)), "migrations");
try {
// Strip the file:// prefix and decode, then normalize for the platform
const rawPath = decodeURIComponent(
metaUrl.replace(/^file:\/\/\//, "/").replace(/^file:\/\//, "")
);
return path.join(path.dirname(path.resolve(rawPath)), "migrations");
} catch {
// Fall through to process.cwd fallback
}
}
// Last resort: use process.cwd to find migrations relative to the app root
const cwdFallback = path.join(process.cwd(), "src", "lib", "db", "migrations");
if (fs.existsSync(cwdFallback)) {
return cwdFallback;
}
const appFallback = path.join(process.cwd(), "app", "src", "lib", "db", "migrations");
if (fs.existsSync(appFallback)) {
return appFallback;
}
throw new Error(

View File

@@ -240,32 +240,40 @@ export async function updateProxy(id: string, payload: Partial<ProxyPayload>) {
}
export async function getProxyAssignments(filters?: { proxyId?: string; scope?: string }) {
const db = getDbInstance();
try {
const db = getDbInstance();
if (filters?.proxyId) {
return db
.prepare(
"SELECT id, proxy_id, scope, scope_id, created_at, updated_at FROM proxy_assignments WHERE proxy_id = ? ORDER BY scope, scope_id"
)
.all(filters.proxyId)
.map(mapAssignmentRow);
}
if (filters?.scope) {
return db
.prepare(
"SELECT id, proxy_id, scope, scope_id, created_at, updated_at FROM proxy_assignments WHERE scope = ? ORDER BY scope_id"
)
.all(normalizeScope(filters.scope))
.map(mapAssignmentRow);
}
if (filters?.proxyId) {
return db
.prepare(
"SELECT id, proxy_id, scope, scope_id, created_at, updated_at FROM proxy_assignments WHERE proxy_id = ? ORDER BY scope, scope_id"
"SELECT id, proxy_id, scope, scope_id, created_at, updated_at FROM proxy_assignments ORDER BY scope, scope_id"
)
.all(filters.proxyId)
.all()
.map(mapAssignmentRow);
} catch (error: unknown) {
// Fix #1706: Gracefully handle missing proxy_assignments table on fresh
// Electron installs where migration 004 hasn't run yet.
const msg = error instanceof Error ? error.message : String(error);
if (msg.includes("no such table")) return [];
throw error;
}
if (filters?.scope) {
return db
.prepare(
"SELECT id, proxy_id, scope, scope_id, created_at, updated_at FROM proxy_assignments WHERE scope = ? ORDER BY scope_id"
)
.all(normalizeScope(filters.scope))
.map(mapAssignmentRow);
}
return db
.prepare(
"SELECT id, proxy_id, scope, scope_id, created_at, updated_at FROM proxy_assignments ORDER BY scope, scope_id"
)
.all()
.map(mapAssignmentRow);
}
export async function getProxyWhereUsed(proxyId: string) {
@@ -353,41 +361,16 @@ export async function deleteProxyById(id: string, options?: { force?: boolean })
}
export async function resolveProxyForConnectionFromRegistry(connectionId: string) {
const db = getDbInstance();
try {
const db = getDbInstance();
const accountAssignment = db
.prepare(
"SELECT p.id, p.type, p.host, p.port, p.username, p.password FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'account' AND a.scope_id = ? LIMIT 1"
)
.get(connectionId);
if (accountAssignment) {
const record = toRecord(accountAssignment);
return {
proxy: {
type: record.type,
host: record.host,
port: record.port,
username: record.username,
password: record.password,
},
level: "account",
levelId: connectionId,
source: "registry",
};
}
const connection = db
.prepare("SELECT provider FROM provider_connections WHERE id = ?")
.get(connectionId) as { provider?: string } | undefined;
if (connection?.provider) {
const providerAssignment = db
const accountAssignment = db
.prepare(
"SELECT p.id, p.type, p.host, p.port, p.username, p.password FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'provider' AND a.scope_id = ? LIMIT 1"
"SELECT p.id, p.type, p.host, p.port, p.username, p.password FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'account' AND a.scope_id = ? LIMIT 1"
)
.get(connection.provider);
if (providerAssignment) {
const record = toRecord(providerAssignment);
.get(connectionId);
if (accountAssignment) {
const record = toRecord(accountAssignment);
return {
proxy: {
type: record.type,
@@ -396,35 +379,66 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string
username: record.username,
password: record.password,
},
level: "provider",
levelId: connection.provider,
level: "account",
levelId: connectionId,
source: "registry",
};
}
}
const globalAssignment = db
.prepare(
"SELECT p.id, p.type, p.host, p.port, p.username, p.password FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'global' LIMIT 1"
)
.get();
if (globalAssignment) {
const record = toRecord(globalAssignment);
return {
proxy: {
type: record.type,
host: record.host,
port: record.port,
username: record.username,
password: record.password,
},
level: "global",
levelId: null,
source: "registry",
};
}
const connection = db
.prepare("SELECT provider FROM provider_connections WHERE id = ?")
.get(connectionId) as { provider?: string } | undefined;
return null;
if (connection?.provider) {
const providerAssignment = db
.prepare(
"SELECT p.id, p.type, p.host, p.port, p.username, p.password FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'provider' AND a.scope_id = ? LIMIT 1"
)
.get(connection.provider);
if (providerAssignment) {
const record = toRecord(providerAssignment);
return {
proxy: {
type: record.type,
host: record.host,
port: record.port,
username: record.username,
password: record.password,
},
level: "provider",
levelId: connection.provider,
source: "registry",
};
}
}
const globalAssignment = db
.prepare(
"SELECT p.id, p.type, p.host, p.port, p.username, p.password FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'global' LIMIT 1"
)
.get();
if (globalAssignment) {
const record = toRecord(globalAssignment);
return {
proxy: {
type: record.type,
host: record.host,
port: record.port,
username: record.username,
password: record.password,
},
level: "global",
levelId: null,
source: "registry",
};
}
return null;
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error);
if (msg.includes("no such table")) return null;
throw error;
}
}
export async function migrateLegacyProxyConfigToRegistry(options?: { force?: boolean }) {
@@ -593,41 +607,47 @@ export async function bulkAssignProxyToScope(
* Priority: provider-level → global → null
*/
export async function resolveProxyForProvider(providerId: string) {
const db = getDbInstance();
try {
const db = getDbInstance();
// Check provider-level proxy
const providerAssignment = db
.prepare(
"SELECT p.id, p.type, p.host, p.port, p.username, p.password FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'provider' AND a.scope_id = ? LIMIT 1"
)
.get(providerId);
if (providerAssignment) {
const record = toRecord(providerAssignment);
return {
type: record.type,
host: record.host,
port: record.port,
username: record.username,
password: record.password,
};
// Check provider-level proxy
const providerAssignment = db
.prepare(
"SELECT p.id, p.type, p.host, p.port, p.username, p.password FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'provider' AND a.scope_id = ? LIMIT 1"
)
.get(providerId);
if (providerAssignment) {
const record = toRecord(providerAssignment);
return {
type: record.type,
host: record.host,
port: record.port,
username: record.username,
password: record.password,
};
}
// Check global proxy
const globalAssignment = db
.prepare(
"SELECT p.id, p.type, p.host, p.port, p.username, p.password FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'global' LIMIT 1"
)
.get();
if (globalAssignment) {
const record = toRecord(globalAssignment);
return {
type: record.type,
host: record.host,
port: record.port,
username: record.username,
password: record.password,
};
}
return null;
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error);
if (msg.includes("no such table")) return null;
throw error;
}
// Check global proxy
const globalAssignment = db
.prepare(
"SELECT p.id, p.type, p.host, p.port, p.username, p.password FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'global' LIMIT 1"
)
.get();
if (globalAssignment) {
const record = toRecord(globalAssignment);
return {
type: record.type,
host: record.host,
port: record.port,
username: record.username,
password: record.password,
};
}
return null;
}

View File

@@ -121,8 +121,10 @@ test("migration infrastructure avoids cwd-based repo tracing fallbacks", () => {
const runnerSource = fs.readFileSync(path.resolve("src/lib/db/migrationRunner.ts"), "utf8");
const dataPathsSource = fs.readFileSync(path.resolve("src/lib/dataPaths.ts"), "utf8");
assert.doesNotMatch(runnerSource, /process\.cwd\(\)/);
// dataPaths must never use process.cwd() — it resolves via import.meta.url
assert.doesNotMatch(dataPathsSource, /process\.cwd\(\)/);
// migrationRunner uses import.meta.url as the primary strategy (process.cwd is
// only a last-resort fallback for Windows/CI-built bundles with leaked paths)
assert.match(runnerSource, /fileURLToPath\(import\.meta\.url\)/);
});