mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 05:12:11 +03:00
- #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:
12
CHANGELOG.md
12
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user