fix(memory): honor mixed-case embedding header overrides (#130774)

This commit is contained in:
Peter Steinberger
2026-08-27 00:40:25 -07:00
committed by GitHub
parent 8c7de197cd
commit 36856401d4
2 changed files with 83 additions and 39 deletions
@@ -1,5 +1,5 @@
// Memory Host SDK tests cover embeddings remote client behavior.
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveRemoteEmbeddingBearerClient } from "./embeddings-remote-client.js";
import type { EmbeddingProviderOptions } from "./embeddings.types.js";
@@ -10,6 +10,10 @@ const configuredProvider = {
models: [],
};
afterEach(() => {
vi.unstubAllEnvs();
});
describe("resolveRemoteEmbeddingBearerClient", () => {
it.each<{
name: string;
@@ -43,6 +47,7 @@ describe("resolveRemoteEmbeddingBearerClient", () => {
tenant: "remote-b",
},
])("$name", async ({ remote, authorization, tenant }) => {
vi.stubEnv("OPENAI_API_KEY", "");
const client = await resolveRemoteEmbeddingBearerClient({
provider: "openai",
defaultBaseUrl: "https://api.openai.com/v1",
@@ -75,6 +80,46 @@ describe("resolveRemoteEmbeddingBearerClient", () => {
).rejects.toThrow(/memory\.search\.remote\.apiKey|Authorization header/);
});
it("lets the last source replace mixed-case auth, tenant, and default headers", async () => {
const client = await resolveRemoteEmbeddingBearerClient({
provider: "openai",
defaultBaseUrl: configuredProvider.baseUrl,
options: {
config: {
models: {
providers: {
openai: {
...configuredProvider,
headers: {
Authorization: "first",
authorization: "second",
"X-Tenant": "first",
"x-tenant": "second",
},
},
},
},
},
model: "fixture-embedding",
remote: {
headers: {
Authorization: "Bearer remote",
"X-Tenant": "remote",
"content-type": "application/json; charset=utf-8",
"X-Unchanged": "value",
},
},
},
});
expect(client.headers).toEqual({
"content-type": "application/json; charset=utf-8",
Authorization: "Bearer remote",
"X-Tenant": "remote",
"X-Unchanged": "value",
});
});
it("treats loopback address families as distinct credential destinations", async () => {
await expect(
resolveRemoteEmbeddingBearerClient({
@@ -129,8 +174,8 @@ describe("resolveRemoteEmbeddingBearerClient", () => {
remote: {
apiKey: "sk-test",
headers: {
originator: "openclaw",
"User-Agent": "openclaw",
Originator: "caller",
"user-agent": "caller",
},
},
},
@@ -51,29 +51,26 @@ export function resolveEmbeddingEndpointUrl(baseUrl: string, endpoint: string):
return url.toString();
}
function resolveEmbeddingHeaders(params: {
headers: Record<string, unknown> | undefined;
path: string;
}): Record<string, string> {
const resolved: Record<string, string> = {};
for (const [name, value] of Object.entries(params.headers ?? {})) {
const header = resolveMemorySecretInputString({
value,
path: `${params.path}.${name}`,
});
if (header) {
resolved[name] = header;
function resolveEmbeddingHeaders(
...sources: Array<{ headers: Record<string, unknown> | undefined; path: string }>
): Map<string, [string, string]> {
const resolved = new Map<string, [string, string]>();
for (const source of sources) {
// Retain each source's existing SecretRef and prototype-key handling.
const headers: Record<string, string> = {};
for (const [name, value] of Object.entries(source.headers ?? {})) {
const header = resolveMemorySecretInputString({ value, path: `${source.path}.${name}` });
if (header) {
headers[name] = header;
}
}
for (const entry of Object.entries(headers)) {
resolved.set(entry[0].toLowerCase(), entry);
}
}
return resolved;
}
function hasAuthorizationHeader(headers: Record<string, string>): boolean {
return Object.entries(headers).some(
([name, value]) => name.toLowerCase() === "authorization" && value.trim().length > 0,
);
}
/** Detect the native OpenAI embeddings API route that accepts attribution headers. */
function isNativeOpenAIEmbeddingRoute(provider: string, baseUrl: string): boolean {
if (provider !== "openai") {
@@ -105,20 +102,17 @@ export async function resolveRemoteEmbeddingBearerClient(params: {
baseUrl,
providerBaseUrl,
});
const headerOverrides = Object.assign(
{},
providerOwnsDestination
? resolveEmbeddingHeaders({
headers: providerConfig?.headers,
path: `models.providers.${params.provider}.headers`,
})
: undefined,
resolveEmbeddingHeaders({
const headerOverrides = resolveEmbeddingHeaders(
{
headers: providerOwnsDestination ? providerConfig?.headers : undefined,
path: `models.providers.${params.provider}.headers`,
},
{
headers: remote?.headers,
path: "memory.search.remote.headers",
}),
},
);
const hasExplicitAuthorization = hasAuthorizationHeader(headerOverrides);
const hasExplicitAuthorization = headerOverrides.has("authorization");
const apiKey = hasExplicitAuthorization
? undefined
: remoteApiKey
@@ -138,13 +132,18 @@ export async function resolveRemoteEmbeddingBearerClient(params: {
`${params.provider} embedding credentials are not configured for ${baseUrl}. Set memory.search.remote.apiKey or an Authorization header for this destination.`,
);
}
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
...headerOverrides,
};
if (isNativeOpenAIEmbeddingRoute(params.provider, baseUrl)) {
Object.assign(headers, resolveOpenClawAttributionHeaders());
const entries: Array<[string, string]> = [["Content-Type", "application/json"]];
if (apiKey) {
entries.push(["Authorization", `Bearer ${apiKey}`]);
}
entries.push(...headerOverrides.values());
if (isNativeOpenAIEmbeddingRoute(params.provider, baseUrl)) {
entries.push(...Object.entries(resolveOpenClawAttributionHeaders()));
}
// Fetch joins duplicate names; retain only the last source, but preserve its
// spelling so ordinary non-secret embedding cache identities stay unchanged.
const headers = Object.fromEntries(
new Map(entries.map((entry) => [entry[0].toLowerCase(), entry])).values(),
);
return { baseUrl, headers, ssrfPolicy: buildRemoteBaseUrlPolicy(baseUrl) };
}