mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(memory): honor mixed-case embedding header overrides (#130774)
This commit is contained in:
committed by
GitHub
parent
8c7de197cd
commit
36856401d4
@@ -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) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user