Files
openclaw/extensions/openai/embedding-provider.ts
Peter Steinberger 67d22a58ac refactor(embeddings): unify provider contract (#130506)
* refactor(embeddings): unify provider contract

* test(plugins): declare embedding type bridge

* test(memory): migrate embedding fixtures
2026-08-26 18:20:48 -07:00

145 lines
4.9 KiB
TypeScript

// Openai provider module implements model/runtime integration.
import {
fetchRemoteEmbeddingVectors,
resolveEmbeddingEndpointUrl,
resolveRemoteEmbeddingClient,
type MemoryEmbeddingProvider,
type MemoryEmbeddingProviderCreateOptions,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
import { OPENAI_DEFAULT_EMBEDDING_MODEL } from "./default-models.js";
export type OpenAiEmbeddingClient = {
baseUrl: string;
headers: Record<string, string>;
ssrfPolicy?: SsrFPolicy;
fetchImpl?: typeof fetch;
model: string;
inputType?: string;
queryInputType?: string;
documentInputType?: string;
outputDimensionality?: number;
};
const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
export const DEFAULT_OPENAI_EMBEDDING_MODEL = OPENAI_DEFAULT_EMBEDDING_MODEL;
const OPENAI_MAX_INPUT_TOKENS: Record<string, number> = {
"text-embedding-3-small": 8192,
"text-embedding-3-large": 8192,
"text-embedding-ada-002": 8191,
};
function normalizeOpenAiModel(model: string): string {
const trimmed = model.trim();
if (!trimmed) {
return DEFAULT_OPENAI_EMBEDDING_MODEL;
}
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 }> {
const client = await resolveOpenAiEmbeddingClient(options);
const url = resolveEmbeddingEndpointUrl(client.baseUrl, "embeddings");
const resolveInputType = (kind: "query" | "document"): string | undefined => {
const explicit = kind === "query" ? client.queryInputType : client.documentInputType;
const value = explicit ?? client.inputType;
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
};
const embedMany = async (
input: string[],
kind: "query" | "document",
signal?: AbortSignal,
): Promise<number[][]> => {
if (input.length === 0) {
return [];
}
const inputType = resolveInputType(kind);
return await fetchRemoteEmbeddingVectors({
url,
headers: client.headers,
ssrfPolicy: client.ssrfPolicy,
fetchImpl: client.fetchImpl,
signal,
body: {
model: client.model,
input,
...(typeof client.outputDimensionality === "number"
? { dimensions: client.outputDimensionality }
: {}),
...(inputType ? { input_type: inputType } : {}),
},
errorPrefix: "openai embeddings failed",
});
};
return {
provider: {
id: "openai",
model: client.model,
...(typeof OPENAI_MAX_INPUT_TOKENS[normalizeOpenAiModel(client.model)] === "number"
? { maxInputTokens: OPENAI_MAX_INPUT_TOKENS[normalizeOpenAiModel(client.model)] }
: {}),
embed: async (input, optionsValue) => {
const text = typeof input === "string" ? input : input.text;
const [vec] = await embedMany(
[text],
optionsValue?.inputType === "query" ? "query" : "document",
optionsValue?.signal,
);
return vec ?? [];
},
embedBatch: async (inputs, optionsLocal) => {
const texts = inputs.map((input) => (typeof input === "string" ? input : input.text));
if (optionsLocal?.inputType === "query") {
return await Promise.all(
texts.map(async (text) => {
const [vec] = await embedMany([text], "query", optionsLocal.signal);
return vec ?? [];
}),
);
}
return await embedMany(texts, "document", optionsLocal?.signal);
},
},
client,
};
}
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,
queryInputType: options.queryInputType,
documentInputType: options.documentInputType,
outputDimensionality: options.dimensions,
};
}