From 67f6c039f34f04dca700c4d6bedbe0ec354e5f7a Mon Sep 17 00:00:00 2001 From: "Bob.Hou" Date: Wed, 16 Sep 2026 01:00:54 -0400 Subject: [PATCH] fix(embeddings): send stored API key on private-host embeddings nodes (#13398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an embeddings provider is classified `authType: "none"` and no credentials resolved, the service now looks up the stored connection and promotes to bearer if it holds a key — so private-host/CGNAT embeddings nodes that do require a key stop being called anonymously (#13234). Security posture holds: `isNoAuthLocalEmbeddingHost` is `isPrivateHost(hostname) && !isCloudMetadataHost(hostname)`, so cloud metadata addresses never reach `authType: "none"` and therefore never reach the new branch; the key only ever goes to the host the operator configured on that connection. Validated as a combined board first (this PR merged with the 11 siblings of the same batch on the release tip): eslint on every changed file with the suppressions file, typecheck:core, check:open-sse-typecheck, complexity, cognitive-complexity, changelog-integrity, i18n new-key coverage, docs-sync, migration-numbering, provider-consistency and a duplicate-identifier audit all green, plus 275 passing / 0 failing focused node:test cases across the 28 test files the batch touches. Then re-validated alone on the fresh tip before this merge: conflicts re-resolved, file sizes rebaselined for this PR's own growth, eslint and this PR's focused tests re-run. Thanks @HouMinXi! --- .../fixes/13234-embed-lan-keyed-auth.md | 1 + src/lib/embeddings/service.ts | 25 +++ .../embeddings-lan-keyed-auth-13234.test.ts | 165 ++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 changelog.d/fixes/13234-embed-lan-keyed-auth.md create mode 100644 tests/unit/embeddings-lan-keyed-auth-13234.test.ts diff --git a/changelog.d/fixes/13234-embed-lan-keyed-auth.md b/changelog.d/fixes/13234-embed-lan-keyed-auth.md new file mode 100644 index 0000000000..8228549624 --- /dev/null +++ b/changelog.d/fixes/13234-embed-lan-keyed-auth.md @@ -0,0 +1 @@ +- **fix(embeddings):** LAN/CGNAT OpenAI-compatible embeddings nodes with a stored API key now send `Authorization: Bearer` on the outbound request, matching dashboard Check. Keyless LAN nodes stay no-auth ([#6925](https://github.com/diegosouzapw/OmniRoute/issues/6925)) ([#13234](https://github.com/diegosouzapw/OmniRoute/issues/13234)) diff --git a/src/lib/embeddings/service.ts b/src/lib/embeddings/service.ts index 7bf961f2af..cead8383a0 100644 --- a/src/lib/embeddings/service.ts +++ b/src/lib/embeddings/service.ts @@ -344,6 +344,31 @@ export async function createEmbeddingResponse( ) { credentials = localCredentials; } + } else if (!credentials && providerConfig.authType === "none") { + // #13234: private-host nodes are classified no-auth so a keyless + // LAN Ollama still works (#6925). A stored API key on that same + // node must still ride outbound, matching dashboard Check. + const keyedCredentials = await getProviderCredentials(credentialsProviderId); + if ( + keyedCredentials && + !("allRateLimited" in keyedCredentials) && + !("allExpired" in keyedCredentials) + ) { + const token = + (typeof (keyedCredentials as { apiKey?: unknown }).apiKey === "string" && + (keyedCredentials as { apiKey?: string }).apiKey) || + (typeof (keyedCredentials as { accessToken?: unknown }).accessToken === "string" && + (keyedCredentials as { accessToken?: string }).accessToken) || + ""; + if (token) { + credentials = keyedCredentials; + providerConfig = { + ...providerConfig, + authType: "apikey", + authHeader: "bearer", + }; + } + } } // #474: when the request used a bare model name (no "/" — e.g. an alias that diff --git a/tests/unit/embeddings-lan-keyed-auth-13234.test.ts b/tests/unit/embeddings-lan-keyed-auth-13234.test.ts new file mode 100644 index 0000000000..fdba2165ca --- /dev/null +++ b/tests/unit/embeddings-lan-keyed-auth-13234.test.ts @@ -0,0 +1,165 @@ +/** + * #13234: a LAN/CGNAT OpenAI-compatible embeddings node with a stored API key + * must send Authorization: Bearer on the outbound proxy request. + * + * Dashboard Check already does this via buildBearerHeaders. The embeddings + * proxy did not: #6925 classified every private-host node as authType "none" + * before credentials were loaded, so buildAuth dropped the key and the + * upstream returned 401. Chat/completions against the same node/key worked. + * + * Keyless LAN nodes stay no-auth (#6925). Cloud-metadata hosts stay blocked. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-embed-lan-key-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const { createProviderNode } = await import("../../src/lib/db/providers/nodes.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const { createEmbeddingResponse } = await import("../../src/lib/embeddings/service.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +function stubEmbeddingFetch() { + const originalFetch = globalThis.fetch; + let captured: { url: string; headers: Record } | null = null; + globalThis.fetch = async (url: RequestInfo | URL, options: RequestInit = {}) => { + captured = { + url: String(url), + headers: (options.headers as Record) || {}, + }; + return new Response( + JSON.stringify({ + data: [{ object: "embedding", embedding: [0.1, 0.2], index: 0 }], + usage: { prompt_tokens: 3, total_tokens: 3 }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + return { + get captured() { + return captured; + }, + restore() { + globalThis.fetch = originalFetch; + }, + }; +} + +test("#13234: 100.64 CGNAT embeddings node with stored key sends Authorization", async () => { + const node = await createProviderNode({ + type: "openai-compatible-embeddings", + name: "CGNAT Embed", + prefix: "cgnatembed13234", + apiType: "embeddings", + baseUrl: "http://100.64.1.10:8080/v1", + }); + + await providersDb.createProviderConnection({ + provider: node.id, + authType: "apikey", + name: "CGNAT Embed Key", + apiKey: "sk-embed-13234", + isActive: true, + testStatus: "active", + providerSpecificData: { + prefix: "cgnatembed13234", + baseUrl: "http://100.64.1.10:8080/v1", + }, + }); + + const fetchStub = stubEmbeddingFetch(); + try { + const res = await createEmbeddingResponse({ + model: "cgnatembed13234/nomic-embed-text", + input: "hello world", + }); + assert.equal(res.status, 200); + } finally { + fetchStub.restore(); + } + + assert.ok(fetchStub.captured); + assert.equal( + fetchStub.captured!.url, + "http://100.64.1.10:8080/v1/embeddings", + "should hit the node's own embeddings endpoint" + ); + assert.equal( + fetchStub.captured!.headers.Authorization, + "Bearer sk-embed-13234", + "stored key must ride on the outbound embeddings request, matching Check" + ); +}); + +test("#13234: keyless 10.x LAN embeddings node still sends no Authorization", async () => { + await createProviderNode({ + type: "openai-compatible-embeddings", + name: "LAN Ollama Keyless", + prefix: "lanollama13234", + apiType: "embeddings", + baseUrl: "http://10.10.0.181:11434/v1", + }); + + const fetchStub = stubEmbeddingFetch(); + try { + const res = await createEmbeddingResponse({ + model: "lanollama13234/nomic-embed-text", + input: "hello world", + }); + assert.equal(res.status, 200); + } finally { + fetchStub.restore(); + } + + assert.ok(fetchStub.captured); + assert.equal( + fetchStub.captured!.headers.Authorization, + undefined, + "a keyless LAN provider must not receive a fabricated Authorization header" + ); +}); + +test("#13234: LAN node with a keyless connection record still sends no Authorization", async () => { + const node = await createProviderNode({ + type: "openai-compatible-embeddings", + name: "LAN empty key", + prefix: "lanempty13234", + apiType: "embeddings", + baseUrl: "http://10.20.0.5:11434/v1", + }); + + await providersDb.createProviderConnection({ + provider: node.id, + authType: "apikey", + name: "LAN empty key conn", + apiKey: "", + isActive: true, + }); + + const fetchStub = stubEmbeddingFetch(); + try { + const res = await createEmbeddingResponse({ + model: "lanempty13234/nomic-embed-text", + input: "hello world", + }); + assert.equal(res.status, 200); + } finally { + fetchStub.restore(); + } + + assert.ok(fetchStub.captured); + assert.equal( + fetchStub.captured!.headers.Authorization, + undefined, + "empty stored key must not fabricate Authorization", + ); +});