fix(embeddings): send stored API key on private-host embeddings nodes (#13398)

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!
This commit is contained in:
Bob.Hou
2026-09-16 01:00:54 -04:00
committed by GitHub
parent 4902ac8128
commit 67f6c039f3
3 changed files with 191 additions and 0 deletions

View File

@@ -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))

View File

@@ -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

View File

@@ -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<string, string> } | null = null;
globalThis.fetch = async (url: RequestInfo | URL, options: RequestInit = {}) => {
captured = {
url: String(url),
headers: (options.headers as Record<string, string>) || {},
};
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",
);
});