mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 01:41:40 +00:00
test: collapse provider plugin suites
This commit is contained in:
@@ -8,6 +8,7 @@ import type {
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js";
|
||||
import type { OpenClawPluginApi } from "./api.js";
|
||||
import type { PendingPairingRequest } from "./notify.ts";
|
||||
|
||||
const pluginApiMocks = vi.hoisted(() => ({
|
||||
clearDeviceBootstrapTokens: vi.fn(async () => ({ removed: 2 })),
|
||||
@@ -385,6 +386,49 @@ describe("device-pair /pair qr", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("device-pair notify pending formatting", () => {
|
||||
it("includes role and scopes for pending requests", async () => {
|
||||
const { formatPendingRequests } =
|
||||
await vi.importActual<typeof import("./notify.ts")>("./notify.ts");
|
||||
const pending: PendingPairingRequest[] = [
|
||||
{
|
||||
requestId: "req-1",
|
||||
deviceId: "device-1",
|
||||
displayName: "dev one",
|
||||
platform: "ios",
|
||||
role: "operator",
|
||||
scopes: ["operator.admin", "operator.read"],
|
||||
remoteIp: "198.51.100.2",
|
||||
},
|
||||
];
|
||||
|
||||
const text = formatPendingRequests(pending);
|
||||
expect(text).toContain("Pending device pairing requests:");
|
||||
expect(text).toContain("name=dev one");
|
||||
expect(text).toContain("platform=ios");
|
||||
expect(text).toContain("role=operator");
|
||||
expect(text).toContain("scopes=operator.admin, operator.read");
|
||||
expect(text).toContain("ip=198.51.100.2");
|
||||
});
|
||||
|
||||
it("falls back to roles list and no scopes when role/scopes are absent", async () => {
|
||||
const { formatPendingRequests } =
|
||||
await vi.importActual<typeof import("./notify.ts")>("./notify.ts");
|
||||
const pending: PendingPairingRequest[] = [
|
||||
{
|
||||
requestId: "req-2",
|
||||
deviceId: "device-2",
|
||||
roles: ["node", "operator"],
|
||||
scopes: [],
|
||||
},
|
||||
];
|
||||
|
||||
const text = formatPendingRequests(pending);
|
||||
expect(text).toContain("role=node, operator");
|
||||
expect(text).toContain("scopes=none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("device-pair /pair approve", () => {
|
||||
it("rejects internal gateway callers without operator.pairing", async () => {
|
||||
vi.mocked(listDevicePairing).mockResolvedValueOnce({
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatPendingRequests, type PendingPairingRequest } from "./notify.ts";
|
||||
|
||||
describe("device-pair notify pending formatting", () => {
|
||||
it("includes role and scopes for pending requests", () => {
|
||||
const pending: PendingPairingRequest[] = [
|
||||
{
|
||||
requestId: "req-1",
|
||||
deviceId: "device-1",
|
||||
displayName: "dev one",
|
||||
platform: "ios",
|
||||
role: "operator",
|
||||
scopes: ["operator.admin", "operator.read"],
|
||||
remoteIp: "198.51.100.2",
|
||||
},
|
||||
];
|
||||
|
||||
const text = formatPendingRequests(pending);
|
||||
expect(text).toContain("Pending device pairing requests:");
|
||||
expect(text).toContain("name=dev one");
|
||||
expect(text).toContain("platform=ios");
|
||||
expect(text).toContain("role=operator");
|
||||
expect(text).toContain("scopes=operator.admin, operator.read");
|
||||
expect(text).toContain("ip=198.51.100.2");
|
||||
});
|
||||
|
||||
it("falls back to roles list and no scopes when role/scopes are absent", () => {
|
||||
const pending: PendingPairingRequest[] = [
|
||||
{
|
||||
requestId: "req-2",
|
||||
deviceId: "device-2",
|
||||
roles: ["node", "operator"],
|
||||
scopes: [],
|
||||
},
|
||||
];
|
||||
|
||||
const text = formatPendingRequests(pending);
|
||||
expect(text).toContain("role=node, operator");
|
||||
expect(text).toContain("scopes=none");
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawPluginApi } from "./runtime-api.js";
|
||||
|
||||
const registerFeishuDocToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuChatToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuWikiToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuDriveToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuPermToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuBitableToolsMock = vi.hoisted(() => vi.fn());
|
||||
const feishuPluginMock = vi.hoisted(() => ({ id: "feishu-test-plugin" }));
|
||||
const setFeishuRuntimeMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuSubagentHooksMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./src/channel.js", () => ({
|
||||
feishuPlugin: feishuPluginMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/docx.js", () => ({
|
||||
registerFeishuDocTools: registerFeishuDocToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/chat.js", () => ({
|
||||
registerFeishuChatTools: registerFeishuChatToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/wiki.js", () => ({
|
||||
registerFeishuWikiTools: registerFeishuWikiToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/drive.js", () => ({
|
||||
registerFeishuDriveTools: registerFeishuDriveToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/perm.js", () => ({
|
||||
registerFeishuPermTools: registerFeishuPermToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/bitable.js", () => ({
|
||||
registerFeishuBitableTools: registerFeishuBitableToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/runtime.js", () => ({
|
||||
setFeishuRuntime: setFeishuRuntimeMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/subagent-hooks.js", () => ({
|
||||
registerFeishuSubagentHooks: registerFeishuSubagentHooksMock,
|
||||
}));
|
||||
|
||||
describe("feishu plugin register", () => {
|
||||
it("registers the Feishu channel, tools, and subagent hooks", async () => {
|
||||
const { default: plugin } = await import("./index.js");
|
||||
const registerChannel = vi.fn();
|
||||
const api = {
|
||||
runtime: { log: vi.fn() },
|
||||
registerChannel,
|
||||
on: vi.fn(),
|
||||
config: {},
|
||||
registrationMode: "full",
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
plugin.register(api);
|
||||
|
||||
expect(setFeishuRuntimeMock).toHaveBeenCalledWith(api.runtime);
|
||||
expect(registerChannel).toHaveBeenCalledTimes(1);
|
||||
expect(registerChannel).toHaveBeenCalledWith({ plugin: feishuPluginMock });
|
||||
expect(registerFeishuSubagentHooksMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuDocToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuChatToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuWikiToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuDriveToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuPermToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuBitableToolsMock).toHaveBeenCalledWith(api);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawPluginApi } from "../runtime-api.js";
|
||||
import type { FeishuConfig, ResolvedFeishuAccount } from "./types.js";
|
||||
|
||||
type CreateFeishuClient = typeof import("./client.js").createFeishuClient;
|
||||
@@ -33,6 +34,15 @@ const mockBaseHttpInstance = vi.hoisted(() => ({
|
||||
}));
|
||||
const proxyEnvKeys = ["https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"] as const;
|
||||
type ProxyEnvKey = (typeof proxyEnvKeys)[number];
|
||||
const registerFeishuDocToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuChatToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuWikiToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuDriveToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuPermToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuBitableToolsMock = vi.hoisted(() => vi.fn());
|
||||
const feishuPluginMock = vi.hoisted(() => ({ id: "feishu-test-plugin" }));
|
||||
const setFeishuRuntimeMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuSubagentHooksMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
let createFeishuClient: CreateFeishuClient;
|
||||
let createFeishuWSClient: CreateFeishuWSClient;
|
||||
@@ -45,6 +55,42 @@ let FEISHU_HTTP_TIMEOUT_ENV_VAR: string;
|
||||
let priorProxyEnv: Partial<Record<ProxyEnvKey, string | undefined>> = {};
|
||||
let priorFeishuTimeoutEnv: string | undefined;
|
||||
|
||||
vi.mock("./channel.js", () => ({
|
||||
feishuPlugin: feishuPluginMock,
|
||||
}));
|
||||
|
||||
vi.mock("./docx.js", () => ({
|
||||
registerFeishuDocTools: registerFeishuDocToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./chat.js", () => ({
|
||||
registerFeishuChatTools: registerFeishuChatToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./wiki.js", () => ({
|
||||
registerFeishuWikiTools: registerFeishuWikiToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./drive.js", () => ({
|
||||
registerFeishuDriveTools: registerFeishuDriveToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./perm.js", () => ({
|
||||
registerFeishuPermTools: registerFeishuPermToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./bitable.js", () => ({
|
||||
registerFeishuBitableTools: registerFeishuBitableToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
setFeishuRuntime: setFeishuRuntimeMock,
|
||||
}));
|
||||
|
||||
vi.mock("./subagent-hooks.js", () => ({
|
||||
registerFeishuSubagentHooks: registerFeishuSubagentHooksMock,
|
||||
}));
|
||||
|
||||
const baseAccount: ResolvedFeishuAccount = {
|
||||
accountId: "main",
|
||||
selectionSource: "explicit",
|
||||
@@ -290,6 +336,33 @@ describe("createFeishuClient HTTP timeout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("feishu plugin register", () => {
|
||||
it("registers the Feishu channel, tools, and subagent hooks", async () => {
|
||||
const { default: plugin } = await import("../index.js");
|
||||
const registerChannel = vi.fn();
|
||||
const api = {
|
||||
runtime: { log: vi.fn() },
|
||||
registerChannel,
|
||||
on: vi.fn(),
|
||||
config: {},
|
||||
registrationMode: "full",
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
plugin.register(api);
|
||||
|
||||
expect(setFeishuRuntimeMock).toHaveBeenCalledWith(api.runtime);
|
||||
expect(registerChannel).toHaveBeenCalledTimes(1);
|
||||
expect(registerChannel).toHaveBeenCalledWith({ plugin: feishuPluginMock });
|
||||
expect(registerFeishuSubagentHooksMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuDocToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuChatToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuWikiToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuDriveToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuPermToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuBitableToolsMock).toHaveBeenCalledWith(api);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFeishuWSClient proxy handling", () => {
|
||||
it("does not set a ws proxy agent when proxy env is absent", () => {
|
||||
createFeishuWSClient(baseAccount);
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
import { __testing as firecrawlClientTesting } from "./src/firecrawl-client.js";
|
||||
|
||||
describe("firecrawl plugin", () => {
|
||||
it("parses scrape payloads into wrapped external-content results", () => {
|
||||
const result = firecrawlClientTesting.parseFirecrawlScrapePayload({
|
||||
payload: {
|
||||
success: true,
|
||||
data: {
|
||||
markdown: "# Hello\n\nWorld",
|
||||
metadata: {
|
||||
title: "Example page",
|
||||
sourceURL: "https://example.com/final",
|
||||
statusCode: 200,
|
||||
},
|
||||
},
|
||||
},
|
||||
url: "https://example.com/start",
|
||||
extractMode: "text",
|
||||
maxChars: 1000,
|
||||
});
|
||||
|
||||
expect(result.finalUrl).toBe("https://example.com/final");
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.extractor).toBe("firecrawl");
|
||||
expect(String(result.text)).toContain("Hello");
|
||||
expect(String(result.text)).toContain("World");
|
||||
expect(result.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it("extracts search items from flexible Firecrawl payload shapes", () => {
|
||||
const items = firecrawlClientTesting.resolveSearchItems({
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
title: "Docs",
|
||||
url: "https://docs.example.com/path",
|
||||
description: "Reference docs",
|
||||
markdown: "Body",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toEqual([
|
||||
{
|
||||
title: "Docs",
|
||||
url: "https://docs.example.com/path",
|
||||
description: "Reference docs",
|
||||
content: "Body",
|
||||
published: undefined,
|
||||
siteName: "docs.example.com",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("extracts search items from Firecrawl v2 data.web payloads", () => {
|
||||
const items = firecrawlClientTesting.resolveSearchItems({
|
||||
success: true,
|
||||
data: {
|
||||
web: [
|
||||
{
|
||||
title: "API Platform - OpenAI",
|
||||
url: "https://openai.com/api/",
|
||||
description: "Build on the OpenAI API platform.",
|
||||
markdown: "# API Platform",
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(items).toEqual([
|
||||
{
|
||||
title: "API Platform - OpenAI",
|
||||
url: "https://openai.com/api/",
|
||||
description: "Build on the OpenAI API platform.",
|
||||
content: "# API Platform",
|
||||
published: undefined,
|
||||
siteName: "openai.com",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,85 @@ describe("firecrawl tools", () => {
|
||||
expect(applied.plugins?.entries?.firecrawl?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("parses scrape payloads into wrapped external-content results", () => {
|
||||
const result = firecrawlClientTesting.parseFirecrawlScrapePayload({
|
||||
payload: {
|
||||
success: true,
|
||||
data: {
|
||||
markdown: "# Hello\n\nWorld",
|
||||
metadata: {
|
||||
title: "Example page",
|
||||
sourceURL: "https://example.com/final",
|
||||
statusCode: 200,
|
||||
},
|
||||
},
|
||||
},
|
||||
url: "https://example.com/start",
|
||||
extractMode: "text",
|
||||
maxChars: 1000,
|
||||
});
|
||||
|
||||
expect(result.finalUrl).toBe("https://example.com/final");
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.extractor).toBe("firecrawl");
|
||||
expect(String(result.text)).toContain("Hello");
|
||||
expect(String(result.text)).toContain("World");
|
||||
expect(result.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it("extracts search items from flexible Firecrawl payload shapes", () => {
|
||||
const items = firecrawlClientTesting.resolveSearchItems({
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
title: "Docs",
|
||||
url: "https://docs.example.com/path",
|
||||
description: "Reference docs",
|
||||
markdown: "Body",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toEqual([
|
||||
{
|
||||
title: "Docs",
|
||||
url: "https://docs.example.com/path",
|
||||
description: "Reference docs",
|
||||
content: "Body",
|
||||
published: undefined,
|
||||
siteName: "docs.example.com",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("extracts search items from Firecrawl v2 data.web payloads", () => {
|
||||
const items = firecrawlClientTesting.resolveSearchItems({
|
||||
success: true,
|
||||
data: {
|
||||
web: [
|
||||
{
|
||||
title: "API Platform - OpenAI",
|
||||
url: "https://openai.com/api/",
|
||||
description: "Build on the OpenAI API platform.",
|
||||
markdown: "# API Platform",
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(items).toEqual([
|
||||
{
|
||||
title: "API Platform - OpenAI",
|
||||
url: "https://openai.com/api/",
|
||||
description: "Build on the OpenAI API platform.",
|
||||
content: "# API Platform",
|
||||
published: undefined,
|
||||
siteName: "openai.com",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps generic provider args into firecrawl search params", async () => {
|
||||
const provider = createFirecrawlWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildCopilotModelDefinition, getDefaultCopilotModelIds } from "./models-defaults.js";
|
||||
|
||||
describe("github-copilot model defaults", () => {
|
||||
describe("getDefaultCopilotModelIds", () => {
|
||||
it("includes claude-sonnet-4.6", () => {
|
||||
expect(getDefaultCopilotModelIds()).toContain("claude-sonnet-4.6");
|
||||
});
|
||||
|
||||
it("includes claude-sonnet-4.5", () => {
|
||||
expect(getDefaultCopilotModelIds()).toContain("claude-sonnet-4.5");
|
||||
});
|
||||
|
||||
it("returns a mutable copy", () => {
|
||||
const a = getDefaultCopilotModelIds();
|
||||
const b = getDefaultCopilotModelIds();
|
||||
expect(a).not.toBe(b);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCopilotModelDefinition", () => {
|
||||
it("builds a valid definition for claude-sonnet-4.6", () => {
|
||||
const def = buildCopilotModelDefinition("claude-sonnet-4.6");
|
||||
expect(def.id).toBe("claude-sonnet-4.6");
|
||||
expect(def.api).toBe("openai-responses");
|
||||
});
|
||||
|
||||
it("trims whitespace from model id", () => {
|
||||
const def = buildCopilotModelDefinition(" gpt-4o ");
|
||||
expect(def.id).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("throws on empty model id", () => {
|
||||
expect(() => buildCopilotModelDefinition("")).toThrow("Model id required");
|
||||
expect(() => buildCopilotModelDefinition(" ")).toThrow("Model id required");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createProviderUsageFetch,
|
||||
makeResponse,
|
||||
} from "../../test/helpers/extensions/provider-usage-fetch.js";
|
||||
import { buildCopilotModelDefinition, getDefaultCopilotModelIds } from "./models-defaults.js";
|
||||
import { fetchCopilotUsage } from "./usage.js";
|
||||
|
||||
vi.mock("@mariozechner/pi-ai/oauth", async () => {
|
||||
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai/oauth")>(
|
||||
@@ -15,9 +21,24 @@ vi.mock("openclaw/plugin-sdk/provider-models", () => ({
|
||||
normalizeModelCompat: (model: Record<string, unknown>) => model,
|
||||
}));
|
||||
|
||||
const loadJsonFile = vi.fn();
|
||||
const saveJsonFile = vi.fn();
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/json-store", () => ({
|
||||
loadJsonFile,
|
||||
saveJsonFile,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/state-paths", () => ({
|
||||
resolveStateDir: () => "/tmp/openclaw-state",
|
||||
}));
|
||||
|
||||
import type { ProviderResolveDynamicModelContext } from "openclaw/plugin-sdk/core";
|
||||
import { resolveCopilotForwardCompatModel } from "./models.js";
|
||||
|
||||
let deriveCopilotApiBaseUrlFromToken: typeof import("./token.js").deriveCopilotApiBaseUrlFromToken;
|
||||
let resolveCopilotApiToken: typeof import("./token.js").resolveCopilotApiToken;
|
||||
|
||||
function createMockCtx(
|
||||
modelId: string,
|
||||
registryModels: Record<string, Record<string, unknown>> = {},
|
||||
@@ -40,6 +61,43 @@ function requireResolvedModel(ctx: ProviderResolveDynamicModelContext) {
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("github-copilot model defaults", () => {
|
||||
describe("getDefaultCopilotModelIds", () => {
|
||||
it("includes claude-sonnet-4.6", () => {
|
||||
expect(getDefaultCopilotModelIds()).toContain("claude-sonnet-4.6");
|
||||
});
|
||||
|
||||
it("includes claude-sonnet-4.5", () => {
|
||||
expect(getDefaultCopilotModelIds()).toContain("claude-sonnet-4.5");
|
||||
});
|
||||
|
||||
it("returns a mutable copy", () => {
|
||||
const a = getDefaultCopilotModelIds();
|
||||
const b = getDefaultCopilotModelIds();
|
||||
expect(a).not.toBe(b);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCopilotModelDefinition", () => {
|
||||
it("builds a valid definition for claude-sonnet-4.6", () => {
|
||||
const def = buildCopilotModelDefinition("claude-sonnet-4.6");
|
||||
expect(def.id).toBe("claude-sonnet-4.6");
|
||||
expect(def.api).toBe("openai-responses");
|
||||
});
|
||||
|
||||
it("trims whitespace from model id", () => {
|
||||
const def = buildCopilotModelDefinition(" gpt-4o ");
|
||||
expect(def.id).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("throws on empty model id", () => {
|
||||
expect(() => buildCopilotModelDefinition("")).toThrow("Model id required");
|
||||
expect(() => buildCopilotModelDefinition(" ")).toThrow("Model id required");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCopilotForwardCompatModel", () => {
|
||||
it("returns undefined for empty modelId", () => {
|
||||
expect(resolveCopilotForwardCompatModel(createMockCtx(""))).toBeUndefined();
|
||||
@@ -108,3 +166,141 @@ describe("resolveCopilotForwardCompatModel", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchCopilotUsage", () => {
|
||||
it("returns HTTP errors for failed requests", async () => {
|
||||
const mockFetch = createProviderUsageFetch(async () => makeResponse(500, "boom"));
|
||||
const result = await fetchCopilotUsage("token", 5000, mockFetch);
|
||||
|
||||
expect(result.error).toBe("HTTP 500");
|
||||
expect(result.windows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("parses premium/chat usage from remaining percentages", async () => {
|
||||
const mockFetch = createProviderUsageFetch(async (_url, init) => {
|
||||
const headers = (init?.headers as Record<string, string> | undefined) ?? {};
|
||||
expect(headers.Authorization).toBe("token token");
|
||||
expect(headers["X-Github-Api-Version"]).toBe("2025-04-01");
|
||||
|
||||
return makeResponse(200, {
|
||||
quota_snapshots: {
|
||||
premium_interactions: { percent_remaining: 20 },
|
||||
chat: { percent_remaining: 75 },
|
||||
},
|
||||
copilot_plan: "pro",
|
||||
});
|
||||
});
|
||||
|
||||
const result = await fetchCopilotUsage("token", 5000, mockFetch);
|
||||
|
||||
expect(result.plan).toBe("pro");
|
||||
expect(result.windows).toEqual([
|
||||
{ label: "Premium", usedPercent: 80 },
|
||||
{ label: "Chat", usedPercent: 25 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("defaults missing snapshot values and clamps invalid remaining percentages", async () => {
|
||||
const mockFetch = createProviderUsageFetch(async () =>
|
||||
makeResponse(200, {
|
||||
quota_snapshots: {
|
||||
premium_interactions: { percent_remaining: null },
|
||||
chat: { percent_remaining: 140 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetchCopilotUsage("token", 5000, mockFetch);
|
||||
|
||||
expect(result.windows).toEqual([
|
||||
{ label: "Premium", usedPercent: 100 },
|
||||
{ label: "Chat", usedPercent: 0 },
|
||||
]);
|
||||
expect(result.plan).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns an empty window list when quota snapshots are missing", async () => {
|
||||
const mockFetch = createProviderUsageFetch(async () =>
|
||||
makeResponse(200, {
|
||||
copilot_plan: "free",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetchCopilotUsage("token", 5000, mockFetch);
|
||||
|
||||
expect(result).toEqual({
|
||||
provider: "github-copilot",
|
||||
displayName: "Copilot",
|
||||
windows: [],
|
||||
plan: "free",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("github-copilot token", () => {
|
||||
const cachePath = "/tmp/openclaw-state/credentials/github-copilot.token.json";
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
loadJsonFile.mockClear();
|
||||
saveJsonFile.mockClear();
|
||||
({ deriveCopilotApiBaseUrlFromToken, resolveCopilotApiToken } = await import("./token.js"));
|
||||
});
|
||||
|
||||
it("derives baseUrl from token", async () => {
|
||||
expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=proxy.example.com;")).toBe(
|
||||
"https://api.example.com",
|
||||
);
|
||||
expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=https://proxy.foo.bar;")).toBe(
|
||||
"https://api.foo.bar",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses cache when token is still valid", async () => {
|
||||
const now = Date.now();
|
||||
loadJsonFile.mockReturnValue({
|
||||
token: "cached;proxy-ep=proxy.example.com;",
|
||||
expiresAt: now + 60 * 60 * 1000,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const fetchImpl = vi.fn();
|
||||
const res = await resolveCopilotApiToken({
|
||||
githubToken: "gh",
|
||||
cachePath,
|
||||
loadJsonFileImpl: loadJsonFile,
|
||||
saveJsonFileImpl: saveJsonFile,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(res.token).toBe("cached;proxy-ep=proxy.example.com;");
|
||||
expect(res.baseUrl).toBe("https://api.example.com");
|
||||
expect(String(res.source)).toContain("cache:");
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches and stores token when cache is missing", async () => {
|
||||
loadJsonFile.mockReturnValue(undefined);
|
||||
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
token: "fresh;proxy-ep=https://proxy.contoso.test;",
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await resolveCopilotApiToken({
|
||||
githubToken: "gh",
|
||||
cachePath,
|
||||
loadJsonFileImpl: loadJsonFile,
|
||||
saveJsonFileImpl: saveJsonFile,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(res.token).toBe("fresh;proxy-ep=https://proxy.contoso.test;");
|
||||
expect(res.baseUrl).toBe("https://api.contoso.test");
|
||||
expect(saveJsonFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const loadJsonFile = vi.fn();
|
||||
const saveJsonFile = vi.fn();
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/json-store", () => ({
|
||||
loadJsonFile,
|
||||
saveJsonFile,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/state-paths", () => ({
|
||||
resolveStateDir: () => "/tmp/openclaw-state",
|
||||
}));
|
||||
|
||||
let deriveCopilotApiBaseUrlFromToken: typeof import("./token.js").deriveCopilotApiBaseUrlFromToken;
|
||||
let resolveCopilotApiToken: typeof import("./token.js").resolveCopilotApiToken;
|
||||
|
||||
describe("github-copilot token", () => {
|
||||
const cachePath = "/tmp/openclaw-state/credentials/github-copilot.token.json";
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
loadJsonFile.mockClear();
|
||||
saveJsonFile.mockClear();
|
||||
({ deriveCopilotApiBaseUrlFromToken, resolveCopilotApiToken } = await import("./token.js"));
|
||||
});
|
||||
|
||||
it("derives baseUrl from token", async () => {
|
||||
expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=proxy.example.com;")).toBe(
|
||||
"https://api.example.com",
|
||||
);
|
||||
expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=https://proxy.foo.bar;")).toBe(
|
||||
"https://api.foo.bar",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses cache when token is still valid", async () => {
|
||||
const now = Date.now();
|
||||
loadJsonFile.mockReturnValue({
|
||||
token: "cached;proxy-ep=proxy.example.com;",
|
||||
expiresAt: now + 60 * 60 * 1000,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const fetchImpl = vi.fn();
|
||||
const res = await resolveCopilotApiToken({
|
||||
githubToken: "gh",
|
||||
cachePath,
|
||||
loadJsonFileImpl: loadJsonFile,
|
||||
saveJsonFileImpl: saveJsonFile,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(res.token).toBe("cached;proxy-ep=proxy.example.com;");
|
||||
expect(res.baseUrl).toBe("https://api.example.com");
|
||||
expect(String(res.source)).toContain("cache:");
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches and stores token when cache is missing", async () => {
|
||||
loadJsonFile.mockReturnValue(undefined);
|
||||
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
token: "fresh;proxy-ep=https://proxy.contoso.test;",
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await resolveCopilotApiToken({
|
||||
githubToken: "gh",
|
||||
cachePath,
|
||||
loadJsonFileImpl: loadJsonFile,
|
||||
saveJsonFileImpl: saveJsonFile,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(res.token).toBe("fresh;proxy-ep=https://proxy.contoso.test;");
|
||||
expect(res.baseUrl).toBe("https://api.contoso.test");
|
||||
expect(saveJsonFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createProviderUsageFetch,
|
||||
makeResponse,
|
||||
} from "../../test/helpers/extensions/provider-usage-fetch.js";
|
||||
import { fetchCopilotUsage } from "./usage.js";
|
||||
|
||||
describe("fetchCopilotUsage", () => {
|
||||
it("returns HTTP errors for failed requests", async () => {
|
||||
const mockFetch = createProviderUsageFetch(async () => makeResponse(500, "boom"));
|
||||
const result = await fetchCopilotUsage("token", 5000, mockFetch);
|
||||
|
||||
expect(result.error).toBe("HTTP 500");
|
||||
expect(result.windows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("parses premium/chat usage from remaining percentages", async () => {
|
||||
const mockFetch = createProviderUsageFetch(async (_url, init) => {
|
||||
const headers = (init?.headers as Record<string, string> | undefined) ?? {};
|
||||
expect(headers.Authorization).toBe("token token");
|
||||
expect(headers["X-Github-Api-Version"]).toBe("2025-04-01");
|
||||
|
||||
return makeResponse(200, {
|
||||
quota_snapshots: {
|
||||
premium_interactions: { percent_remaining: 20 },
|
||||
chat: { percent_remaining: 75 },
|
||||
},
|
||||
copilot_plan: "pro",
|
||||
});
|
||||
});
|
||||
|
||||
const result = await fetchCopilotUsage("token", 5000, mockFetch);
|
||||
|
||||
expect(result.plan).toBe("pro");
|
||||
expect(result.windows).toEqual([
|
||||
{ label: "Premium", usedPercent: 80 },
|
||||
{ label: "Chat", usedPercent: 25 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("defaults missing snapshot values and clamps invalid remaining percentages", async () => {
|
||||
const mockFetch = createProviderUsageFetch(async () =>
|
||||
makeResponse(200, {
|
||||
quota_snapshots: {
|
||||
premium_interactions: { percent_remaining: null },
|
||||
chat: { percent_remaining: 140 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetchCopilotUsage("token", 5000, mockFetch);
|
||||
|
||||
expect(result.windows).toEqual([
|
||||
{ label: "Premium", usedPercent: 100 },
|
||||
{ label: "Chat", usedPercent: 0 },
|
||||
]);
|
||||
expect(result.plan).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns an empty window list when quota snapshots are missing", async () => {
|
||||
const mockFetch = createProviderUsageFetch(async () =>
|
||||
makeResponse(200, {
|
||||
copilot_plan: "free",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetchCopilotUsage("token", 5000, mockFetch);
|
||||
|
||||
expect(result).toEqual({
|
||||
provider: "github-copilot",
|
||||
displayName: "Copilot",
|
||||
windows: [],
|
||||
plan: "free",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as providerAuth from "openclaw/plugin-sdk/provider-auth";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildGoogleImageGenerationProvider } from "./image-generation-provider.js";
|
||||
import { __testing as geminiWebSearchTesting } from "./src/gemini-web-search-provider.js";
|
||||
|
||||
function mockGoogleApiKeyAuth() {
|
||||
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
@@ -282,4 +283,20 @@ describe("Google image-generation provider", () => {
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers scoped configured Gemini API keys over environment fallbacks", () => {
|
||||
expect(
|
||||
geminiWebSearchTesting.resolveGeminiApiKey({
|
||||
apiKey: "gemini-secret",
|
||||
}),
|
||||
).toBe("gemini-secret");
|
||||
});
|
||||
|
||||
it("falls back to the default Gemini model when unset or blank", () => {
|
||||
expect(geminiWebSearchTesting.resolveGeminiModel()).toBe("gemini-2.5-flash");
|
||||
expect(geminiWebSearchTesting.resolveGeminiModel({ model: " " })).toBe("gemini-2.5-flash");
|
||||
expect(geminiWebSearchTesting.resolveGeminiModel({ model: "gemini-2.5-pro" })).toBe(
|
||||
"gemini-2.5-pro",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __testing } from "./gemini-web-search-provider.js";
|
||||
|
||||
describe("gemini web search provider", () => {
|
||||
it("prefers scoped configured api keys over environment fallbacks", () => {
|
||||
expect(
|
||||
__testing.resolveGeminiApiKey({
|
||||
apiKey: "gemini-secret",
|
||||
}),
|
||||
).toBe("gemini-secret");
|
||||
});
|
||||
|
||||
it("falls back to the default Gemini model when unset or blank", () => {
|
||||
expect(__testing.resolveGeminiModel()).toBe("gemini-2.5-flash");
|
||||
expect(__testing.resolveGeminiModel({ model: " " })).toBe("gemini-2.5-flash");
|
||||
expect(__testing.resolveGeminiModel({ model: "gemini-2.5-pro" })).toBe("gemini-2.5-pro");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
setProcessPlatform,
|
||||
snapshotPlatformPathEnv,
|
||||
} from "./test-helpers.js";
|
||||
import { resolveWindowsLobsterSpawn } from "./windows-spawn.js";
|
||||
|
||||
const spawnState = vi.hoisted(() => ({
|
||||
queue: [] as Array<{ stdout: string; stderr?: string; exitCode?: number }>,
|
||||
@@ -75,6 +76,19 @@ function fakeCtx(overrides: Partial<OpenClawPluginToolContext> = {}): OpenClawPl
|
||||
};
|
||||
}
|
||||
|
||||
async function expectUnwrappedShim(params: {
|
||||
scriptPath: string;
|
||||
shimPath: string;
|
||||
shimLine: string;
|
||||
}) {
|
||||
await createWindowsCmdShimFixture(params);
|
||||
|
||||
const target = resolveWindowsLobsterSpawn(params.shimPath, ["run", "noop"], process.env);
|
||||
expect(target.command).toBe(process.execPath);
|
||||
expect(target.argv).toEqual([params.scriptPath, "run", "noop"]);
|
||||
expect(target.windowsHide).toBe(true);
|
||||
}
|
||||
|
||||
describe("lobster plugin tool", () => {
|
||||
let tempDir = "";
|
||||
const originalProcessState = snapshotPlatformPathEnv();
|
||||
@@ -317,3 +331,97 @@ describe("lobster plugin tool", () => {
|
||||
expect(factoryTool(fakeCtx({ sandboxed: false }))?.name).toBe("lobster");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWindowsLobsterSpawn", () => {
|
||||
let tempDir = "";
|
||||
const originalProcessState = snapshotPlatformPathEnv();
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-win-spawn-"));
|
||||
setProcessPlatform("win32");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
restorePlatformPathEnv(originalProcessState);
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = "";
|
||||
}
|
||||
});
|
||||
|
||||
it("unwraps cmd shim with %dp0% token", async () => {
|
||||
const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
|
||||
const shimPath = path.join(tempDir, "shim", "lobster.cmd");
|
||||
await expectUnwrappedShim({
|
||||
shimPath,
|
||||
scriptPath,
|
||||
shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`,
|
||||
});
|
||||
});
|
||||
|
||||
it("unwraps cmd shim with %~dp0% token", async () => {
|
||||
const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
|
||||
const shimPath = path.join(tempDir, "shim", "lobster.cmd");
|
||||
await expectUnwrappedShim({
|
||||
shimPath,
|
||||
scriptPath,
|
||||
shimLine: `"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores node.exe shim entries and picks lobster script", async () => {
|
||||
const shimDir = path.join(tempDir, "shim-with-node");
|
||||
const scriptPath = path.join(tempDir, "shim-dist-node", "lobster-cli.cjs");
|
||||
const shimPath = path.join(shimDir, "lobster.cmd");
|
||||
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||
await fs.mkdir(shimDir, { recursive: true });
|
||||
await fs.writeFile(path.join(shimDir, "node.exe"), "", "utf8");
|
||||
await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
|
||||
await fs.writeFile(
|
||||
shimPath,
|
||||
`@echo off\r\n"%~dp0%\\node.exe" "%~dp0%\\..\\shim-dist-node\\lobster-cli.cjs" %*\r\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env);
|
||||
expect(target.command).toBe(process.execPath);
|
||||
expect(target.argv).toEqual([scriptPath, "run", "noop"]);
|
||||
expect(target.windowsHide).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves lobster.cmd from PATH and unwraps npm layout shim", async () => {
|
||||
const binDir = path.join(tempDir, "node_modules", ".bin");
|
||||
const packageDir = path.join(tempDir, "node_modules", "lobster");
|
||||
const scriptPath = path.join(packageDir, "dist", "cli.js");
|
||||
const shimPath = path.join(binDir, "lobster.cmd");
|
||||
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await fs.writeFile(shimPath, "@echo off\r\n", "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(packageDir, "package.json"),
|
||||
JSON.stringify({ name: "lobster", version: "0.0.0", bin: { lobster: "dist/cli.js" } }),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
PATH: `${binDir};${process.env.PATH ?? ""}`,
|
||||
PATHEXT: ".CMD;.EXE",
|
||||
};
|
||||
const target = resolveWindowsLobsterSpawn("lobster", ["run", "noop"], env);
|
||||
expect(target.command).toBe(process.execPath);
|
||||
expect(target.argv).toEqual([scriptPath, "run", "noop"]);
|
||||
expect(target.windowsHide).toBe(true);
|
||||
});
|
||||
|
||||
it("fails fast when wrapper cannot be resolved without shell execution", async () => {
|
||||
const badShimPath = path.join(tempDir, "bad-shim", "lobster.cmd");
|
||||
await fs.mkdir(path.dirname(badShimPath), { recursive: true });
|
||||
await fs.writeFile(badShimPath, "@echo off\r\nREM no entrypoint\r\n", "utf8");
|
||||
|
||||
expect(() => resolveWindowsLobsterSpawn(badShimPath, ["run", "noop"], process.env)).toThrow(
|
||||
/without shell execution/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createWindowsCmdShimFixture,
|
||||
restorePlatformPathEnv,
|
||||
setProcessPlatform,
|
||||
snapshotPlatformPathEnv,
|
||||
} from "./test-helpers.js";
|
||||
import { resolveWindowsLobsterSpawn } from "./windows-spawn.js";
|
||||
|
||||
describe("resolveWindowsLobsterSpawn", () => {
|
||||
let tempDir = "";
|
||||
const originalProcessState = snapshotPlatformPathEnv();
|
||||
|
||||
async function expectUnwrappedShim(params: {
|
||||
scriptPath: string;
|
||||
shimPath: string;
|
||||
shimLine: string;
|
||||
}) {
|
||||
await createWindowsCmdShimFixture(params);
|
||||
|
||||
const target = resolveWindowsLobsterSpawn(params.shimPath, ["run", "noop"], process.env);
|
||||
expect(target.command).toBe(process.execPath);
|
||||
expect(target.argv).toEqual([params.scriptPath, "run", "noop"]);
|
||||
expect(target.windowsHide).toBe(true);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-win-spawn-"));
|
||||
setProcessPlatform("win32");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
restorePlatformPathEnv(originalProcessState);
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = "";
|
||||
}
|
||||
});
|
||||
|
||||
it("unwraps cmd shim with %dp0% token", async () => {
|
||||
const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
|
||||
const shimPath = path.join(tempDir, "shim", "lobster.cmd");
|
||||
await expectUnwrappedShim({
|
||||
shimPath,
|
||||
scriptPath,
|
||||
shimLine: `"%dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`,
|
||||
});
|
||||
});
|
||||
|
||||
it("unwraps cmd shim with %~dp0% token", async () => {
|
||||
const scriptPath = path.join(tempDir, "shim-dist", "lobster-cli.cjs");
|
||||
const shimPath = path.join(tempDir, "shim", "lobster.cmd");
|
||||
await expectUnwrappedShim({
|
||||
shimPath,
|
||||
scriptPath,
|
||||
shimLine: `"%~dp0%\\..\\shim-dist\\lobster-cli.cjs" %*`,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores node.exe shim entries and picks lobster script", async () => {
|
||||
const shimDir = path.join(tempDir, "shim-with-node");
|
||||
const scriptPath = path.join(tempDir, "shim-dist-node", "lobster-cli.cjs");
|
||||
const shimPath = path.join(shimDir, "lobster.cmd");
|
||||
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||
await fs.mkdir(shimDir, { recursive: true });
|
||||
await fs.writeFile(path.join(shimDir, "node.exe"), "", "utf8");
|
||||
await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
|
||||
await fs.writeFile(
|
||||
shimPath,
|
||||
`@echo off\r\n"%~dp0%\\node.exe" "%~dp0%\\..\\shim-dist-node\\lobster-cli.cjs" %*\r\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const target = resolveWindowsLobsterSpawn(shimPath, ["run", "noop"], process.env);
|
||||
expect(target.command).toBe(process.execPath);
|
||||
expect(target.argv).toEqual([scriptPath, "run", "noop"]);
|
||||
expect(target.windowsHide).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves lobster.cmd from PATH and unwraps npm layout shim", async () => {
|
||||
const binDir = path.join(tempDir, "node_modules", ".bin");
|
||||
const packageDir = path.join(tempDir, "node_modules", "lobster");
|
||||
const scriptPath = path.join(packageDir, "dist", "cli.js");
|
||||
const shimPath = path.join(binDir, "lobster.cmd");
|
||||
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
|
||||
await fs.mkdir(binDir, { recursive: true });
|
||||
await fs.writeFile(shimPath, "@echo off\r\n", "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(packageDir, "package.json"),
|
||||
JSON.stringify({ name: "lobster", version: "0.0.0", bin: { lobster: "dist/cli.js" } }),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(scriptPath, "module.exports = {};\n", "utf8");
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
PATH: `${binDir};${process.env.PATH ?? ""}`,
|
||||
PATHEXT: ".CMD;.EXE",
|
||||
};
|
||||
const target = resolveWindowsLobsterSpawn("lobster", ["run", "noop"], env);
|
||||
expect(target.command).toBe(process.execPath);
|
||||
expect(target.argv).toEqual([scriptPath, "run", "noop"]);
|
||||
expect(target.windowsHide).toBe(true);
|
||||
});
|
||||
|
||||
it("fails fast when wrapper cannot be resolved without shell execution", async () => {
|
||||
const badShimPath = path.join(tempDir, "bad-shim", "lobster.cmd");
|
||||
await fs.mkdir(path.dirname(badShimPath), { recursive: true });
|
||||
await fs.writeFile(badShimPath, "@echo off\r\nREM no entrypoint\r\n", "utf8");
|
||||
|
||||
expect(() => resolveWindowsLobsterSpawn(badShimPath, ["run", "noop"], process.env)).toThrow(
|
||||
/without shell execution/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { createLanceDbRuntimeLoader, type LanceDbRuntimeLogger } from "./lancedb-runtime.js";
|
||||
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "test-key";
|
||||
const HAS_OPENAI_KEY = Boolean(process.env.OPENAI_API_KEY);
|
||||
@@ -30,6 +31,23 @@ type MemoryPluginTestConfig = {
|
||||
autoRecall?: boolean;
|
||||
};
|
||||
|
||||
const TEST_RUNTIME_MANIFEST = {
|
||||
name: "openclaw-memory-lancedb-runtime",
|
||||
private: true as const,
|
||||
type: "module" as const,
|
||||
dependencies: {
|
||||
"@lancedb/lancedb": "^0.27.1",
|
||||
},
|
||||
};
|
||||
|
||||
type LanceDbModule = typeof import("@lancedb/lancedb");
|
||||
type RuntimeManifest = {
|
||||
name: string;
|
||||
private: true;
|
||||
type: "module";
|
||||
dependencies: Record<string, string>;
|
||||
};
|
||||
|
||||
function installTmpDirHarness(params: { prefix: string }) {
|
||||
let tmpDir = "";
|
||||
let dbPath = "";
|
||||
@@ -51,6 +69,47 @@ function installTmpDirHarness(params: { prefix: string }) {
|
||||
};
|
||||
}
|
||||
|
||||
function createMockModule(): LanceDbModule {
|
||||
return {
|
||||
connect: vi.fn(),
|
||||
} as unknown as LanceDbModule;
|
||||
}
|
||||
|
||||
function createRuntimeLoader(
|
||||
overrides: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
importBundled?: () => Promise<LanceDbModule>;
|
||||
importResolved?: (resolvedPath: string) => Promise<LanceDbModule>;
|
||||
resolveRuntimeEntry?: (params: {
|
||||
runtimeDir: string;
|
||||
manifest: RuntimeManifest;
|
||||
}) => string | null;
|
||||
installRuntime?: (params: {
|
||||
runtimeDir: string;
|
||||
manifest: RuntimeManifest;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger?: LanceDbRuntimeLogger;
|
||||
}) => Promise<string>;
|
||||
} = {},
|
||||
) {
|
||||
return createLanceDbRuntimeLoader({
|
||||
env: overrides.env ?? ({} as NodeJS.ProcessEnv),
|
||||
resolveStateDir: () => "/tmp/openclaw-state",
|
||||
runtimeManifest: TEST_RUNTIME_MANIFEST,
|
||||
importBundled:
|
||||
overrides.importBundled ??
|
||||
(async () => {
|
||||
throw new Error("Cannot find package '@lancedb/lancedb'");
|
||||
}),
|
||||
importResolved: overrides.importResolved ?? (async () => createMockModule()),
|
||||
resolveRuntimeEntry: overrides.resolveRuntimeEntry ?? (() => null),
|
||||
installRuntime:
|
||||
overrides.installRuntime ??
|
||||
(async ({ runtimeDir }: { runtimeDir: string }) =>
|
||||
`${runtimeDir}/node_modules/@lancedb/lancedb/index.js`),
|
||||
});
|
||||
}
|
||||
|
||||
describe("memory plugin e2e", () => {
|
||||
const { getDbPath } = installTmpDirHarness({ prefix: "openclaw-memory-test-" });
|
||||
|
||||
@@ -289,6 +348,122 @@ describe("memory plugin e2e", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("lancedb runtime loader", () => {
|
||||
test("uses the bundled module when it is already available", async () => {
|
||||
const bundledModule = createMockModule();
|
||||
const importBundled = vi.fn(async () => bundledModule);
|
||||
const importResolved = vi.fn(async () => createMockModule());
|
||||
const resolveRuntimeEntry = vi.fn(() => null);
|
||||
const installRuntime = vi.fn(async () => "/tmp/openclaw-state/plugin-runtimes/lancedb.js");
|
||||
const loader = createRuntimeLoader({
|
||||
importBundled,
|
||||
importResolved,
|
||||
resolveRuntimeEntry,
|
||||
installRuntime,
|
||||
});
|
||||
|
||||
await expect(loader.load()).resolves.toBe(bundledModule);
|
||||
|
||||
expect(resolveRuntimeEntry).not.toHaveBeenCalled();
|
||||
expect(installRuntime).not.toHaveBeenCalled();
|
||||
expect(importResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("reuses an existing user runtime install before attempting a reinstall", async () => {
|
||||
const runtimeModule = createMockModule();
|
||||
const importResolved = vi.fn(async () => runtimeModule);
|
||||
const resolveRuntimeEntry = vi.fn(
|
||||
() => "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/runtime-entry.js",
|
||||
);
|
||||
const installRuntime = vi.fn(
|
||||
async () => "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/runtime-entry.js",
|
||||
);
|
||||
const loader = createRuntimeLoader({
|
||||
importResolved,
|
||||
resolveRuntimeEntry,
|
||||
installRuntime,
|
||||
});
|
||||
|
||||
await expect(loader.load()).resolves.toBe(runtimeModule);
|
||||
|
||||
expect(resolveRuntimeEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runtimeDir: "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb",
|
||||
}),
|
||||
);
|
||||
expect(installRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("installs LanceDB into user state when the bundled runtime is unavailable", async () => {
|
||||
const runtimeModule = createMockModule();
|
||||
const logger: LanceDbRuntimeLogger = {
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
};
|
||||
const importResolved = vi.fn(async () => runtimeModule);
|
||||
const resolveRuntimeEntry = vi.fn(() => null);
|
||||
const installRuntime = vi.fn(
|
||||
async ({ runtimeDir }: { runtimeDir: string }) =>
|
||||
`${runtimeDir}/node_modules/@lancedb/lancedb/index.js`,
|
||||
);
|
||||
const loader = createRuntimeLoader({
|
||||
importResolved,
|
||||
resolveRuntimeEntry,
|
||||
installRuntime,
|
||||
});
|
||||
|
||||
await expect(loader.load(logger)).resolves.toBe(runtimeModule);
|
||||
|
||||
expect(installRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runtimeDir: "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb",
|
||||
manifest: TEST_RUNTIME_MANIFEST,
|
||||
}),
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"installing runtime deps under /tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("fails fast in nix mode instead of attempting auto-install", async () => {
|
||||
const installRuntime = vi.fn(
|
||||
async ({ runtimeDir }: { runtimeDir: string }) =>
|
||||
`${runtimeDir}/node_modules/@lancedb/lancedb/index.js`,
|
||||
);
|
||||
const loader = createRuntimeLoader({
|
||||
env: { OPENCLAW_NIX_MODE: "1" } as NodeJS.ProcessEnv,
|
||||
installRuntime,
|
||||
});
|
||||
|
||||
await expect(loader.load()).rejects.toThrow(
|
||||
"memory-lancedb: failed to load LanceDB and Nix mode disables auto-install.",
|
||||
);
|
||||
expect(installRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("clears the cached failure so later calls can retry the install", async () => {
|
||||
const runtimeModule = createMockModule();
|
||||
const installRuntime = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("network down"))
|
||||
.mockResolvedValueOnce(
|
||||
"/tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb/node_modules/@lancedb/lancedb/index.js",
|
||||
);
|
||||
const importResolved = vi.fn(async () => runtimeModule);
|
||||
const loader = createRuntimeLoader({
|
||||
installRuntime,
|
||||
importResolved,
|
||||
});
|
||||
|
||||
await expect(loader.load()).rejects.toThrow("network down");
|
||||
await expect(loader.load()).resolves.toBe(runtimeModule);
|
||||
|
||||
expect(installRuntime).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
// Live tests that require OpenAI API key and actually use LanceDB
|
||||
describeLive("memory plugin live tests", () => {
|
||||
const { getDbPath } = installTmpDirHarness({ prefix: "openclaw-memory-live-" });
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createLanceDbRuntimeLoader, type LanceDbRuntimeLogger } from "./lancedb-runtime.js";
|
||||
|
||||
const TEST_RUNTIME_MANIFEST = {
|
||||
name: "openclaw-memory-lancedb-runtime",
|
||||
private: true as const,
|
||||
type: "module" as const,
|
||||
dependencies: {
|
||||
"@lancedb/lancedb": "^0.27.1",
|
||||
},
|
||||
};
|
||||
|
||||
type LanceDbModule = typeof import("@lancedb/lancedb");
|
||||
type RuntimeManifest = {
|
||||
name: string;
|
||||
private: true;
|
||||
type: "module";
|
||||
dependencies: Record<string, string>;
|
||||
};
|
||||
|
||||
function createMockModule(): LanceDbModule {
|
||||
return {
|
||||
connect: vi.fn(),
|
||||
} as unknown as LanceDbModule;
|
||||
}
|
||||
|
||||
function createLoader(
|
||||
overrides: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
importBundled?: () => Promise<LanceDbModule>;
|
||||
importResolved?: (resolvedPath: string) => Promise<LanceDbModule>;
|
||||
resolveRuntimeEntry?: (params: {
|
||||
runtimeDir: string;
|
||||
manifest: RuntimeManifest;
|
||||
}) => string | null;
|
||||
installRuntime?: (params: {
|
||||
runtimeDir: string;
|
||||
manifest: RuntimeManifest;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger?: LanceDbRuntimeLogger;
|
||||
}) => Promise<string>;
|
||||
} = {},
|
||||
) {
|
||||
return createLanceDbRuntimeLoader({
|
||||
env: overrides.env ?? ({} as NodeJS.ProcessEnv),
|
||||
resolveStateDir: () => "/tmp/openclaw-state",
|
||||
runtimeManifest: TEST_RUNTIME_MANIFEST,
|
||||
importBundled:
|
||||
overrides.importBundled ??
|
||||
(async () => {
|
||||
throw new Error("Cannot find package '@lancedb/lancedb'");
|
||||
}),
|
||||
importResolved: overrides.importResolved ?? (async () => createMockModule()),
|
||||
resolveRuntimeEntry: overrides.resolveRuntimeEntry ?? (() => null),
|
||||
installRuntime:
|
||||
overrides.installRuntime ??
|
||||
(async ({ runtimeDir }: { runtimeDir: string }) =>
|
||||
`${runtimeDir}/node_modules/@lancedb/lancedb/index.js`),
|
||||
});
|
||||
}
|
||||
|
||||
describe("lancedb runtime loader", () => {
|
||||
it("uses the bundled module when it is already available", async () => {
|
||||
const bundledModule = createMockModule();
|
||||
const importBundled = vi.fn(async () => bundledModule);
|
||||
const importResolved = vi.fn(async () => createMockModule());
|
||||
const resolveRuntimeEntry = vi.fn(() => null);
|
||||
const installRuntime = vi.fn(async () => "/tmp/openclaw-state/plugin-runtimes/lancedb.js");
|
||||
const loader = createLoader({
|
||||
importBundled,
|
||||
importResolved,
|
||||
resolveRuntimeEntry,
|
||||
installRuntime,
|
||||
});
|
||||
|
||||
await expect(loader.load()).resolves.toBe(bundledModule);
|
||||
|
||||
expect(resolveRuntimeEntry).not.toHaveBeenCalled();
|
||||
expect(installRuntime).not.toHaveBeenCalled();
|
||||
expect(importResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses an existing user runtime install before attempting a reinstall", async () => {
|
||||
const runtimeModule = createMockModule();
|
||||
const importResolved = vi.fn(async () => runtimeModule);
|
||||
const resolveRuntimeEntry = vi.fn(
|
||||
() => "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/runtime-entry.js",
|
||||
);
|
||||
const installRuntime = vi.fn(
|
||||
async () => "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/runtime-entry.js",
|
||||
);
|
||||
const loader = createLoader({
|
||||
importResolved,
|
||||
resolveRuntimeEntry,
|
||||
installRuntime,
|
||||
});
|
||||
|
||||
await expect(loader.load()).resolves.toBe(runtimeModule);
|
||||
|
||||
expect(resolveRuntimeEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runtimeDir: "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb",
|
||||
}),
|
||||
);
|
||||
expect(installRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("installs LanceDB into user state when the bundled runtime is unavailable", async () => {
|
||||
const runtimeModule = createMockModule();
|
||||
const logger: LanceDbRuntimeLogger = {
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
};
|
||||
const importResolved = vi.fn(async () => runtimeModule);
|
||||
const resolveRuntimeEntry = vi.fn(() => null);
|
||||
const installRuntime = vi.fn(
|
||||
async ({ runtimeDir }: { runtimeDir: string }) =>
|
||||
`${runtimeDir}/node_modules/@lancedb/lancedb/index.js`,
|
||||
);
|
||||
const loader = createLoader({
|
||||
importResolved,
|
||||
resolveRuntimeEntry,
|
||||
installRuntime,
|
||||
});
|
||||
|
||||
await expect(loader.load(logger)).resolves.toBe(runtimeModule);
|
||||
|
||||
expect(installRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runtimeDir: "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb",
|
||||
manifest: TEST_RUNTIME_MANIFEST,
|
||||
}),
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"installing runtime deps under /tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails fast in nix mode instead of attempting auto-install", async () => {
|
||||
const installRuntime = vi.fn(
|
||||
async ({ runtimeDir }: { runtimeDir: string }) =>
|
||||
`${runtimeDir}/node_modules/@lancedb/lancedb/index.js`,
|
||||
);
|
||||
const loader = createLoader({
|
||||
env: { OPENCLAW_NIX_MODE: "1" } as NodeJS.ProcessEnv,
|
||||
installRuntime,
|
||||
});
|
||||
|
||||
await expect(loader.load()).rejects.toThrow(
|
||||
"memory-lancedb: failed to load LanceDB and Nix mode disables auto-install.",
|
||||
);
|
||||
expect(installRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the cached failure so later calls can retry the install", async () => {
|
||||
const runtimeModule = createMockModule();
|
||||
const installRuntime = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("network down"))
|
||||
.mockResolvedValueOnce(
|
||||
"/tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb/node_modules/@lancedb/lancedb/index.js",
|
||||
);
|
||||
const importResolved = vi.fn(async () => runtimeModule);
|
||||
const loader = createLoader({
|
||||
installRuntime,
|
||||
importResolved,
|
||||
});
|
||||
|
||||
await expect(loader.load()).rejects.toThrow("network down");
|
||||
await expect(loader.load()).resolves.toBe(runtimeModule);
|
||||
|
||||
expect(installRuntime).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import * as providerAuth from "openclaw/plugin-sdk/provider-auth";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildOpenAIImageGenerationProvider } from "./image-generation-provider.js";
|
||||
|
||||
describe("OpenAI image-generation provider", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("generates PNG buffers from the OpenAI Images API", async () => {
|
||||
const resolveApiKeySpy = vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
apiKey: "sk-test",
|
||||
source: "env",
|
||||
mode: "api-key",
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{
|
||||
b64_json: Buffer.from("png-data").toString("base64"),
|
||||
revised_prompt: "revised",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const provider = buildOpenAIImageGenerationProvider();
|
||||
const authStore = { version: 1, profiles: {} };
|
||||
const result = await provider.generateImage({
|
||||
provider: "openai",
|
||||
model: "gpt-image-1",
|
||||
prompt: "draw a cat",
|
||||
cfg: {},
|
||||
authStore,
|
||||
});
|
||||
|
||||
expect(resolveApiKeySpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "openai",
|
||||
store: authStore,
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.openai.com/v1/images/generations",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
model: "gpt-image-1",
|
||||
prompt: "draw a cat",
|
||||
n: 1,
|
||||
size: "1024x1024",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
images: [
|
||||
{
|
||||
buffer: Buffer.from("png-data"),
|
||||
mimeType: "image/png",
|
||||
fileName: "image-1.png",
|
||||
revisedPrompt: "revised",
|
||||
},
|
||||
],
|
||||
model: "gpt-image-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects reference-image edits for now", async () => {
|
||||
const provider = buildOpenAIImageGenerationProvider();
|
||||
|
||||
await expect(
|
||||
provider.generateImage({
|
||||
provider: "openai",
|
||||
model: "gpt-image-1",
|
||||
prompt: "Edit this image",
|
||||
cfg: {},
|
||||
inputImages: [{ buffer: Buffer.from("x"), mimeType: "image/png" }],
|
||||
}),
|
||||
).rejects.toThrow("does not support reference-image edits");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,8 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import OpenAI from "openai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as providerAuth from "openclaw/plugin-sdk/provider-auth";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../src/config/config.js";
|
||||
import { loadConfig } from "../../src/config/config.js";
|
||||
import { encodePngRgba, fillPixel } from "../../src/media/png-encode.js";
|
||||
@@ -11,8 +12,24 @@ import {
|
||||
registerProviderPlugin,
|
||||
requireRegisteredProvider,
|
||||
} from "../../test/helpers/extensions/provider-registration.js";
|
||||
import { buildOpenAIImageGenerationProvider } from "./image-generation-provider.js";
|
||||
import plugin from "./index.js";
|
||||
|
||||
const runtimeMocks = vi.hoisted(() => ({
|
||||
ensureGlobalUndiciEnvProxyDispatcher: vi.fn(),
|
||||
getOAuthApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/infra-runtime", () => ({
|
||||
ensureGlobalUndiciEnvProxyDispatcher: runtimeMocks.ensureGlobalUndiciEnvProxyDispatcher,
|
||||
}));
|
||||
|
||||
vi.mock("@mariozechner/pi-ai/oauth", () => ({
|
||||
getOAuthApiKey: runtimeMocks.getOAuthApiKey,
|
||||
}));
|
||||
|
||||
import { getOAuthApiKey as getCodexOAuthApiKey } from "./openai-codex-provider.runtime.js";
|
||||
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "";
|
||||
const LIVE_MODEL_ID = process.env.OPENCLAW_LIVE_OPENAI_PLUGIN_MODEL?.trim() || "gpt-5.4-nano";
|
||||
const LIVE_IMAGE_MODEL = process.env.OPENCLAW_LIVE_OPENAI_IMAGE_MODEL?.trim() || "gpt-image-1";
|
||||
@@ -164,6 +181,14 @@ async function createTempAgentDir(): Promise<string> {
|
||||
}
|
||||
|
||||
describe("openai plugin", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("registers the expected provider surfaces", () => {
|
||||
const { providers, speechProviders, mediaProviders, imageProviders } = registerOpenAIPlugin();
|
||||
|
||||
@@ -179,6 +204,109 @@ describe("openai plugin", () => {
|
||||
expect(mediaProviders).toHaveLength(1);
|
||||
expect(imageProviders).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("generates PNG buffers from the OpenAI Images API", async () => {
|
||||
const resolveApiKeySpy = vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
apiKey: "sk-test",
|
||||
source: "env",
|
||||
mode: "api-key",
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{
|
||||
b64_json: Buffer.from("png-data").toString("base64"),
|
||||
revised_prompt: "revised",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const provider = buildOpenAIImageGenerationProvider();
|
||||
const authStore = { version: 1, profiles: {} };
|
||||
const result = await provider.generateImage({
|
||||
provider: "openai",
|
||||
model: "gpt-image-1",
|
||||
prompt: "draw a cat",
|
||||
cfg: {},
|
||||
authStore,
|
||||
});
|
||||
|
||||
expect(resolveApiKeySpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "openai",
|
||||
store: authStore,
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.openai.com/v1/images/generations",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
model: "gpt-image-1",
|
||||
prompt: "draw a cat",
|
||||
n: 1,
|
||||
size: "1024x1024",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
images: [
|
||||
{
|
||||
buffer: Buffer.from("png-data"),
|
||||
mimeType: "image/png",
|
||||
fileName: "image-1.png",
|
||||
revisedPrompt: "revised",
|
||||
},
|
||||
],
|
||||
model: "gpt-image-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects reference-image edits for now", async () => {
|
||||
const provider = buildOpenAIImageGenerationProvider();
|
||||
|
||||
await expect(
|
||||
provider.generateImage({
|
||||
provider: "openai",
|
||||
model: "gpt-image-1",
|
||||
prompt: "Edit this image",
|
||||
cfg: {},
|
||||
inputImages: [{ buffer: Buffer.from("x"), mimeType: "image/png" }],
|
||||
}),
|
||||
).rejects.toThrow("does not support reference-image edits");
|
||||
});
|
||||
|
||||
it("bootstraps the env proxy dispatcher before refreshing oauth credentials", async () => {
|
||||
const refreshed = {
|
||||
newCredentials: {
|
||||
access: "next-access",
|
||||
refresh: "next-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
};
|
||||
runtimeMocks.getOAuthApiKey.mockResolvedValue(refreshed);
|
||||
|
||||
await expect(
|
||||
getCodexOAuthApiKey("openai-codex", {
|
||||
"openai-codex": {
|
||||
provider: "openai-codex",
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now(),
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(refreshed);
|
||||
|
||||
expect(runtimeMocks.ensureGlobalUndiciEnvProxyDispatcher).toHaveBeenCalledOnce();
|
||||
expect(runtimeMocks.getOAuthApiKey).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
runtimeMocks.ensureGlobalUndiciEnvProxyDispatcher.mock.invocationCallOrder[0],
|
||||
).toBeLessThan(runtimeMocks.getOAuthApiKey.mock.invocationCallOrder[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describeLive("openai plugin live", () => {
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
ensureGlobalUndiciEnvProxyDispatcher: vi.fn(),
|
||||
getOAuthApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/infra-runtime", () => ({
|
||||
ensureGlobalUndiciEnvProxyDispatcher: mocks.ensureGlobalUndiciEnvProxyDispatcher,
|
||||
}));
|
||||
|
||||
vi.mock("@mariozechner/pi-ai/oauth", () => ({
|
||||
getOAuthApiKey: mocks.getOAuthApiKey,
|
||||
}));
|
||||
|
||||
import { getOAuthApiKey } from "./openai-codex-provider.runtime.js";
|
||||
|
||||
describe("openai-codex-provider.runtime", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("bootstraps the env proxy dispatcher before refreshing oauth credentials", async () => {
|
||||
const refreshed = {
|
||||
newCredentials: {
|
||||
access: "next-access",
|
||||
refresh: "next-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
};
|
||||
mocks.getOAuthApiKey.mockResolvedValue(refreshed);
|
||||
|
||||
await expect(
|
||||
getOAuthApiKey("openai-codex", {
|
||||
"openai-codex": {
|
||||
provider: "openai-codex",
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now(),
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(refreshed);
|
||||
|
||||
expect(mocks.ensureGlobalUndiciEnvProxyDispatcher).toHaveBeenCalledOnce();
|
||||
expect(mocks.getOAuthApiKey).toHaveBeenCalledOnce();
|
||||
expect(mocks.ensureGlobalUndiciEnvProxyDispatcher.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.getOAuthApiKey.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
|
||||
describe("tavily plugin", () => {
|
||||
it("registers web search provider and two tools", () => {
|
||||
const registrations: {
|
||||
webSearchProviders: unknown[];
|
||||
tools: unknown[];
|
||||
} = { webSearchProviders: [], tools: [] };
|
||||
|
||||
const mockApi = {
|
||||
registerWebSearchProvider(provider: unknown) {
|
||||
registrations.webSearchProviders.push(provider);
|
||||
},
|
||||
registerTool(tool: unknown) {
|
||||
registrations.tools.push(tool);
|
||||
},
|
||||
config: {},
|
||||
};
|
||||
|
||||
plugin.register(mockApi as never);
|
||||
|
||||
expect(plugin.id).toBe("tavily");
|
||||
expect(plugin.name).toBe("Tavily Plugin");
|
||||
expect(registrations.webSearchProviders).toHaveLength(1);
|
||||
expect(registrations.tools).toHaveLength(2);
|
||||
|
||||
const provider = registrations.webSearchProviders[0] as Record<string, unknown>;
|
||||
expect(provider.id).toBe("tavily");
|
||||
expect(provider.autoDetectOrder).toBe(70);
|
||||
expect(provider.envVars).toEqual(["TAVILY_API_KEY"]);
|
||||
|
||||
const toolNames = registrations.tools.map((t) => (t as Record<string, unknown>).name);
|
||||
expect(toolNames).toContain("tavily_search");
|
||||
expect(toolNames).toContain("tavily_extract");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import plugin from "../index.js";
|
||||
import {
|
||||
DEFAULT_TAVILY_BASE_URL,
|
||||
DEFAULT_TAVILY_EXTRACT_TIMEOUT_SECONDS,
|
||||
@@ -63,6 +64,39 @@ describe("tavily tools", () => {
|
||||
expect(applied.plugins?.entries?.tavily?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("registers web search provider and two tools", () => {
|
||||
const registrations: {
|
||||
webSearchProviders: unknown[];
|
||||
tools: unknown[];
|
||||
} = { webSearchProviders: [], tools: [] };
|
||||
|
||||
const mockApi = {
|
||||
registerWebSearchProvider(provider: unknown) {
|
||||
registrations.webSearchProviders.push(provider);
|
||||
},
|
||||
registerTool(tool: unknown) {
|
||||
registrations.tools.push(tool);
|
||||
},
|
||||
config: {},
|
||||
};
|
||||
|
||||
plugin.register(mockApi as never);
|
||||
|
||||
expect(plugin.id).toBe("tavily");
|
||||
expect(plugin.name).toBe("Tavily Plugin");
|
||||
expect(registrations.webSearchProviders).toHaveLength(1);
|
||||
expect(registrations.tools).toHaveLength(2);
|
||||
|
||||
const provider = registrations.webSearchProviders[0] as Record<string, unknown>;
|
||||
expect(provider.id).toBe("tavily");
|
||||
expect(provider.autoDetectOrder).toBe(70);
|
||||
expect(provider.envVars).toEqual(["TAVILY_API_KEY"]);
|
||||
|
||||
const toolNames = registrations.tools.map((t) => (t as Record<string, unknown>).name);
|
||||
expect(toolNames).toContain("tavily_search");
|
||||
expect(toolNames).toContain("tavily_extract");
|
||||
});
|
||||
|
||||
it("maps generic provider args into Tavily search params", async () => {
|
||||
const provider = createTavilyWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveXaiCatalogEntry } from "./model-definitions.js";
|
||||
import { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js";
|
||||
|
||||
describe("xai provider models", () => {
|
||||
it("publishes the newer Grok fast and code models in the bundled catalog", () => {
|
||||
expect(resolveXaiCatalogEntry("grok-4-1-fast")).toMatchObject({
|
||||
id: "grok-4-1-fast",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 2_000_000,
|
||||
maxTokens: 30_000,
|
||||
});
|
||||
expect(resolveXaiCatalogEntry("grok-code-fast-1")).toMatchObject({
|
||||
id: "grok-code-fast-1",
|
||||
reasoning: true,
|
||||
contextWindow: 256_000,
|
||||
maxTokens: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes Grok 4.20 reasoning and non-reasoning models", () => {
|
||||
expect(resolveXaiCatalogEntry("grok-4.20-beta-latest-reasoning")).toMatchObject({
|
||||
id: "grok-4.20-beta-latest-reasoning",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 2_000_000,
|
||||
});
|
||||
expect(resolveXaiCatalogEntry("grok-4.20-beta-latest-non-reasoning")).toMatchObject({
|
||||
id: "grok-4.20-beta-latest-non-reasoning",
|
||||
reasoning: false,
|
||||
contextWindow: 2_000_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps older Grok aliases resolving with current limits", () => {
|
||||
expect(resolveXaiCatalogEntry("grok-4-1-fast-reasoning")).toMatchObject({
|
||||
id: "grok-4-1-fast-reasoning",
|
||||
reasoning: true,
|
||||
contextWindow: 2_000_000,
|
||||
maxTokens: 30_000,
|
||||
});
|
||||
expect(resolveXaiCatalogEntry("grok-4.20-reasoning")).toMatchObject({
|
||||
id: "grok-4.20-reasoning",
|
||||
reasoning: true,
|
||||
contextWindow: 2_000_000,
|
||||
maxTokens: 30_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes the remaining Grok 3 family that Pi still carries", () => {
|
||||
expect(resolveXaiCatalogEntry("grok-3-mini-fast")).toMatchObject({
|
||||
id: "grok-3-mini-fast",
|
||||
reasoning: true,
|
||||
contextWindow: 131_072,
|
||||
maxTokens: 8_192,
|
||||
});
|
||||
expect(resolveXaiCatalogEntry("grok-3-fast")).toMatchObject({
|
||||
id: "grok-3-fast",
|
||||
reasoning: false,
|
||||
contextWindow: 131_072,
|
||||
maxTokens: 8_192,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks current Grok families as modern while excluding multi-agent ids", () => {
|
||||
expect(isModernXaiModel("grok-4.20-beta-latest-reasoning")).toBe(true);
|
||||
expect(isModernXaiModel("grok-code-fast-1")).toBe(true);
|
||||
expect(isModernXaiModel("grok-3-mini-fast")).toBe(true);
|
||||
expect(isModernXaiModel("grok-4.20-multi-agent-experimental-beta-0304")).toBe(false);
|
||||
});
|
||||
|
||||
it("builds forward-compatible runtime models for newer Grok ids", () => {
|
||||
const grok41 = resolveXaiForwardCompatModel({
|
||||
providerId: "xai",
|
||||
ctx: {
|
||||
provider: "xai",
|
||||
modelId: "grok-4-1-fast",
|
||||
modelRegistry: { find: () => null } as never,
|
||||
providerConfig: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
},
|
||||
},
|
||||
});
|
||||
const grok420 = resolveXaiForwardCompatModel({
|
||||
providerId: "xai",
|
||||
ctx: {
|
||||
provider: "xai",
|
||||
modelId: "grok-4.20-beta-latest-reasoning",
|
||||
modelRegistry: { find: () => null } as never,
|
||||
providerConfig: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
},
|
||||
},
|
||||
});
|
||||
const grok3Mini = resolveXaiForwardCompatModel({
|
||||
providerId: "xai",
|
||||
ctx: {
|
||||
provider: "xai",
|
||||
modelId: "grok-3-mini-fast",
|
||||
modelRegistry: { find: () => null } as never,
|
||||
providerConfig: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(grok41).toMatchObject({
|
||||
provider: "xai",
|
||||
id: "grok-4-1-fast",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
reasoning: true,
|
||||
contextWindow: 2_000_000,
|
||||
maxTokens: 30_000,
|
||||
});
|
||||
expect(grok420).toMatchObject({
|
||||
provider: "xai",
|
||||
id: "grok-4.20-beta-latest-reasoning",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 2_000_000,
|
||||
maxTokens: 30_000,
|
||||
});
|
||||
expect(grok3Mini).toMatchObject({
|
||||
provider: "xai",
|
||||
id: "grok-3-mini-fast",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
reasoning: true,
|
||||
contextWindow: 131_072,
|
||||
maxTokens: 8_192,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses the unsupported multi-agent endpoint ids", () => {
|
||||
const model = resolveXaiForwardCompatModel({
|
||||
providerId: "xai",
|
||||
ctx: {
|
||||
provider: "xai",
|
||||
modelId: "grok-4.20-multi-agent-experimental-beta-0304",
|
||||
modelRegistry: { find: () => null } as never,
|
||||
providerConfig: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(model).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withEnv } from "../../test/helpers/extensions/env.js";
|
||||
import { resolveXaiCatalogEntry } from "./model-definitions.js";
|
||||
import { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js";
|
||||
import { __testing as grokProviderTesting } from "./src/grok-web-search-provider.js";
|
||||
import { __testing } from "./web-search.js";
|
||||
|
||||
@@ -159,3 +161,157 @@ describe("xai web search response parsing", () => {
|
||||
expect(result.annotationCitations).toEqual(["https://example.com/direct"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("xai provider models", () => {
|
||||
it("publishes the newer Grok fast and code models in the bundled catalog", () => {
|
||||
expect(resolveXaiCatalogEntry("grok-4-1-fast")).toMatchObject({
|
||||
id: "grok-4-1-fast",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 2_000_000,
|
||||
maxTokens: 30_000,
|
||||
});
|
||||
expect(resolveXaiCatalogEntry("grok-code-fast-1")).toMatchObject({
|
||||
id: "grok-code-fast-1",
|
||||
reasoning: true,
|
||||
contextWindow: 256_000,
|
||||
maxTokens: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes Grok 4.20 reasoning and non-reasoning models", () => {
|
||||
expect(resolveXaiCatalogEntry("grok-4.20-beta-latest-reasoning")).toMatchObject({
|
||||
id: "grok-4.20-beta-latest-reasoning",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 2_000_000,
|
||||
});
|
||||
expect(resolveXaiCatalogEntry("grok-4.20-beta-latest-non-reasoning")).toMatchObject({
|
||||
id: "grok-4.20-beta-latest-non-reasoning",
|
||||
reasoning: false,
|
||||
contextWindow: 2_000_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps older Grok aliases resolving with current limits", () => {
|
||||
expect(resolveXaiCatalogEntry("grok-4-1-fast-reasoning")).toMatchObject({
|
||||
id: "grok-4-1-fast-reasoning",
|
||||
reasoning: true,
|
||||
contextWindow: 2_000_000,
|
||||
maxTokens: 30_000,
|
||||
});
|
||||
expect(resolveXaiCatalogEntry("grok-4.20-reasoning")).toMatchObject({
|
||||
id: "grok-4.20-reasoning",
|
||||
reasoning: true,
|
||||
contextWindow: 2_000_000,
|
||||
maxTokens: 30_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes the remaining Grok 3 family that Pi still carries", () => {
|
||||
expect(resolveXaiCatalogEntry("grok-3-mini-fast")).toMatchObject({
|
||||
id: "grok-3-mini-fast",
|
||||
reasoning: true,
|
||||
contextWindow: 131_072,
|
||||
maxTokens: 8_192,
|
||||
});
|
||||
expect(resolveXaiCatalogEntry("grok-3-fast")).toMatchObject({
|
||||
id: "grok-3-fast",
|
||||
reasoning: false,
|
||||
contextWindow: 131_072,
|
||||
maxTokens: 8_192,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks current Grok families as modern while excluding multi-agent ids", () => {
|
||||
expect(isModernXaiModel("grok-4.20-beta-latest-reasoning")).toBe(true);
|
||||
expect(isModernXaiModel("grok-code-fast-1")).toBe(true);
|
||||
expect(isModernXaiModel("grok-3-mini-fast")).toBe(true);
|
||||
expect(isModernXaiModel("grok-4.20-multi-agent-experimental-beta-0304")).toBe(false);
|
||||
});
|
||||
|
||||
it("builds forward-compatible runtime models for newer Grok ids", () => {
|
||||
const grok41 = resolveXaiForwardCompatModel({
|
||||
providerId: "xai",
|
||||
ctx: {
|
||||
provider: "xai",
|
||||
modelId: "grok-4-1-fast",
|
||||
modelRegistry: { find: () => null } as never,
|
||||
providerConfig: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
},
|
||||
},
|
||||
});
|
||||
const grok420 = resolveXaiForwardCompatModel({
|
||||
providerId: "xai",
|
||||
ctx: {
|
||||
provider: "xai",
|
||||
modelId: "grok-4.20-beta-latest-reasoning",
|
||||
modelRegistry: { find: () => null } as never,
|
||||
providerConfig: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
},
|
||||
},
|
||||
});
|
||||
const grok3Mini = resolveXaiForwardCompatModel({
|
||||
providerId: "xai",
|
||||
ctx: {
|
||||
provider: "xai",
|
||||
modelId: "grok-3-mini-fast",
|
||||
modelRegistry: { find: () => null } as never,
|
||||
providerConfig: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(grok41).toMatchObject({
|
||||
provider: "xai",
|
||||
id: "grok-4-1-fast",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
reasoning: true,
|
||||
contextWindow: 2_000_000,
|
||||
maxTokens: 30_000,
|
||||
});
|
||||
expect(grok420).toMatchObject({
|
||||
provider: "xai",
|
||||
id: "grok-4.20-beta-latest-reasoning",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 2_000_000,
|
||||
maxTokens: 30_000,
|
||||
});
|
||||
expect(grok3Mini).toMatchObject({
|
||||
provider: "xai",
|
||||
id: "grok-3-mini-fast",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
reasoning: true,
|
||||
contextWindow: 131_072,
|
||||
maxTokens: 8_192,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses the unsupported multi-agent endpoint ids", () => {
|
||||
const model = resolveXaiForwardCompatModel({
|
||||
providerId: "xai",
|
||||
ctx: {
|
||||
provider: "xai",
|
||||
modelId: "grok-4.20-multi-agent-experimental-beta-0304",
|
||||
modelRegistry: { find: () => null } as never,
|
||||
providerConfig: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(model).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user