diff --git a/CHANGELOG.md b/CHANGELOG.md index 8806afa554..d4430d74a9 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 707cd28a24..8d05e65cd2 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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, diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 7c0b4e1087..81cca90907 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -482,6 +482,18 @@ export class BaseExecutor { if (Array.isArray(tb.system)) { const sysBlocks = tb.system as Array>; + // 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" && diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index f50045bec1..09740e64be 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -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; + delete oc.format; + if (Object.keys(oc).length === 0) { + delete translatedBody.output_config; + } + } } else { translatedBody = { ...body }; diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index e74aef792e..a85848b11a 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -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 } diff --git a/src/lib/db/migrationRunner.ts b/src/lib/db/migrationRunner.ts index 18dddca847..636352a627 100644 --- a/src/lib/db/migrationRunner.ts +++ b/src/lib/db/migrationRunner.ts @@ -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( diff --git a/src/lib/db/proxies.ts b/src/lib/db/proxies.ts index 0fc6d2eb2e..dc02836192 100755 --- a/src/lib/db/proxies.ts +++ b/src/lib/db/proxies.ts @@ -240,32 +240,40 @@ export async function updateProxy(id: string, payload: Partial) { } 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; } diff --git a/tests/unit/db-migration-runner.test.ts b/tests/unit/db-migration-runner.test.ts index 654ccb0da3..86955f7c6f 100644 --- a/tests/unit/db-migration-runner.test.ts +++ b/tests/unit/db-migration-runner.test.ts @@ -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\)/); });