fix(openai-embedding): preserve openai/ prefix for non-native base URLs (#92135)

* fix(openai-embedding): preserve openai/ prefix for non-native base URLs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(openai-embedding): normalize model before maxInputTokens lookup so qualified models retain token cap

* fix(openai-embedding): use semantic hostname check for native OpenAI URL detection

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Peter Lee
2026-06-18 13:03:41 -05:00
committed by GitHub
parent 7c24de5c87
commit 111018984c
2 changed files with 114 additions and 7 deletions

View File

@@ -2,13 +2,15 @@
import type { MemoryEmbeddingProviderCreateOptions } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { beforeEach, describe, expect, it, vi } from "vitest";
const DEFAULT_MOCK_CLIENT = {
baseUrl: "https://embeddings.example/v1",
headers: { Authorization: "Bearer test" },
model: "text-embedding-3-small",
};
const mocks = vi.hoisted(() => ({
fetchRemoteEmbeddingVectors: vi.fn(async () => [[1, 0]]),
resolveRemoteEmbeddingClient: vi.fn(async () => ({
baseUrl: "https://embeddings.example/v1",
headers: { Authorization: "Bearer test" },
model: "text-embedding-3-small",
})),
resolveRemoteEmbeddingClient: vi.fn(async () => ({ ...DEFAULT_MOCK_CLIENT })),
}));
vi.mock("openclaw/plugin-sdk/memory-core-host-engine-embeddings", () => ({
@@ -120,4 +122,93 @@ describe("OpenAI embedding provider", () => {
}),
);
});
// --- openai/ prefix preservation ---
it("strips openai/ prefix when using native OpenAI API base URL", async () => {
mocks.resolveRemoteEmbeddingClient.mockResolvedValueOnce({
...DEFAULT_MOCK_CLIENT,
baseUrl: "https://api.openai.com/v1",
model: "text-embedding-3-small",
});
const { provider } = await createOpenAiEmbeddingProvider(
createOptions({ model: "openai/text-embedding-3-small" }),
);
expect(provider.model).toBe("text-embedding-3-small");
});
it("strips openai/ prefix for semantically native URLs (uppercase hostname)", async () => {
mocks.resolveRemoteEmbeddingClient.mockResolvedValueOnce({
...DEFAULT_MOCK_CLIENT,
baseUrl: "https://API.OPENAI.COM/v1",
model: "text-embedding-3-small",
});
const { provider } = await createOpenAiEmbeddingProvider(
createOptions({ model: "openai/text-embedding-3-small" }),
);
expect(provider.model).toBe("text-embedding-3-small");
});
it("preserves openai/ prefix for non-native OpenAI base URLs", async () => {
mocks.resolveRemoteEmbeddingClient.mockResolvedValueOnce({
...DEFAULT_MOCK_CLIENT,
baseUrl: "https://router.requesty.ai/v1",
model: "text-embedding-3-small",
});
const { provider } = await createOpenAiEmbeddingProvider(
createOptions({ model: "openai/text-embedding-3-small" }),
);
expect(provider.model).toBe("openai/text-embedding-3-small");
});
it("provides maxInputTokens for qualified model with non-native base URL", async () => {
mocks.resolveRemoteEmbeddingClient.mockResolvedValueOnce({
...DEFAULT_MOCK_CLIENT,
baseUrl: "https://router.requesty.ai/v1",
model: "text-embedding-3-small",
});
const { provider } = await createOpenAiEmbeddingProvider(
createOptions({ model: "openai/text-embedding-3-small" }),
);
expect(provider.maxInputTokens).toBe(8192);
});
it("preserves openai/ prefix in embedding request body for non-native base URLs", async () => {
mocks.resolveRemoteEmbeddingClient.mockResolvedValueOnce({
...DEFAULT_MOCK_CLIENT,
baseUrl: "https://router.requesty.ai/v1",
model: "text-embedding-3-small",
});
const { provider } = await createOpenAiEmbeddingProvider(
createOptions({
model: "openai/text-embedding-3-small",
inputType: "query",
}),
);
await provider.embedQuery("test");
expect(mocks.fetchRemoteEmbeddingVectors).toHaveBeenCalledWith({
url: "https://router.requesty.ai/v1/embeddings",
headers: { Authorization: "Bearer test" },
ssrfPolicy: undefined,
fetchImpl: undefined,
signal: undefined,
body: {
model: "openai/text-embedding-3-small",
input: ["test"],
input_type: "query",
},
errorPrefix: "openai embeddings failed",
});
});
});

View File

@@ -36,6 +36,15 @@ function normalizeOpenAiModel(model: string): string {
return trimmed.startsWith("openai/") ? trimmed.slice("openai/".length) : trimmed;
}
/** Whether the embedding base URL points to the native OpenAI API endpoint. */
function isNativeOpenAiBaseUrl(baseUrl: string): boolean {
try {
return new URL(baseUrl).hostname.toLowerCase().replace(/\.+$/, "") === "api.openai.com";
} catch {
return false;
}
}
export async function createOpenAiEmbeddingProvider(
options: MemoryEmbeddingProviderCreateOptions,
): Promise<{ provider: MemoryEmbeddingProvider; client: OpenAiEmbeddingClient }> {
@@ -79,8 +88,8 @@ export async function createOpenAiEmbeddingProvider(
provider: {
id: "openai",
model: client.model,
...(typeof OPENAI_MAX_INPUT_TOKENS[client.model] === "number"
? { maxInputTokens: OPENAI_MAX_INPUT_TOKENS[client.model] }
...(typeof OPENAI_MAX_INPUT_TOKENS[normalizeOpenAiModel(client.model)] === "number"
? { maxInputTokens: OPENAI_MAX_INPUT_TOKENS[normalizeOpenAiModel(client.model)] }
: {}),
embedQuery: async (text, optionsValue) => {
const [vec] = await embed([text], "query", optionsValue?.signal);
@@ -96,12 +105,19 @@ export async function createOpenAiEmbeddingProvider(
async function resolveOpenAiEmbeddingClient(
options: MemoryEmbeddingProviderCreateOptions,
): Promise<OpenAiEmbeddingClient> {
const originalModel = options.model;
const client = await resolveRemoteEmbeddingClient({
provider: options.provider ?? "openai",
options,
defaultBaseUrl: DEFAULT_OPENAI_BASE_URL,
normalizeModel: normalizeOpenAiModel,
});
// Non-native OpenAI routers (e.g. Requesty) expect the provider-qualified
// model name ("openai/text-embedding-3-small") in embedding requests.
// Strip the prefix only when talking to the native OpenAI API.
if (!isNativeOpenAiBaseUrl(client.baseUrl) && originalModel.startsWith("openai/")) {
client.model = `openai/${normalizeOpenAiModel(originalModel)}`;
}
return {
...client,
inputType: options.inputType,