mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-05 14:21:32 +00:00
284 lines
9.3 KiB
TypeScript
284 lines
9.3 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { resolveAgentDir } from "../agents/agent-scope.js";
|
|
import type { MemoryEmbeddingProviderAdapter } from "../plugins/memory-embedding-providers.js";
|
|
import { getFreePort, installGatewayTestHooks } from "./test-helpers.js";
|
|
|
|
installGatewayTestHooks({ scope: "suite" });
|
|
|
|
const WRITE_SCOPE_HEADER = { "x-openclaw-scopes": "operator.write" };
|
|
|
|
let startGatewayServer: typeof import("./server.js").startGatewayServer;
|
|
let createEmbeddingProviderMock: ReturnType<
|
|
typeof vi.fn<
|
|
(options: { provider: string; model: string; agentDir?: string }) => Promise<{
|
|
provider: {
|
|
id: string;
|
|
model: string;
|
|
embedQuery: (text: string) => Promise<number[]>;
|
|
embedBatch: (texts: string[]) => Promise<number[][]>;
|
|
};
|
|
}>
|
|
>
|
|
>;
|
|
let clearMemoryEmbeddingProviders: typeof import("../plugins/memory-embedding-providers.js").clearMemoryEmbeddingProviders;
|
|
let registerMemoryEmbeddingProvider: typeof import("../plugins/memory-embedding-providers.js").registerMemoryEmbeddingProvider;
|
|
let enabledServer: Awaited<ReturnType<typeof startServer>>;
|
|
let enabledPort: number;
|
|
|
|
beforeAll(async () => {
|
|
vi.resetModules();
|
|
({ clearMemoryEmbeddingProviders, registerMemoryEmbeddingProvider } =
|
|
await import("../plugins/memory-embedding-providers.js"));
|
|
createEmbeddingProviderMock = vi.fn(
|
|
async (options: { provider: string; model: string; agentDir?: string }) => ({
|
|
provider: {
|
|
id: options.provider,
|
|
model: options.model,
|
|
embedQuery: async () => [0.1, 0.2],
|
|
embedBatch: async (texts: string[]) =>
|
|
texts.map((_text, index) => [index + 0.1, index + 0.2]),
|
|
},
|
|
}),
|
|
);
|
|
clearMemoryEmbeddingProviders();
|
|
const openAiAdapter: MemoryEmbeddingProviderAdapter = {
|
|
id: "openai",
|
|
defaultModel: "text-embedding-3-small",
|
|
transport: "remote",
|
|
autoSelectPriority: 20,
|
|
allowExplicitWhenConfiguredAuto: true,
|
|
create: async (options) => {
|
|
const result = await createEmbeddingProviderMock({
|
|
provider: "openai",
|
|
model: options.model,
|
|
agentDir: options.agentDir,
|
|
});
|
|
return result;
|
|
},
|
|
};
|
|
registerMemoryEmbeddingProvider(openAiAdapter);
|
|
({ startGatewayServer } = await import("./server.js"));
|
|
enabledPort = await getFreePort();
|
|
enabledServer = await startServer(enabledPort, { openAiChatCompletionsEnabled: true });
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await enabledServer.close({ reason: "embeddings http enabled suite done" });
|
|
clearMemoryEmbeddingProviders();
|
|
vi.resetModules();
|
|
});
|
|
|
|
async function startServer(port: number, opts?: { openAiChatCompletionsEnabled?: boolean }) {
|
|
return await startGatewayServer(port, {
|
|
host: "127.0.0.1",
|
|
auth: { mode: "token", token: "secret" },
|
|
controlUiEnabled: false,
|
|
openAiChatCompletionsEnabled: opts?.openAiChatCompletionsEnabled ?? false,
|
|
});
|
|
}
|
|
|
|
async function postEmbeddings(body: unknown, headers?: Record<string, string>) {
|
|
return await fetch(`http://127.0.0.1:${enabledPort}/v1/embeddings`, {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: "Bearer secret",
|
|
"content-type": "application/json",
|
|
...WRITE_SCOPE_HEADER,
|
|
...headers,
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
describe("OpenAI-compatible embeddings HTTP API (e2e)", () => {
|
|
it("embeds string and array inputs", async () => {
|
|
const single = await postEmbeddings({
|
|
model: "openclaw/default",
|
|
input: "hello",
|
|
});
|
|
expect(single.status).toBe(200);
|
|
const singleJson = (await single.json()) as {
|
|
object?: string;
|
|
data?: Array<{ object?: string; embedding?: number[]; index?: number }>;
|
|
};
|
|
expect(singleJson.object).toBe("list");
|
|
expect(singleJson.data?.[0]?.object).toBe("embedding");
|
|
expect(singleJson.data?.[0]?.embedding).toEqual([0.1, 0.2]);
|
|
|
|
const batch = await postEmbeddings({
|
|
model: "openclaw/default",
|
|
input: ["a", "b"],
|
|
});
|
|
expect(batch.status).toBe(200);
|
|
const batchJson = (await batch.json()) as {
|
|
data?: Array<{ embedding?: number[]; index?: number }>;
|
|
};
|
|
expect(batchJson.data).toEqual([
|
|
{ object: "embedding", index: 0, embedding: [0.1, 0.2] },
|
|
{ object: "embedding", index: 1, embedding: [1.1, 1.2] },
|
|
]);
|
|
|
|
const qualified = await postEmbeddings(
|
|
{
|
|
model: "openclaw/default",
|
|
input: "hello again",
|
|
},
|
|
{ "x-openclaw-model": "openai/text-embedding-3-small" },
|
|
);
|
|
expect(qualified.status).toBe(200);
|
|
const qualifiedJson = (await qualified.json()) as { model?: string };
|
|
expect(qualifiedJson.model).toBe("openclaw/default");
|
|
const lastCall = createEmbeddingProviderMock.mock.calls.at(-1)?.[0] as
|
|
| { provider?: string; model?: string }
|
|
| undefined;
|
|
expect(lastCall).toMatchObject({
|
|
provider: "openai",
|
|
model: "text-embedding-3-small",
|
|
});
|
|
});
|
|
|
|
it("supports base64 encoding and agent-scoped auth/config resolution", async () => {
|
|
const res = await postEmbeddings(
|
|
{
|
|
model: "openclaw/beta",
|
|
input: "hello",
|
|
encoding_format: "base64",
|
|
},
|
|
{ "x-openclaw-agent-id": "beta" },
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as { data?: Array<{ embedding?: string }> };
|
|
expect(typeof json.data?.[0]?.embedding).toBe("string");
|
|
expect(createEmbeddingProviderMock).toHaveBeenCalled();
|
|
const lastCall = createEmbeddingProviderMock.mock.calls.at(-1)?.[0] as
|
|
| { provider?: string; model?: string; agentDir?: string }
|
|
| undefined;
|
|
expect(typeof lastCall?.model).toBe("string");
|
|
expect(lastCall?.agentDir).toBe(resolveAgentDir({}, "beta"));
|
|
});
|
|
|
|
it("rejects invalid input shapes", async () => {
|
|
const res = await postEmbeddings({
|
|
model: "openclaw/default",
|
|
input: [{ nope: true }],
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as { error?: { type?: string } };
|
|
expect(json.error?.type).toBe("invalid_request_error");
|
|
});
|
|
|
|
it("rejects operator scopes that lack write access", async () => {
|
|
const res = await postEmbeddings(
|
|
{
|
|
model: "openclaw/default",
|
|
input: "hello",
|
|
},
|
|
{ "x-openclaw-scopes": "operator.read" },
|
|
);
|
|
expect(res.status).toBe(403);
|
|
await expect(res.json()).resolves.toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
type: "forbidden",
|
|
message: "missing scope: operator.write",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("rejects requests with no declared operator scopes", async () => {
|
|
const res = await postEmbeddings(
|
|
{
|
|
model: "openclaw/default",
|
|
input: "hello",
|
|
},
|
|
{ "x-openclaw-scopes": "" },
|
|
);
|
|
expect(res.status).toBe(403);
|
|
await expect(res.json()).resolves.toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
type: "forbidden",
|
|
message: "missing scope: operator.write",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("rejects requests when the operator scopes header is missing", async () => {
|
|
const res = await fetch(`http://127.0.0.1:${enabledPort}/v1/embeddings`, {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: "Bearer secret",
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
model: "openclaw/default",
|
|
input: "hello",
|
|
}),
|
|
});
|
|
expect(res.status).toBe(403);
|
|
await expect(res.json()).resolves.toMatchObject({
|
|
ok: false,
|
|
error: {
|
|
type: "forbidden",
|
|
message: "missing scope: operator.write",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("rejects invalid agent targets", async () => {
|
|
const res = await postEmbeddings({
|
|
model: "ollama/nomic-embed-text",
|
|
input: "hello",
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as { error?: { type?: string; message?: string } };
|
|
expect(json.error).toEqual({
|
|
type: "invalid_request_error",
|
|
message: "Invalid `model`. Use `openclaw` or `openclaw/<agentId>`.",
|
|
});
|
|
});
|
|
|
|
it("rejects disallowed x-openclaw-model provider overrides", async () => {
|
|
const res = await postEmbeddings(
|
|
{
|
|
model: "openclaw/default",
|
|
input: "hello",
|
|
},
|
|
{ "x-openclaw-model": "ollama/nomic-embed-text" },
|
|
);
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as { error?: { type?: string; message?: string } };
|
|
expect(json.error).toEqual({
|
|
type: "invalid_request_error",
|
|
message: "This agent does not allow that embedding provider on `/v1/embeddings`.",
|
|
});
|
|
});
|
|
|
|
it("rejects oversized batches", async () => {
|
|
const res = await postEmbeddings({
|
|
model: "openclaw/default",
|
|
input: Array.from({ length: 129 }, () => "x"),
|
|
});
|
|
expect(res.status).toBe(400);
|
|
const json = (await res.json()) as { error?: { type?: string; message?: string } };
|
|
expect(json.error).toEqual({
|
|
type: "invalid_request_error",
|
|
message: "Too many inputs (max 128).",
|
|
});
|
|
});
|
|
|
|
it("sanitizes provider failures", async () => {
|
|
createEmbeddingProviderMock.mockRejectedValueOnce(new Error("secret upstream failure"));
|
|
const res = await postEmbeddings({
|
|
model: "openclaw/default",
|
|
input: "hello",
|
|
});
|
|
expect(res.status).toBe(500);
|
|
const json = (await res.json()) as { error?: { type?: string; message?: string } };
|
|
expect(json.error).toEqual({
|
|
type: "api_error",
|
|
message: "internal error",
|
|
});
|
|
});
|
|
});
|