mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-01 20:31:19 +00:00
refactor: move extension-owned tests to extensions
This commit is contained in:
@@ -1,56 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type PackageManifest = {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
|
||||
function readJson<T>(relativePath: string): T {
|
||||
const absolutePath = path.resolve(process.cwd(), relativePath);
|
||||
return JSON.parse(fs.readFileSync(absolutePath, "utf8")) as T;
|
||||
}
|
||||
|
||||
describe("bundled plugin runtime dependencies", () => {
|
||||
function expectPluginOwnsRuntimeDep(pluginPath: string, dependencyName: string) {
|
||||
const rootManifest = readJson<PackageManifest>("package.json");
|
||||
const pluginManifest = readJson<PackageManifest>(pluginPath);
|
||||
const pluginSpec = pluginManifest.dependencies?.[dependencyName];
|
||||
const rootSpec = rootManifest.dependencies?.[dependencyName];
|
||||
|
||||
expect(pluginSpec).toBeTruthy();
|
||||
expect(rootSpec).toBeUndefined();
|
||||
}
|
||||
|
||||
it("keeps bundled Feishu runtime deps plugin-local instead of mirroring them into the root package", () => {
|
||||
expectPluginOwnsRuntimeDep("extensions/feishu/package.json", "@larksuiteoapi/node-sdk");
|
||||
});
|
||||
|
||||
it("keeps memory-lancedb runtime deps plugin-local so packaged installs fetch them on demand", () => {
|
||||
expectPluginOwnsRuntimeDep("extensions/memory-lancedb/package.json", "@lancedb/lancedb");
|
||||
});
|
||||
|
||||
it("keeps bundled Discord runtime deps plugin-local instead of mirroring them into the root package", () => {
|
||||
expectPluginOwnsRuntimeDep("extensions/discord/package.json", "@buape/carbon");
|
||||
});
|
||||
|
||||
it("keeps bundled Slack runtime deps plugin-local instead of mirroring them into the root package", () => {
|
||||
expectPluginOwnsRuntimeDep("extensions/slack/package.json", "@slack/bolt");
|
||||
});
|
||||
|
||||
it("keeps bundled Telegram runtime deps plugin-local instead of mirroring them into the root package", () => {
|
||||
expectPluginOwnsRuntimeDep("extensions/telegram/package.json", "grammy");
|
||||
});
|
||||
|
||||
it("keeps WhatsApp runtime deps plugin-local so packaged installs fetch them on demand", () => {
|
||||
expectPluginOwnsRuntimeDep("extensions/whatsapp/package.json", "@whiskeysockets/baileys");
|
||||
});
|
||||
|
||||
it("keeps WhatsApp image helper deps plugin-local so bundled builds resolve Baileys peers", () => {
|
||||
expectPluginOwnsRuntimeDep("extensions/whatsapp/package.json", "jimp");
|
||||
});
|
||||
|
||||
it("keeps bundled proxy-agent deps plugin-local instead of mirroring them into the root package", () => {
|
||||
expectPluginOwnsRuntimeDep("extensions/discord/package.json", "https-proxy-agent");
|
||||
});
|
||||
});
|
||||
@@ -1,85 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loadBundledCapabilityRuntimeRegistry } from "./bundled-capability-runtime.js";
|
||||
import { BUNDLED_WEB_SEARCH_PLUGIN_IDS } from "./bundled-web-search-ids.js";
|
||||
import { resolveBundledWebSearchPluginId } from "./bundled-web-search-provider-ids.js";
|
||||
import {
|
||||
listBundledWebSearchProviders,
|
||||
resolveBundledWebSearchPluginIds,
|
||||
} from "./bundled-web-search.js";
|
||||
import { loadPluginManifestRegistry } from "./manifest-registry.js";
|
||||
|
||||
describe("bundled web search metadata", () => {
|
||||
function toComparableEntry(params: {
|
||||
pluginId: string;
|
||||
provider: {
|
||||
id: string;
|
||||
label: string;
|
||||
hint: string;
|
||||
envVars: string[];
|
||||
placeholder: string;
|
||||
signupUrl: string;
|
||||
docsUrl?: string;
|
||||
autoDetectOrder?: number;
|
||||
requiresCredential?: boolean;
|
||||
credentialPath: string;
|
||||
inactiveSecretPaths?: string[];
|
||||
getConfiguredCredentialValue?: unknown;
|
||||
setConfiguredCredentialValue?: unknown;
|
||||
applySelectionConfig?: unknown;
|
||||
resolveRuntimeMetadata?: unknown;
|
||||
};
|
||||
}) {
|
||||
return {
|
||||
pluginId: params.pluginId,
|
||||
id: params.provider.id,
|
||||
label: params.provider.label,
|
||||
hint: params.provider.hint,
|
||||
envVars: params.provider.envVars,
|
||||
placeholder: params.provider.placeholder,
|
||||
signupUrl: params.provider.signupUrl,
|
||||
docsUrl: params.provider.docsUrl,
|
||||
autoDetectOrder: params.provider.autoDetectOrder,
|
||||
requiresCredential: params.provider.requiresCredential,
|
||||
credentialPath: params.provider.credentialPath,
|
||||
inactiveSecretPaths: params.provider.inactiveSecretPaths,
|
||||
hasConfiguredCredentialAccessors:
|
||||
typeof params.provider.getConfiguredCredentialValue === "function" &&
|
||||
typeof params.provider.setConfiguredCredentialValue === "function",
|
||||
hasApplySelectionConfig: typeof params.provider.applySelectionConfig === "function",
|
||||
hasResolveRuntimeMetadata: typeof params.provider.resolveRuntimeMetadata === "function",
|
||||
};
|
||||
}
|
||||
|
||||
function sortComparableEntries<
|
||||
T extends {
|
||||
autoDetectOrder?: number;
|
||||
id: string;
|
||||
pluginId: string;
|
||||
},
|
||||
>(entries: T[]): T[] {
|
||||
return [...entries].toSorted((left, right) => {
|
||||
const leftOrder = left.autoDetectOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
const rightOrder = right.autoDetectOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
return (
|
||||
leftOrder - rightOrder ||
|
||||
left.id.localeCompare(right.id) ||
|
||||
left.pluginId.localeCompare(right.pluginId)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps bundled web search compat ids aligned with bundled manifests", () => {
|
||||
expect(resolveBundledWebSearchPluginIds({})).toEqual([
|
||||
"brave",
|
||||
"duckduckgo",
|
||||
"exa",
|
||||
"firecrawl",
|
||||
"google",
|
||||
"moonshot",
|
||||
"perplexity",
|
||||
"tavily",
|
||||
"xai",
|
||||
]);
|
||||
const bundledWebSearchPluginIds = loadPluginManifestRegistry({})
|
||||
.plugins.filter(
|
||||
(plugin) =>
|
||||
plugin.origin === "bundled" && (plugin.contracts?.webSearchProviders?.length ?? 0) > 0,
|
||||
)
|
||||
.map((plugin) => plugin.id)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
|
||||
expect(resolveBundledWebSearchPluginIds({})).toEqual(bundledWebSearchPluginIds);
|
||||
});
|
||||
|
||||
it("keeps bundled web search fast-path ids aligned with the registry", () => {
|
||||
@@ -90,138 +27,4 @@ describe("bundled web search metadata", () => {
|
||||
.toSorted((left, right) => left.localeCompare(right)),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps bundled web search provider-to-plugin ids aligned with bundled contracts", () => {
|
||||
expect(resolveBundledWebSearchPluginId("brave")).toBe("brave");
|
||||
expect(resolveBundledWebSearchPluginId("duckduckgo")).toBe("duckduckgo");
|
||||
expect(resolveBundledWebSearchPluginId("exa")).toBe("exa");
|
||||
expect(resolveBundledWebSearchPluginId("firecrawl")).toBe("firecrawl");
|
||||
expect(resolveBundledWebSearchPluginId("gemini")).toBe("google");
|
||||
expect(resolveBundledWebSearchPluginId("kimi")).toBe("moonshot");
|
||||
expect(resolveBundledWebSearchPluginId("perplexity")).toBe("perplexity");
|
||||
expect(resolveBundledWebSearchPluginId("tavily")).toBe("tavily");
|
||||
expect(resolveBundledWebSearchPluginId("grok")).toBe("xai");
|
||||
});
|
||||
|
||||
it("keeps bundled provider metadata aligned with bundled plugin contracts", async () => {
|
||||
const fastPathProviders = listBundledWebSearchProviders();
|
||||
const bundledProviderEntries = loadBundledCapabilityRuntimeRegistry({
|
||||
pluginIds: BUNDLED_WEB_SEARCH_PLUGIN_IDS,
|
||||
pluginSdkResolution: "dist",
|
||||
}).webSearchProviders.map((entry) => ({
|
||||
pluginId: entry.pluginId,
|
||||
...entry.provider,
|
||||
}));
|
||||
|
||||
expect(
|
||||
sortComparableEntries(
|
||||
fastPathProviders.map((provider) =>
|
||||
toComparableEntry({
|
||||
pluginId: provider.pluginId,
|
||||
provider,
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toEqual(
|
||||
sortComparableEntries(
|
||||
bundledProviderEntries.map(({ pluginId, ...provider }) =>
|
||||
toComparableEntry({
|
||||
pluginId,
|
||||
provider,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
for (const fastPathProvider of fastPathProviders) {
|
||||
const bundledEntry = bundledProviderEntries.find(
|
||||
(entry) => entry.pluginId === fastPathProvider.pluginId && entry.id === fastPathProvider.id,
|
||||
);
|
||||
expect(bundledEntry).toBeDefined();
|
||||
const contractProvider = bundledEntry!;
|
||||
|
||||
const fastSearchConfig: Record<string, unknown> = {};
|
||||
const contractSearchConfig: Record<string, unknown> = {};
|
||||
fastPathProvider.setCredentialValue(fastSearchConfig, "test-key");
|
||||
contractProvider.setCredentialValue(contractSearchConfig, "test-key");
|
||||
expect(fastSearchConfig).toEqual(contractSearchConfig);
|
||||
expect(fastPathProvider.getCredentialValue(fastSearchConfig)).toEqual(
|
||||
contractProvider.getCredentialValue(contractSearchConfig),
|
||||
);
|
||||
|
||||
const fastConfig = {} as OpenClawConfig;
|
||||
const contractConfig = {} as OpenClawConfig;
|
||||
fastPathProvider.setConfiguredCredentialValue?.(fastConfig, "test-key");
|
||||
contractProvider.setConfiguredCredentialValue?.(contractConfig, "test-key");
|
||||
expect(fastConfig).toEqual(contractConfig);
|
||||
expect(fastPathProvider.getConfiguredCredentialValue?.(fastConfig)).toEqual(
|
||||
contractProvider.getConfiguredCredentialValue?.(contractConfig),
|
||||
);
|
||||
|
||||
if (fastPathProvider.applySelectionConfig || contractProvider.applySelectionConfig) {
|
||||
expect(fastPathProvider.applySelectionConfig?.({} as OpenClawConfig)).toEqual(
|
||||
contractProvider.applySelectionConfig?.({} as OpenClawConfig),
|
||||
);
|
||||
}
|
||||
|
||||
if (fastPathProvider.resolveRuntimeMetadata || contractProvider.resolveRuntimeMetadata) {
|
||||
const metadataCases = [
|
||||
{
|
||||
searchConfig: fastSearchConfig,
|
||||
resolvedCredential: {
|
||||
value: "pplx-test",
|
||||
source: "secretRef" as const,
|
||||
fallbackEnvVar: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
searchConfig: fastSearchConfig,
|
||||
resolvedCredential: {
|
||||
value: undefined,
|
||||
source: "env" as const,
|
||||
fallbackEnvVar: "OPENROUTER_API_KEY",
|
||||
},
|
||||
},
|
||||
{
|
||||
searchConfig: {
|
||||
...fastSearchConfig,
|
||||
perplexity: {
|
||||
...(fastSearchConfig.perplexity as Record<string, unknown> | undefined),
|
||||
model: "custom-model",
|
||||
},
|
||||
},
|
||||
resolvedCredential: {
|
||||
value: "pplx-test",
|
||||
source: "secretRef" as const,
|
||||
fallbackEnvVar: undefined,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of metadataCases) {
|
||||
expect(
|
||||
await fastPathProvider.resolveRuntimeMetadata?.({
|
||||
config: fastConfig,
|
||||
searchConfig: testCase.searchConfig,
|
||||
runtimeMetadata: {
|
||||
diagnostics: [],
|
||||
providerSource: "configured",
|
||||
},
|
||||
resolvedCredential: testCase.resolvedCredential,
|
||||
}),
|
||||
).toEqual(
|
||||
await contractProvider.resolveRuntimeMetadata?.({
|
||||
config: contractConfig,
|
||||
searchConfig: testCase.searchConfig,
|
||||
runtimeMetadata: {
|
||||
diagnostics: [],
|
||||
providerSource: "configured",
|
||||
},
|
||||
resolvedCredential: testCase.resolvedCredential,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,12 +16,12 @@ import { resolveGatewayStartupPluginIds } from "./channel-plugin-ids.js";
|
||||
|
||||
describe("resolveGatewayStartupPluginIds", () => {
|
||||
beforeEach(() => {
|
||||
listPotentialConfiguredChannelIds.mockReset().mockReturnValue(["discord"]);
|
||||
listPotentialConfiguredChannelIds.mockReset().mockReturnValue(["demo-channel"]);
|
||||
loadPluginManifestRegistry.mockReset().mockReturnValue({
|
||||
plugins: [
|
||||
{
|
||||
id: "discord",
|
||||
channels: ["discord"],
|
||||
id: "demo-channel",
|
||||
channels: ["demo-channel"],
|
||||
origin: "bundled",
|
||||
enabledByDefault: undefined,
|
||||
providers: [],
|
||||
@@ -36,12 +36,12 @@ describe("resolveGatewayStartupPluginIds", () => {
|
||||
cliBackends: [],
|
||||
},
|
||||
{
|
||||
id: "anthropic",
|
||||
id: "demo-provider-plugin",
|
||||
channels: [],
|
||||
origin: "bundled",
|
||||
enabledByDefault: undefined,
|
||||
providers: ["anthropic"],
|
||||
cliBackends: ["claude-cli"],
|
||||
providers: ["demo-provider"],
|
||||
cliBackends: ["demo-cli"],
|
||||
},
|
||||
{
|
||||
id: "diagnostics-otel",
|
||||
@@ -73,9 +73,9 @@ describe("resolveGatewayStartupPluginIds", () => {
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "claude-cli/claude-sonnet-4-6" },
|
||||
model: { primary: "demo-cli/demo-model" },
|
||||
models: {
|
||||
"claude-cli/claude-sonnet-4-6": {},
|
||||
"demo-cli/demo-model": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -87,7 +87,7 @@ describe("resolveGatewayStartupPluginIds", () => {
|
||||
workspaceDir: "/tmp",
|
||||
env: process.env,
|
||||
}),
|
||||
).toEqual(["discord", "anthropic", "diagnostics-otel", "custom-sidecar"]);
|
||||
).toEqual(["demo-channel", "demo-provider-plugin", "diagnostics-otel", "custom-sidecar"]);
|
||||
});
|
||||
|
||||
it("does not pull default-on bundled non-channel plugins into startup", () => {
|
||||
@@ -99,14 +99,14 @@ describe("resolveGatewayStartupPluginIds", () => {
|
||||
workspaceDir: "/tmp",
|
||||
env: process.env,
|
||||
}),
|
||||
).toEqual(["discord", "custom-sidecar"]);
|
||||
).toEqual(["demo-channel", "custom-sidecar"]);
|
||||
});
|
||||
|
||||
it("auto-loads bundled plugins referenced by configured provider ids", () => {
|
||||
const config = {
|
||||
models: {
|
||||
providers: {
|
||||
anthropic: {
|
||||
"demo-provider": {
|
||||
baseUrl: "https://example.com",
|
||||
models: [],
|
||||
},
|
||||
@@ -120,6 +120,6 @@ describe("resolveGatewayStartupPluginIds", () => {
|
||||
workspaceDir: "/tmp",
|
||||
env: process.env,
|
||||
}),
|
||||
).toEqual(["discord", "anthropic", "custom-sidecar"]);
|
||||
).toEqual(["demo-channel", "demo-provider-plugin", "custom-sidecar"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,8 +58,8 @@ describe("provider auth-choice contract", () => {
|
||||
it("maps provider-plugin choices through the shared preferred-provider fallback resolver", async () => {
|
||||
const pluginFallbackScenarios: ProviderPlugin[] = [
|
||||
{
|
||||
id: "github-copilot",
|
||||
label: "GitHub Copilot",
|
||||
id: "demo-oauth-provider",
|
||||
label: "Demo OAuth Provider",
|
||||
auth: [
|
||||
{
|
||||
id: "oauth",
|
||||
@@ -71,8 +71,8 @@ describe("provider auth-choice contract", () => {
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "minimax-portal",
|
||||
label: "MiniMax Portal",
|
||||
id: "demo-browser-provider",
|
||||
label: "Demo Browser Provider",
|
||||
auth: [
|
||||
{
|
||||
id: "portal",
|
||||
@@ -84,8 +84,8 @@ describe("provider auth-choice contract", () => {
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "modelstudio",
|
||||
label: "ModelStudio",
|
||||
id: "demo-api-key-provider",
|
||||
label: "Demo API Key Provider",
|
||||
auth: [
|
||||
{
|
||||
id: "api-key",
|
||||
@@ -97,8 +97,8 @@ describe("provider auth-choice contract", () => {
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ollama",
|
||||
label: "Ollama",
|
||||
id: "demo-local-provider",
|
||||
label: "Demo Local Provider",
|
||||
auth: [
|
||||
{
|
||||
id: "local",
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearRuntimeAuthProfileStoreSnapshots } from "../../agents/auth-profiles/store.js";
|
||||
import type { AuthProfileStore } from "../../agents/auth-profiles/types.js";
|
||||
import { createNonExitingRuntime } from "../../runtime.js";
|
||||
import type {
|
||||
WizardMultiSelectParams,
|
||||
WizardPrompter,
|
||||
WizardProgress,
|
||||
WizardSelectParams,
|
||||
} from "../../wizard/prompts.js";
|
||||
import { registerProviders, requireProvider } from "./testkit.js";
|
||||
|
||||
type LoginOpenAICodexOAuth =
|
||||
(typeof import("openclaw/plugin-sdk/provider-auth-login"))["loginOpenAICodexOAuth"];
|
||||
type GithubCopilotLoginCommand =
|
||||
(typeof import("openclaw/plugin-sdk/provider-auth-login"))["githubCopilotLoginCommand"];
|
||||
type CreateVpsAwareHandlers =
|
||||
(typeof import("../provider-oauth-flow.js"))["createVpsAwareOAuthHandlers"];
|
||||
type EnsureAuthProfileStore =
|
||||
typeof import("openclaw/plugin-sdk/agent-runtime").ensureAuthProfileStore;
|
||||
type ListProfilesForProvider =
|
||||
typeof import("openclaw/plugin-sdk/agent-runtime").listProfilesForProvider;
|
||||
|
||||
const loginOpenAICodexOAuthMock = vi.hoisted(() => vi.fn<LoginOpenAICodexOAuth>());
|
||||
const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn<GithubCopilotLoginCommand>());
|
||||
const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn<EnsureAuthProfileStore>());
|
||||
const listProfilesForProviderMock = vi.hoisted(() => vi.fn<ListProfilesForProvider>());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-auth-login", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/provider-auth-login")>();
|
||||
return {
|
||||
...actual,
|
||||
loginOpenAICodexOAuth: loginOpenAICodexOAuthMock,
|
||||
githubCopilotLoginCommand: githubCopilotLoginCommandMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/agent-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
ensureAuthProfileStore: ensureAuthProfileStoreMock,
|
||||
listProfilesForProvider: listProfilesForProviderMock,
|
||||
};
|
||||
});
|
||||
|
||||
import githubCopilotPlugin from "../../../extensions/github-copilot/index.js";
|
||||
import openAIPlugin from "../../../extensions/openai/index.js";
|
||||
|
||||
function buildPrompter(): WizardPrompter {
|
||||
const progress: WizardProgress = {
|
||||
update() {},
|
||||
stop() {},
|
||||
};
|
||||
return {
|
||||
intro: async () => {},
|
||||
outro: async () => {},
|
||||
note: async () => {},
|
||||
select: async <T>(params: WizardSelectParams<T>) => {
|
||||
const option = params.options[0];
|
||||
if (!option) {
|
||||
throw new Error("missing select option");
|
||||
}
|
||||
return option.value;
|
||||
},
|
||||
multiselect: async <T>(params: WizardMultiSelectParams<T>) => params.initialValues ?? [],
|
||||
text: async () => "",
|
||||
confirm: async () => false,
|
||||
progress: () => progress,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAuthContext() {
|
||||
return {
|
||||
config: {},
|
||||
prompter: buildPrompter(),
|
||||
runtime: createNonExitingRuntime(),
|
||||
isRemote: false,
|
||||
openUrl: async () => {},
|
||||
oauth: {
|
||||
createVpsAwareHandlers: vi.fn<CreateVpsAwareHandlers>(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createJwt(payload: Record<string, unknown>): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
return `${header}.${body}.signature`;
|
||||
}
|
||||
|
||||
function getOpenAICodexProvider() {
|
||||
return requireProvider(registerProviders(openAIPlugin), "openai-codex");
|
||||
}
|
||||
|
||||
function buildOpenAICodexOAuthResult(params: {
|
||||
profileId: string;
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
email?: string;
|
||||
}) {
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId: params.profileId,
|
||||
credential: {
|
||||
type: "oauth" as const,
|
||||
provider: "openai-codex",
|
||||
access: params.access,
|
||||
refresh: params.refresh,
|
||||
expires: params.expires,
|
||||
...(params.email ? { email: params.email } : {}),
|
||||
},
|
||||
},
|
||||
],
|
||||
configPatch: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai-codex/gpt-5.4": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultModel: "openai-codex/gpt-5.4",
|
||||
notes: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function expectOpenAICodexStableFallbackProfile(params: {
|
||||
access: string;
|
||||
profileId: string;
|
||||
}) {
|
||||
const provider = getOpenAICodexProvider();
|
||||
loginOpenAICodexOAuthMock.mockResolvedValueOnce({
|
||||
refresh: "refresh-token",
|
||||
access: params.access,
|
||||
expires: 1_700_000_000_000,
|
||||
});
|
||||
const result = await provider.auth[0]?.run(buildAuthContext() as never);
|
||||
expect(result).toEqual(
|
||||
buildOpenAICodexOAuthResult({
|
||||
profileId: params.profileId,
|
||||
access: params.access,
|
||||
refresh: "refresh-token",
|
||||
expires: 1_700_000_000_000,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("provider auth contract", () => {
|
||||
let authStore: AuthProfileStore;
|
||||
|
||||
beforeEach(() => {
|
||||
authStore = { version: 1, profiles: {} };
|
||||
ensureAuthProfileStoreMock.mockReset();
|
||||
ensureAuthProfileStoreMock.mockImplementation(() => authStore);
|
||||
listProfilesForProviderMock.mockReset();
|
||||
listProfilesForProviderMock.mockImplementation((store, providerId) =>
|
||||
Object.entries(store.profiles)
|
||||
.filter(([, credential]) => credential?.provider === providerId)
|
||||
.map(([profileId]) => profileId),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
loginOpenAICodexOAuthMock.mockReset();
|
||||
githubCopilotLoginCommandMock.mockReset();
|
||||
ensureAuthProfileStoreMock.mockReset();
|
||||
listProfilesForProviderMock.mockReset();
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
});
|
||||
|
||||
it("keeps OpenAI Codex OAuth auth results provider-owned", async () => {
|
||||
const provider = getOpenAICodexProvider();
|
||||
loginOpenAICodexOAuthMock.mockResolvedValueOnce({
|
||||
email: "user@example.com",
|
||||
refresh: "refresh-token",
|
||||
access: "access-token",
|
||||
expires: 1_700_000_000_000,
|
||||
});
|
||||
|
||||
const result = await provider.auth[0]?.run(buildAuthContext() as never);
|
||||
|
||||
expect(result).toEqual(
|
||||
buildOpenAICodexOAuthResult({
|
||||
profileId: "openai-codex:user@example.com",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 1_700_000_000_000,
|
||||
email: "user@example.com",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("backfills OpenAI Codex OAuth email from the JWT profile claim", async () => {
|
||||
const provider = getOpenAICodexProvider();
|
||||
const access = createJwt({
|
||||
"https://api.openai.com/profile": {
|
||||
email: "jwt-user@example.com",
|
||||
},
|
||||
});
|
||||
loginOpenAICodexOAuthMock.mockResolvedValueOnce({
|
||||
refresh: "refresh-token",
|
||||
access,
|
||||
expires: 1_700_000_000_000,
|
||||
});
|
||||
|
||||
const result = await provider.auth[0]?.run(buildAuthContext() as never);
|
||||
|
||||
expect(result).toEqual(
|
||||
buildOpenAICodexOAuthResult({
|
||||
profileId: "openai-codex:jwt-user@example.com",
|
||||
access,
|
||||
refresh: "refresh-token",
|
||||
expires: 1_700_000_000_000,
|
||||
email: "jwt-user@example.com",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a stable fallback id when OpenAI Codex JWT email is missing", async () => {
|
||||
const access = createJwt({
|
||||
"https://api.openai.com/auth": {
|
||||
chatgpt_account_user_id: "user-123__acct-456",
|
||||
},
|
||||
});
|
||||
const expectedStableId = Buffer.from("user-123__acct-456", "utf8").toString("base64url");
|
||||
await expectOpenAICodexStableFallbackProfile({
|
||||
access,
|
||||
profileId: `openai-codex:id-${expectedStableId}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses iss and sub to build a stable fallback id when auth claims are missing", async () => {
|
||||
const access = createJwt({
|
||||
iss: "https://accounts.openai.com",
|
||||
sub: "user-abc",
|
||||
});
|
||||
const expectedStableId = Buffer.from("https://accounts.openai.com|user-abc").toString(
|
||||
"base64url",
|
||||
);
|
||||
await expectOpenAICodexStableFallbackProfile({
|
||||
access,
|
||||
profileId: `openai-codex:id-${expectedStableId}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses sub alone to build a stable fallback id when iss is missing", async () => {
|
||||
const access = createJwt({
|
||||
sub: "user-abc",
|
||||
});
|
||||
const expectedStableId = Buffer.from("user-abc").toString("base64url");
|
||||
await expectOpenAICodexStableFallbackProfile({
|
||||
access,
|
||||
profileId: `openai-codex:id-${expectedStableId}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the default OpenAI Codex profile when JWT parsing yields no identity", async () => {
|
||||
const provider = getOpenAICodexProvider();
|
||||
loginOpenAICodexOAuthMock.mockResolvedValueOnce({
|
||||
refresh: "refresh-token",
|
||||
access: "not-a-jwt-token",
|
||||
expires: 1_700_000_000_000,
|
||||
});
|
||||
|
||||
const result = await provider.auth[0]?.run(buildAuthContext() as never);
|
||||
|
||||
expect(result).toEqual(
|
||||
buildOpenAICodexOAuthResult({
|
||||
profileId: "openai-codex:default",
|
||||
access: "not-a-jwt-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 1_700_000_000_000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps OpenAI Codex OAuth failures non-fatal at the provider layer", async () => {
|
||||
const provider = requireProvider(registerProviders(openAIPlugin), "openai-codex");
|
||||
loginOpenAICodexOAuthMock.mockRejectedValueOnce(new Error("oauth failed"));
|
||||
|
||||
await expect(provider.auth[0]?.run(buildAuthContext() as never)).resolves.toEqual({
|
||||
profiles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps GitHub Copilot device auth results provider-owned", async () => {
|
||||
const provider = requireProvider(registerProviders(githubCopilotPlugin), "github-copilot");
|
||||
authStore.profiles["github-copilot:github"] = {
|
||||
type: "token" as const,
|
||||
provider: "github-copilot",
|
||||
token: "github-device-token",
|
||||
};
|
||||
|
||||
const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean };
|
||||
const hadOwnIsTTY = Object.prototype.hasOwnProperty.call(stdin, "isTTY");
|
||||
const previousIsTTYDescriptor = Object.getOwnPropertyDescriptor(stdin, "isTTY");
|
||||
Object.defineProperty(stdin, "isTTY", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => true,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await provider.auth[0]?.run(buildAuthContext() as never);
|
||||
expect(githubCopilotLoginCommandMock).toHaveBeenCalledWith(
|
||||
{ yes: true, profileId: "github-copilot:github" },
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
profiles: [
|
||||
{
|
||||
profileId: "github-copilot:github",
|
||||
credential: {
|
||||
type: "token",
|
||||
provider: "github-copilot",
|
||||
token: "github-device-token",
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultModel: "github-copilot/gpt-4o",
|
||||
});
|
||||
} finally {
|
||||
if (previousIsTTYDescriptor) {
|
||||
Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor);
|
||||
} else if (!hadOwnIsTTY) {
|
||||
delete (stdin as { isTTY?: boolean }).isTTY;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps GitHub Copilot auth gated on interactive TTYs", async () => {
|
||||
const provider = requireProvider(registerProviders(githubCopilotPlugin), "github-copilot");
|
||||
const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean };
|
||||
const hadOwnIsTTY = Object.prototype.hasOwnProperty.call(stdin, "isTTY");
|
||||
const previousIsTTYDescriptor = Object.getOwnPropertyDescriptor(stdin, "isTTY");
|
||||
Object.defineProperty(stdin, "isTTY", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => false,
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(provider.auth[0]?.run(buildAuthContext() as never)).resolves.toEqual({
|
||||
profiles: [],
|
||||
});
|
||||
expect(githubCopilotLoginCommandMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (previousIsTTYDescriptor) {
|
||||
Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor);
|
||||
} else if (!hadOwnIsTTY) {
|
||||
delete (stdin as { isTTY?: boolean }).isTTY;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
import { beforeAll, beforeEach, describe, it, vi } from "vitest";
|
||||
import {
|
||||
registerProviderPlugin,
|
||||
requireRegisteredProvider,
|
||||
} from "../../../test/helpers/extensions/provider-registration.js";
|
||||
import {
|
||||
expectAugmentedCodexCatalog,
|
||||
expectCodexBuiltInSuppression,
|
||||
expectCodexMissingAuthHint,
|
||||
} from "../provider-runtime.test-support.js";
|
||||
import type { ProviderPlugin } from "../types.js";
|
||||
|
||||
const PROVIDER_CATALOG_CONTRACT_TIMEOUT_MS = 300_000;
|
||||
|
||||
type ResolvePluginProviders = typeof import("../providers.runtime.js").resolvePluginProviders;
|
||||
type ResolveOwningPluginIdsForProvider =
|
||||
typeof import("../providers.js").resolveOwningPluginIdsForProvider;
|
||||
type ResolveCatalogHookProviderPluginIds =
|
||||
typeof import("../providers.js").resolveCatalogHookProviderPluginIds;
|
||||
|
||||
const resolvePluginProvidersMock = vi.hoisted(() => vi.fn<ResolvePluginProviders>(() => []));
|
||||
const resolveOwningPluginIdsForProviderMock = vi.hoisted(() =>
|
||||
vi.fn<ResolveOwningPluginIdsForProvider>(() => undefined),
|
||||
);
|
||||
const resolveCatalogHookProviderPluginIdsMock = vi.hoisted(() =>
|
||||
vi.fn<ResolveCatalogHookProviderPluginIds>((_) => [] as string[]),
|
||||
);
|
||||
|
||||
vi.mock("../providers.js", () => ({
|
||||
resolveOwningPluginIdsForProvider: (params: unknown) =>
|
||||
resolveOwningPluginIdsForProviderMock(params as never),
|
||||
resolveCatalogHookProviderPluginIds: (params: unknown) =>
|
||||
resolveCatalogHookProviderPluginIdsMock(params as never),
|
||||
}));
|
||||
|
||||
vi.mock("../providers.runtime.js", () => ({
|
||||
resolvePluginProviders: (params: unknown) => resolvePluginProvidersMock(params as never),
|
||||
}));
|
||||
|
||||
let augmentModelCatalogWithProviderPlugins: typeof import("../provider-runtime.js").augmentModelCatalogWithProviderPlugins;
|
||||
let resetProviderRuntimeHookCacheForTest: typeof import("../provider-runtime.js").resetProviderRuntimeHookCacheForTest;
|
||||
let resolveProviderBuiltInModelSuppression: typeof import("../provider-runtime.js").resolveProviderBuiltInModelSuppression;
|
||||
let openaiProviders: ProviderPlugin[];
|
||||
let openaiProvider: ProviderPlugin;
|
||||
|
||||
describe("provider catalog contract", { timeout: PROVIDER_CATALOG_CONTRACT_TIMEOUT_MS }, () => {
|
||||
beforeAll(async () => {
|
||||
vi.resetModules();
|
||||
const openaiPlugin = await import("../../../extensions/openai/index.ts");
|
||||
openaiProviders = registerProviderPlugin({
|
||||
plugin: openaiPlugin.default,
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
}).providers;
|
||||
openaiProvider = requireRegisteredProvider(openaiProviders, "openai", "provider");
|
||||
({
|
||||
augmentModelCatalogWithProviderPlugins,
|
||||
resetProviderRuntimeHookCacheForTest,
|
||||
resolveProviderBuiltInModelSuppression,
|
||||
} = await import("../provider-runtime.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetProviderRuntimeHookCacheForTest();
|
||||
|
||||
resolvePluginProvidersMock.mockReset();
|
||||
resolvePluginProvidersMock.mockImplementation((params?: { onlyPluginIds?: string[] }) => {
|
||||
const onlyPluginIds = params?.onlyPluginIds;
|
||||
if (!onlyPluginIds || onlyPluginIds.length === 0) {
|
||||
return openaiProviders;
|
||||
}
|
||||
return onlyPluginIds.includes("openai") ? openaiProviders : [];
|
||||
});
|
||||
|
||||
resolveOwningPluginIdsForProviderMock.mockReset();
|
||||
resolveOwningPluginIdsForProviderMock.mockImplementation((params) => {
|
||||
switch (params.provider) {
|
||||
case "azure-openai-responses":
|
||||
case "openai":
|
||||
case "openai-codex":
|
||||
return ["openai"];
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
resolveCatalogHookProviderPluginIdsMock.mockReset();
|
||||
resolveCatalogHookProviderPluginIdsMock.mockReturnValue(["openai"]);
|
||||
});
|
||||
|
||||
it("keeps codex-only missing-auth hints wired through the provider runtime", () => {
|
||||
expectCodexMissingAuthHint(
|
||||
(params) => openaiProvider.buildMissingAuthMessage?.(params.context) ?? undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps built-in model suppression wired through the provider runtime", () => {
|
||||
expectCodexBuiltInSuppression(resolveProviderBuiltInModelSuppression);
|
||||
});
|
||||
|
||||
it("keeps bundled model augmentation wired through the provider runtime", async () => {
|
||||
await expectAugmentedCodexCatalog(augmentModelCatalogWithProviderPlugins);
|
||||
});
|
||||
});
|
||||
@@ -1,572 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AuthProfileStore } from "../../agents/auth-profiles/types.js";
|
||||
import type { ModelDefinitionConfig } from "../../config/types.models.js";
|
||||
import { registerProviders, requireProvider } from "./testkit.js";
|
||||
|
||||
const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn());
|
||||
const buildOllamaProviderMock = vi.hoisted(() => vi.fn());
|
||||
const buildVllmProviderMock = vi.hoisted(() => vi.fn());
|
||||
const buildSglangProviderMock = vi.hoisted(() => vi.fn());
|
||||
const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn());
|
||||
const listProfilesForProviderMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
let runProviderCatalog: typeof import("../provider-discovery.js").runProviderCatalog;
|
||||
let githubCopilotProvider: Awaited<ReturnType<typeof requireProvider>>;
|
||||
let ollamaProvider: Awaited<ReturnType<typeof requireProvider>>;
|
||||
let vllmProvider: Awaited<ReturnType<typeof requireProvider>>;
|
||||
let sglangProvider: Awaited<ReturnType<typeof requireProvider>>;
|
||||
let minimaxProvider: Awaited<ReturnType<typeof requireProvider>>;
|
||||
let minimaxPortalProvider: Awaited<ReturnType<typeof requireProvider>>;
|
||||
let modelStudioProvider: Awaited<ReturnType<typeof requireProvider>>;
|
||||
let cloudflareAiGatewayProvider: Awaited<ReturnType<typeof requireProvider>>;
|
||||
|
||||
function createModelConfig(id: string, name = id): ModelDefinitionConfig {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 8_192,
|
||||
};
|
||||
}
|
||||
|
||||
function setRuntimeAuthStore(store?: AuthProfileStore) {
|
||||
const resolvedStore = store ?? {
|
||||
version: 1,
|
||||
profiles: {},
|
||||
};
|
||||
ensureAuthProfileStoreMock.mockReturnValue(resolvedStore);
|
||||
listProfilesForProviderMock.mockImplementation(
|
||||
(authStore: AuthProfileStore, providerId: string) =>
|
||||
Object.entries(authStore.profiles)
|
||||
.filter(([, credential]) => credential.provider === providerId)
|
||||
.map(([profileId]) => profileId),
|
||||
);
|
||||
}
|
||||
|
||||
function setGithubCopilotProfileSnapshot() {
|
||||
setRuntimeAuthStore({
|
||||
version: 1,
|
||||
profiles: {
|
||||
"github-copilot:github": {
|
||||
type: "token",
|
||||
provider: "github-copilot",
|
||||
token: "profile-token",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function runCatalog(params: {
|
||||
provider: Awaited<ReturnType<typeof requireProvider>>;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
resolveProviderApiKey?: () => { apiKey: string | undefined };
|
||||
resolveProviderAuth?: (
|
||||
providerId?: string,
|
||||
options?: { oauthMarker?: string },
|
||||
) => {
|
||||
apiKey: string | undefined;
|
||||
discoveryApiKey?: string;
|
||||
mode: "api_key" | "oauth" | "token" | "none";
|
||||
source: "env" | "profile" | "none";
|
||||
profileId?: string;
|
||||
};
|
||||
}) {
|
||||
return runProviderCatalog({
|
||||
provider: params.provider,
|
||||
config: {},
|
||||
env: params.env ?? ({} as NodeJS.ProcessEnv),
|
||||
resolveProviderApiKey: params.resolveProviderApiKey ?? (() => ({ apiKey: undefined })),
|
||||
resolveProviderAuth:
|
||||
params.resolveProviderAuth ??
|
||||
((_, options) => ({
|
||||
apiKey: options?.oauthMarker,
|
||||
discoveryApiKey: undefined,
|
||||
mode: options?.oauthMarker ? "oauth" : "none",
|
||||
source: options?.oauthMarker ? "profile" : "none",
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
describe("provider discovery contract", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("openclaw/plugin-sdk/agent-runtime", async () => {
|
||||
// Import the direct source module, not the mocked subpath, so bundled
|
||||
// provider helpers still see the full agent-runtime surface.
|
||||
const actual = await import("../../plugin-sdk/agent-runtime.ts");
|
||||
return {
|
||||
...actual,
|
||||
ensureAuthProfileStore: ensureAuthProfileStoreMock,
|
||||
listProfilesForProvider: listProfilesForProviderMock,
|
||||
};
|
||||
});
|
||||
vi.doMock("openclaw/plugin-sdk/provider-auth", async () => {
|
||||
const actual = await vi.importActual<object>("openclaw/plugin-sdk/provider-auth");
|
||||
return {
|
||||
...actual,
|
||||
ensureAuthProfileStore: ensureAuthProfileStoreMock,
|
||||
listProfilesForProvider: listProfilesForProviderMock,
|
||||
};
|
||||
});
|
||||
vi.doMock("../../../extensions/github-copilot/token.js", async () => {
|
||||
const actual = await vi.importActual<object>("../../../extensions/github-copilot/token.js");
|
||||
return {
|
||||
...actual,
|
||||
resolveCopilotApiToken: resolveCopilotApiTokenMock,
|
||||
};
|
||||
});
|
||||
vi.doMock("openclaw/plugin-sdk/provider-setup", async () => {
|
||||
const actual = await vi.importActual<object>("openclaw/plugin-sdk/provider-setup");
|
||||
return {
|
||||
...actual,
|
||||
buildOllamaProvider: (...args: unknown[]) => buildOllamaProviderMock(...args),
|
||||
buildVllmProvider: (...args: unknown[]) => buildVllmProviderMock(...args),
|
||||
buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args),
|
||||
};
|
||||
});
|
||||
vi.doMock("openclaw/plugin-sdk/self-hosted-provider-setup", async () => {
|
||||
const actual = await vi.importActual<object>(
|
||||
"openclaw/plugin-sdk/self-hosted-provider-setup",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
buildVllmProvider: (...args: unknown[]) => buildVllmProviderMock(...args),
|
||||
buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args),
|
||||
};
|
||||
});
|
||||
({ runProviderCatalog } = await import("../provider-discovery.js"));
|
||||
const [
|
||||
{ default: githubCopilotPlugin },
|
||||
{ default: ollamaPlugin },
|
||||
{ default: vllmPlugin },
|
||||
{ default: sglangPlugin },
|
||||
{ default: minimaxPlugin },
|
||||
{ default: modelStudioPlugin },
|
||||
{ default: cloudflareAiGatewayPlugin },
|
||||
] = await Promise.all([
|
||||
import("../../../extensions/github-copilot/index.js"),
|
||||
import("../../../extensions/ollama/index.js"),
|
||||
import("../../../extensions/vllm/index.js"),
|
||||
import("../../../extensions/sglang/index.js"),
|
||||
import("../../../extensions/minimax/index.js"),
|
||||
import("../../../extensions/modelstudio/index.js"),
|
||||
import("../../../extensions/cloudflare-ai-gateway/index.js"),
|
||||
]);
|
||||
githubCopilotProvider = requireProvider(
|
||||
registerProviders(githubCopilotPlugin),
|
||||
"github-copilot",
|
||||
);
|
||||
ollamaProvider = requireProvider(registerProviders(ollamaPlugin), "ollama");
|
||||
vllmProvider = requireProvider(registerProviders(vllmPlugin), "vllm");
|
||||
sglangProvider = requireProvider(registerProviders(sglangPlugin), "sglang");
|
||||
minimaxProvider = requireProvider(registerProviders(minimaxPlugin), "minimax");
|
||||
minimaxPortalProvider = requireProvider(registerProviders(minimaxPlugin), "minimax-portal");
|
||||
modelStudioProvider = requireProvider(registerProviders(modelStudioPlugin), "modelstudio");
|
||||
cloudflareAiGatewayProvider = requireProvider(
|
||||
registerProviders(cloudflareAiGatewayPlugin),
|
||||
"cloudflare-ai-gateway",
|
||||
);
|
||||
setRuntimeAuthStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
resolveCopilotApiTokenMock.mockReset();
|
||||
buildOllamaProviderMock.mockReset();
|
||||
buildVllmProviderMock.mockReset();
|
||||
buildSglangProviderMock.mockReset();
|
||||
ensureAuthProfileStoreMock.mockReset();
|
||||
listProfilesForProviderMock.mockReset();
|
||||
});
|
||||
|
||||
it("keeps GitHub Copilot catalog disabled without env tokens or profiles", async () => {
|
||||
await expect(runCatalog({ provider: githubCopilotProvider })).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps GitHub Copilot profile-only catalog fallback provider-owned", async () => {
|
||||
setGithubCopilotProfileSnapshot();
|
||||
|
||||
await expect(
|
||||
runCatalog({
|
||||
provider: githubCopilotProvider,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
provider: {
|
||||
baseUrl: "https://api.individual.githubcopilot.com",
|
||||
models: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps GitHub Copilot env-token base URL resolution provider-owned", async () => {
|
||||
resolveCopilotApiTokenMock.mockResolvedValueOnce({
|
||||
token: "copilot-api-token",
|
||||
baseUrl: "https://copilot-proxy.example.com",
|
||||
expiresAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runCatalog({
|
||||
provider: githubCopilotProvider,
|
||||
env: {
|
||||
GITHUB_TOKEN: "github-env-token",
|
||||
} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({ apiKey: undefined }),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
provider: {
|
||||
baseUrl: "https://copilot-proxy.example.com",
|
||||
models: [],
|
||||
},
|
||||
});
|
||||
expect(resolveCopilotApiTokenMock).toHaveBeenCalledWith({
|
||||
githubToken: "github-env-token",
|
||||
env: expect.objectContaining({
|
||||
GITHUB_TOKEN: "github-env-token",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Ollama explicit catalog normalization provider-owned", async () => {
|
||||
await expect(
|
||||
runProviderCatalog({
|
||||
provider: ollamaProvider,
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://ollama-host:11434/v1/",
|
||||
models: [createModelConfig("llama3.2")],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({ apiKey: undefined }),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: undefined,
|
||||
discoveryApiKey: undefined,
|
||||
mode: "none",
|
||||
source: "none",
|
||||
}),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
provider: {
|
||||
baseUrl: "http://ollama-host:11434",
|
||||
api: "ollama",
|
||||
apiKey: "ollama-local",
|
||||
models: [createModelConfig("llama3.2")],
|
||||
},
|
||||
});
|
||||
expect(buildOllamaProviderMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps Ollama empty autodiscovery disabled without keys or explicit config", async () => {
|
||||
buildOllamaProviderMock.mockResolvedValueOnce({
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
runProviderCatalog({
|
||||
provider: ollamaProvider,
|
||||
config: {},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({ apiKey: undefined }),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: undefined,
|
||||
discoveryApiKey: undefined,
|
||||
mode: "none",
|
||||
source: "none",
|
||||
}),
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
expect(buildOllamaProviderMock).toHaveBeenCalledWith(undefined, { quiet: true });
|
||||
});
|
||||
|
||||
it("keeps vLLM self-hosted discovery provider-owned", async () => {
|
||||
buildVllmProviderMock.mockResolvedValueOnce({
|
||||
baseUrl: "http://127.0.0.1:8000/v1",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "meta-llama/Meta-Llama-3-8B-Instruct", name: "Meta Llama 3" }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
runProviderCatalog({
|
||||
provider: vllmProvider,
|
||||
config: {},
|
||||
env: {
|
||||
VLLM_API_KEY: "env-vllm-key",
|
||||
} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({
|
||||
apiKey: "VLLM_API_KEY",
|
||||
discoveryApiKey: "env-vllm-key",
|
||||
}),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: "VLLM_API_KEY",
|
||||
discoveryApiKey: "env-vllm-key",
|
||||
mode: "api_key",
|
||||
source: "env",
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
provider: {
|
||||
baseUrl: "http://127.0.0.1:8000/v1",
|
||||
api: "openai-completions",
|
||||
apiKey: "VLLM_API_KEY",
|
||||
models: [{ id: "meta-llama/Meta-Llama-3-8B-Instruct", name: "Meta Llama 3" }],
|
||||
},
|
||||
});
|
||||
expect(buildVllmProviderMock).toHaveBeenCalledWith({
|
||||
apiKey: "env-vllm-key",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps SGLang self-hosted discovery provider-owned", async () => {
|
||||
buildSglangProviderMock.mockResolvedValueOnce({
|
||||
baseUrl: "http://127.0.0.1:30000/v1",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "Qwen/Qwen3-8B", name: "Qwen3-8B" }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
runProviderCatalog({
|
||||
provider: sglangProvider,
|
||||
config: {},
|
||||
env: {
|
||||
SGLANG_API_KEY: "env-sglang-key",
|
||||
} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({
|
||||
apiKey: "SGLANG_API_KEY",
|
||||
discoveryApiKey: "env-sglang-key",
|
||||
}),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: "SGLANG_API_KEY",
|
||||
discoveryApiKey: "env-sglang-key",
|
||||
mode: "api_key",
|
||||
source: "env",
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
provider: {
|
||||
baseUrl: "http://127.0.0.1:30000/v1",
|
||||
api: "openai-completions",
|
||||
apiKey: "SGLANG_API_KEY",
|
||||
models: [{ id: "Qwen/Qwen3-8B", name: "Qwen3-8B" }],
|
||||
},
|
||||
});
|
||||
expect(buildSglangProviderMock).toHaveBeenCalledWith({
|
||||
apiKey: "env-sglang-key",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps MiniMax API catalog provider-owned", async () => {
|
||||
await expect(
|
||||
runProviderCatalog({
|
||||
provider: minimaxProvider,
|
||||
config: {},
|
||||
env: {
|
||||
MINIMAX_API_KEY: "minimax-key",
|
||||
} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({ apiKey: "minimax-key" }),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: "minimax-key",
|
||||
discoveryApiKey: undefined,
|
||||
mode: "api_key",
|
||||
source: "env",
|
||||
}),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
provider: {
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
api: "anthropic-messages",
|
||||
authHeader: true,
|
||||
apiKey: "minimax-key",
|
||||
models: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "MiniMax-M2.7" }),
|
||||
expect.objectContaining({ id: "MiniMax-M2.7-highspeed" }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps MiniMax portal oauth marker fallback provider-owned", async () => {
|
||||
setRuntimeAuthStore({
|
||||
version: 1,
|
||||
profiles: {
|
||||
"minimax-portal:default": {
|
||||
type: "oauth",
|
||||
provider: "minimax-portal",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
runProviderCatalog({
|
||||
provider: minimaxPortalProvider,
|
||||
config: {},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({ apiKey: undefined }),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: "minimax-oauth",
|
||||
discoveryApiKey: "access-token",
|
||||
mode: "oauth",
|
||||
source: "profile",
|
||||
profileId: "minimax-portal:default",
|
||||
}),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
provider: {
|
||||
baseUrl: "https://api.minimax.io/anthropic",
|
||||
api: "anthropic-messages",
|
||||
authHeader: true,
|
||||
apiKey: "minimax-oauth",
|
||||
models: expect.arrayContaining([expect.objectContaining({ id: "MiniMax-M2.7" })]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps MiniMax portal explicit base URL override provider-owned", async () => {
|
||||
await expect(
|
||||
runProviderCatalog({
|
||||
provider: minimaxPortalProvider,
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
"minimax-portal": {
|
||||
baseUrl: "https://portal-proxy.example.com/anthropic",
|
||||
apiKey: "explicit-key",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({ apiKey: undefined }),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: undefined,
|
||||
discoveryApiKey: undefined,
|
||||
mode: "none",
|
||||
source: "none",
|
||||
}),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
provider: {
|
||||
baseUrl: "https://portal-proxy.example.com/anthropic",
|
||||
apiKey: "explicit-key",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Model Studio catalog provider-owned", async () => {
|
||||
await expect(
|
||||
runProviderCatalog({
|
||||
provider: modelStudioProvider,
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
modelstudio: {
|
||||
baseUrl: "https://coding.dashscope.aliyuncs.com/v1",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {
|
||||
MODELSTUDIO_API_KEY: "modelstudio-key",
|
||||
} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({ apiKey: "modelstudio-key" }),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: "modelstudio-key",
|
||||
discoveryApiKey: undefined,
|
||||
mode: "api_key",
|
||||
source: "env",
|
||||
}),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
provider: {
|
||||
baseUrl: "https://coding.dashscope.aliyuncs.com/v1",
|
||||
api: "openai-completions",
|
||||
apiKey: "modelstudio-key",
|
||||
models: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "qwen3.5-plus" }),
|
||||
expect.objectContaining({ id: "MiniMax-M2.5" }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Cloudflare AI Gateway catalog disabled without stored metadata", async () => {
|
||||
await expect(
|
||||
runProviderCatalog({
|
||||
provider: cloudflareAiGatewayProvider,
|
||||
config: {},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({ apiKey: undefined }),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: undefined,
|
||||
discoveryApiKey: undefined,
|
||||
mode: "none",
|
||||
source: "none",
|
||||
}),
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps Cloudflare AI Gateway env-managed catalog provider-owned", async () => {
|
||||
setRuntimeAuthStore({
|
||||
version: 1,
|
||||
profiles: {
|
||||
"cloudflare-ai-gateway:default": {
|
||||
type: "api_key",
|
||||
provider: "cloudflare-ai-gateway",
|
||||
keyRef: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "CLOUDFLARE_AI_GATEWAY_API_KEY",
|
||||
},
|
||||
metadata: {
|
||||
accountId: "acc-123",
|
||||
gatewayId: "gw-456",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
runProviderCatalog({
|
||||
provider: cloudflareAiGatewayProvider,
|
||||
config: {},
|
||||
env: {
|
||||
CLOUDFLARE_AI_GATEWAY_API_KEY: "secret-value",
|
||||
} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({ apiKey: undefined }),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: undefined,
|
||||
discoveryApiKey: undefined,
|
||||
mode: "none",
|
||||
source: "none",
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
provider: {
|
||||
baseUrl: "https://gateway.ai.cloudflare.com/v1/acc-123/gw-456/anthropic",
|
||||
api: "anthropic-messages",
|
||||
apiKey: "CLOUDFLARE_AI_GATEWAY_API_KEY",
|
||||
models: [expect.objectContaining({ id: "claude-sonnet-4-5" })],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,8 @@ function resolveBundledManifestProviderPluginIds() {
|
||||
);
|
||||
}
|
||||
|
||||
const demoAllowEntry = "demo-allowed";
|
||||
|
||||
describe("plugin loader contract", () => {
|
||||
let providerPluginIds: string[];
|
||||
let manifestProviderPluginIds: string[];
|
||||
@@ -31,14 +33,14 @@ describe("plugin loader contract", () => {
|
||||
compatPluginIds = providerTesting.resolveBundledProviderCompatPluginIds({
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["openrouter"],
|
||||
allow: [demoAllowEntry],
|
||||
},
|
||||
},
|
||||
});
|
||||
compatConfig = withBundledPluginAllowlistCompat({
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["openrouter"],
|
||||
allow: [demoAllowEntry],
|
||||
},
|
||||
},
|
||||
pluginIds: compatPluginIds,
|
||||
@@ -55,7 +57,7 @@ describe("plugin loader contract", () => {
|
||||
webSearchAllowlistCompatConfig = withBundledPluginAllowlistCompat({
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["openrouter"],
|
||||
allow: [demoAllowEntry],
|
||||
},
|
||||
},
|
||||
pluginIds: webSearchPluginIds,
|
||||
|
||||
@@ -83,14 +83,14 @@ describe("memory embedding provider registration", () => {
|
||||
}),
|
||||
register(api) {
|
||||
api.registerMemoryEmbeddingProvider({
|
||||
id: "openai",
|
||||
id: "demo-embedding",
|
||||
create: async () => ({ provider: null }),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
expect(getRegisteredMemoryEmbeddingProvider("openai")).toEqual({
|
||||
adapter: expect.objectContaining({ id: "openai" }),
|
||||
expect(getRegisteredMemoryEmbeddingProvider("demo-embedding")).toEqual({
|
||||
adapter: expect.objectContaining({ id: "demo-embedding" }),
|
||||
ownerPluginId: "memory-core",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { providerContractLoadError, providerContractRegistry } from "./registry.js";
|
||||
import { installProviderPluginContractSuite } from "./suites.js";
|
||||
|
||||
describe("provider contract registry load", () => {
|
||||
it("loads bundled providers without import-time registry failure", () => {
|
||||
expect(providerContractLoadError).toBeUndefined();
|
||||
expect(providerContractRegistry.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
for (const entry of providerContractRegistry) {
|
||||
describe(`${entry.pluginId}:${entry.provider.id} provider contract`, () => {
|
||||
installProviderPluginContractSuite({
|
||||
provider: entry.provider,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -12,93 +12,6 @@ import {
|
||||
|
||||
const REGISTRY_CONTRACT_TIMEOUT_MS = 300_000;
|
||||
|
||||
function findProviderIdsForPlugin(pluginId: string) {
|
||||
return (
|
||||
pluginRegistrationContractRegistry.find((entry) => entry.pluginId === pluginId)?.providerIds ??
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
function findWebSearchIdsForPlugin(pluginId: string) {
|
||||
return (
|
||||
pluginRegistrationContractRegistry.find((entry) => entry.pluginId === pluginId)
|
||||
?.webSearchProviderIds ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function findSpeechProviderIdsForPlugin(pluginId: string) {
|
||||
return speechProviderContractRegistry
|
||||
.filter((entry) => entry.pluginId === pluginId)
|
||||
.map((entry) => entry.provider.id)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function findSpeechProviderForPlugin(pluginId: string) {
|
||||
const entry = speechProviderContractRegistry.find((candidate) => candidate.pluginId === pluginId);
|
||||
if (!entry) {
|
||||
throw new Error(`speech provider contract missing for ${pluginId}`);
|
||||
}
|
||||
return entry.provider;
|
||||
}
|
||||
|
||||
function findMediaUnderstandingProviderIdsForPlugin(pluginId: string) {
|
||||
return mediaUnderstandingProviderContractRegistry
|
||||
.filter((entry) => entry.pluginId === pluginId)
|
||||
.map((entry) => entry.provider.id)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function findMediaUnderstandingProviderForPlugin(pluginId: string) {
|
||||
const entry = mediaUnderstandingProviderContractRegistry.find(
|
||||
(candidate) => candidate.pluginId === pluginId,
|
||||
);
|
||||
if (!entry) {
|
||||
throw new Error(`media-understanding provider contract missing for ${pluginId}`);
|
||||
}
|
||||
return entry.provider;
|
||||
}
|
||||
|
||||
function findImageGenerationProviderIdsForPlugin(pluginId: string) {
|
||||
return imageGenerationProviderContractRegistry
|
||||
.filter((entry) => entry.pluginId === pluginId)
|
||||
.map((entry) => entry.provider.id)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function findImageGenerationProviderForPlugin(pluginId: string) {
|
||||
const entry = imageGenerationProviderContractRegistry.find(
|
||||
(candidate) => candidate.pluginId === pluginId,
|
||||
);
|
||||
if (!entry) {
|
||||
throw new Error(`image-generation provider contract missing for ${pluginId}`);
|
||||
}
|
||||
return entry.provider;
|
||||
}
|
||||
|
||||
function findRegistrationForPlugin(pluginId: string) {
|
||||
const entry = pluginRegistrationContractRegistry.find(
|
||||
(candidate) => candidate.pluginId === pluginId,
|
||||
);
|
||||
if (!entry) {
|
||||
throw new Error(`plugin registration contract missing for ${pluginId}`);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
type BundledCapabilityContractKey =
|
||||
| "speechProviders"
|
||||
| "mediaUnderstandingProviders"
|
||||
| "imageGenerationProviders";
|
||||
|
||||
function findBundledManifestPluginIdsForContract(key: BundledCapabilityContractKey) {
|
||||
return loadPluginManifestRegistry({})
|
||||
.plugins.filter(
|
||||
(plugin) => plugin.origin === "bundled" && (plugin.contracts?.[key]?.length ?? 0) > 0,
|
||||
)
|
||||
.map((plugin) => plugin.id)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
describe("plugin contract registry", () => {
|
||||
it("loads bundled non-provider capability registries without import-time failure", () => {
|
||||
expect(providerContractLoadError).toBeUndefined();
|
||||
@@ -139,7 +52,13 @@ describe("plugin contract registry", () => {
|
||||
});
|
||||
|
||||
it("covers every bundled speech plugin discovered from manifests", () => {
|
||||
const bundledSpeechPluginIds = findBundledManifestPluginIdsForContract("speechProviders");
|
||||
const bundledSpeechPluginIds = loadPluginManifestRegistry({})
|
||||
.plugins.filter(
|
||||
(plugin) =>
|
||||
plugin.origin === "bundled" && (plugin.contracts?.speechProviders?.length ?? 0) > 0,
|
||||
)
|
||||
.map((plugin) => plugin.id)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
|
||||
expect(
|
||||
[...new Set(speechProviderContractRegistry.map((entry) => entry.pluginId))].toSorted(
|
||||
@@ -148,30 +67,6 @@ describe("plugin contract registry", () => {
|
||||
).toEqual(bundledSpeechPluginIds);
|
||||
});
|
||||
|
||||
it("covers every bundled media-understanding plugin discovered from manifests", () => {
|
||||
const bundledMediaPluginIds = findBundledManifestPluginIdsForContract(
|
||||
"mediaUnderstandingProviders",
|
||||
);
|
||||
|
||||
expect(
|
||||
[
|
||||
...new Set(mediaUnderstandingProviderContractRegistry.map((entry) => entry.pluginId)),
|
||||
].toSorted((left, right) => left.localeCompare(right)),
|
||||
).toEqual(bundledMediaPluginIds);
|
||||
});
|
||||
|
||||
it("covers every bundled image-generation plugin discovered from manifests", () => {
|
||||
const bundledImagePluginIds = findBundledManifestPluginIdsForContract(
|
||||
"imageGenerationProviders",
|
||||
);
|
||||
|
||||
expect(
|
||||
[...new Set(imageGenerationProviderContractRegistry.map((entry) => entry.pluginId))].toSorted(
|
||||
(left, right) => left.localeCompare(right),
|
||||
),
|
||||
).toEqual(bundledImagePluginIds);
|
||||
});
|
||||
|
||||
it("covers every bundled web search plugin from the shared resolver", () => {
|
||||
const bundledWebSearchPluginIds = resolveBundledWebSearchPluginIds({});
|
||||
|
||||
@@ -183,222 +78,8 @@ describe("plugin contract registry", () => {
|
||||
).toEqual(bundledWebSearchPluginIds);
|
||||
});
|
||||
|
||||
it("keeps Kimi Coding onboarding grouped under Moonshot", () => {
|
||||
const kimi = loadPluginManifestRegistry({}).plugins.find(
|
||||
(plugin) => plugin.origin === "bundled" && plugin.id === "kimi",
|
||||
);
|
||||
|
||||
expect(kimi?.providerAuthChoices).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
choiceId: "kimi-code-api-key",
|
||||
choiceLabel: "Kimi Code API key (subscription)",
|
||||
groupId: "moonshot",
|
||||
groupLabel: "Moonshot AI (Kimi K2.5)",
|
||||
groupHint: "Kimi K2.5",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate bundled image-generation provider ids", () => {
|
||||
const ids = imageGenerationProviderContractRegistry.map((entry) => entry.provider.id);
|
||||
expect(ids).toEqual([...new Set(ids)]);
|
||||
});
|
||||
it("keeps multi-provider plugin ownership explicit", () => {
|
||||
expect(findProviderIdsForPlugin("google")).toEqual(["google", "google-gemini-cli"]);
|
||||
expect(findProviderIdsForPlugin("minimax")).toEqual(["minimax", "minimax-portal"]);
|
||||
expect(findProviderIdsForPlugin("openai")).toEqual(["openai", "openai-codex"]);
|
||||
});
|
||||
|
||||
it("keeps bundled web search ownership explicit", () => {
|
||||
expect(findWebSearchIdsForPlugin("brave")).toEqual(["brave"]);
|
||||
expect(findWebSearchIdsForPlugin("duckduckgo")).toEqual(["duckduckgo"]);
|
||||
expect(findWebSearchIdsForPlugin("exa")).toEqual(["exa"]);
|
||||
expect(findWebSearchIdsForPlugin("firecrawl")).toEqual(["firecrawl"]);
|
||||
expect(findWebSearchIdsForPlugin("google")).toEqual(["gemini"]);
|
||||
expect(findWebSearchIdsForPlugin("moonshot")).toEqual(["kimi"]);
|
||||
expect(findWebSearchIdsForPlugin("perplexity")).toEqual(["perplexity"]);
|
||||
expect(findWebSearchIdsForPlugin("tavily")).toEqual(["tavily"]);
|
||||
expect(findWebSearchIdsForPlugin("xai")).toEqual(["grok"]);
|
||||
});
|
||||
|
||||
it("keeps bundled speech ownership explicit", () => {
|
||||
expect(findSpeechProviderIdsForPlugin("elevenlabs")).toEqual(["elevenlabs"]);
|
||||
expect(findSpeechProviderIdsForPlugin("microsoft")).toEqual(["microsoft"]);
|
||||
expect(findSpeechProviderIdsForPlugin("openai")).toEqual(["openai"]);
|
||||
});
|
||||
|
||||
it("keeps bundled media-understanding ownership explicit", () => {
|
||||
expect(findMediaUnderstandingProviderIdsForPlugin("anthropic")).toEqual(["anthropic"]);
|
||||
expect(findMediaUnderstandingProviderIdsForPlugin("google")).toEqual(["google"]);
|
||||
expect(findMediaUnderstandingProviderIdsForPlugin("minimax")).toEqual([
|
||||
"minimax",
|
||||
"minimax-portal",
|
||||
]);
|
||||
expect(findMediaUnderstandingProviderIdsForPlugin("mistral")).toEqual(["mistral"]);
|
||||
expect(findMediaUnderstandingProviderIdsForPlugin("moonshot")).toEqual(["moonshot"]);
|
||||
expect(findMediaUnderstandingProviderIdsForPlugin("openai")).toEqual([
|
||||
"openai",
|
||||
"openai-codex",
|
||||
]);
|
||||
expect(findMediaUnderstandingProviderIdsForPlugin("zai")).toEqual(["zai"]);
|
||||
});
|
||||
|
||||
it("keeps bundled image-generation ownership explicit", () => {
|
||||
expect(findImageGenerationProviderIdsForPlugin("fal")).toEqual(["fal"]);
|
||||
expect(findImageGenerationProviderIdsForPlugin("google")).toEqual(["google"]);
|
||||
expect(findImageGenerationProviderIdsForPlugin("minimax")).toEqual([
|
||||
"minimax",
|
||||
"minimax-portal",
|
||||
]);
|
||||
expect(findImageGenerationProviderIdsForPlugin("openai")).toEqual(["openai"]);
|
||||
});
|
||||
|
||||
it("keeps bundled provider and web search tool ownership explicit", () => {
|
||||
expect(findRegistrationForPlugin("exa")).toMatchObject({
|
||||
cliBackendIds: [],
|
||||
providerIds: [],
|
||||
speechProviderIds: [],
|
||||
mediaUnderstandingProviderIds: [],
|
||||
imageGenerationProviderIds: [],
|
||||
webSearchProviderIds: ["exa"],
|
||||
toolNames: [],
|
||||
});
|
||||
expect(findRegistrationForPlugin("firecrawl")).toMatchObject({
|
||||
cliBackendIds: [],
|
||||
providerIds: [],
|
||||
speechProviderIds: [],
|
||||
mediaUnderstandingProviderIds: [],
|
||||
imageGenerationProviderIds: [],
|
||||
webSearchProviderIds: ["firecrawl"],
|
||||
toolNames: ["firecrawl_search", "firecrawl_scrape"],
|
||||
});
|
||||
expect(findRegistrationForPlugin("tavily")).toMatchObject({
|
||||
cliBackendIds: [],
|
||||
providerIds: [],
|
||||
speechProviderIds: [],
|
||||
mediaUnderstandingProviderIds: [],
|
||||
imageGenerationProviderIds: [],
|
||||
webSearchProviderIds: ["tavily"],
|
||||
toolNames: ["tavily_search", "tavily_extract"],
|
||||
});
|
||||
});
|
||||
|
||||
it("tracks speech registrations on bundled provider plugins", () => {
|
||||
expect(findRegistrationForPlugin("fal")).toMatchObject({
|
||||
cliBackendIds: [],
|
||||
providerIds: ["fal"],
|
||||
speechProviderIds: [],
|
||||
mediaUnderstandingProviderIds: [],
|
||||
imageGenerationProviderIds: ["fal"],
|
||||
webSearchProviderIds: [],
|
||||
});
|
||||
expect(findRegistrationForPlugin("anthropic")).toMatchObject({
|
||||
cliBackendIds: ["claude-cli"],
|
||||
providerIds: ["anthropic"],
|
||||
speechProviderIds: [],
|
||||
mediaUnderstandingProviderIds: ["anthropic"],
|
||||
imageGenerationProviderIds: [],
|
||||
webSearchProviderIds: [],
|
||||
});
|
||||
expect(findRegistrationForPlugin("google")).toMatchObject({
|
||||
cliBackendIds: ["google-gemini-cli"],
|
||||
providerIds: ["google", "google-gemini-cli"],
|
||||
speechProviderIds: [],
|
||||
mediaUnderstandingProviderIds: ["google"],
|
||||
imageGenerationProviderIds: ["google"],
|
||||
webSearchProviderIds: ["gemini"],
|
||||
});
|
||||
expect(findRegistrationForPlugin("openai")).toMatchObject({
|
||||
cliBackendIds: ["codex-cli"],
|
||||
providerIds: ["openai", "openai-codex"],
|
||||
speechProviderIds: ["openai"],
|
||||
mediaUnderstandingProviderIds: ["openai", "openai-codex"],
|
||||
imageGenerationProviderIds: ["openai"],
|
||||
});
|
||||
expect(findRegistrationForPlugin("minimax")).toMatchObject({
|
||||
cliBackendIds: [],
|
||||
providerIds: ["minimax", "minimax-portal"],
|
||||
speechProviderIds: [],
|
||||
mediaUnderstandingProviderIds: ["minimax", "minimax-portal"],
|
||||
imageGenerationProviderIds: ["minimax", "minimax-portal"],
|
||||
webSearchProviderIds: [],
|
||||
});
|
||||
expect(findRegistrationForPlugin("elevenlabs")).toMatchObject({
|
||||
cliBackendIds: [],
|
||||
providerIds: [],
|
||||
speechProviderIds: ["elevenlabs"],
|
||||
mediaUnderstandingProviderIds: [],
|
||||
imageGenerationProviderIds: [],
|
||||
});
|
||||
expect(findRegistrationForPlugin("microsoft")).toMatchObject({
|
||||
cliBackendIds: [],
|
||||
providerIds: [],
|
||||
speechProviderIds: ["microsoft"],
|
||||
mediaUnderstandingProviderIds: [],
|
||||
imageGenerationProviderIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("tracks every provider, speech, media, image, or web search plugin in the registration registry", () => {
|
||||
const expectedPluginIds = [
|
||||
...new Set([
|
||||
...pluginRegistrationContractRegistry
|
||||
.filter((entry) => entry.providerIds.length > 0)
|
||||
.map((entry) => entry.pluginId),
|
||||
...speechProviderContractRegistry.map((entry) => entry.pluginId),
|
||||
...mediaUnderstandingProviderContractRegistry.map((entry) => entry.pluginId),
|
||||
...imageGenerationProviderContractRegistry.map((entry) => entry.pluginId),
|
||||
...pluginRegistrationContractRegistry
|
||||
.filter((entry) => entry.webSearchProviderIds.length > 0)
|
||||
.map((entry) => entry.pluginId),
|
||||
]),
|
||||
].toSorted((left, right) => left.localeCompare(right));
|
||||
|
||||
expect(
|
||||
pluginRegistrationContractRegistry
|
||||
.map((entry) => entry.pluginId)
|
||||
.toSorted((left, right) => left.localeCompare(right)),
|
||||
).toEqual(expectedPluginIds);
|
||||
});
|
||||
|
||||
it("keeps bundled speech voice-list support explicit", () => {
|
||||
expect(findSpeechProviderForPlugin("openai").listVoices).toEqual(expect.any(Function));
|
||||
expect(findSpeechProviderForPlugin("elevenlabs").listVoices).toEqual(expect.any(Function));
|
||||
expect(findSpeechProviderForPlugin("microsoft").listVoices).toEqual(expect.any(Function));
|
||||
});
|
||||
|
||||
it("keeps bundled multi-image support explicit", () => {
|
||||
expect(findMediaUnderstandingProviderForPlugin("anthropic").describeImages).toEqual(
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(findMediaUnderstandingProviderForPlugin("google").describeImages).toEqual(
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(findMediaUnderstandingProviderForPlugin("minimax").describeImages).toEqual(
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(findMediaUnderstandingProviderForPlugin("moonshot").describeImages).toEqual(
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(findMediaUnderstandingProviderForPlugin("openai").describeImages).toEqual(
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(findMediaUnderstandingProviderForPlugin("zai").describeImages).toEqual(
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps bundled image-generation support explicit", () => {
|
||||
expect(findImageGenerationProviderForPlugin("google").generateImage).toEqual(
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(findImageGenerationProviderForPlugin("minimax").generateImage).toEqual(
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(findImageGenerationProviderForPlugin("openai").generateImage).toEqual(
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,831 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
registerProviderPlugin,
|
||||
requireRegisteredProvider,
|
||||
} from "../../../test/helpers/extensions/provider-registration.js";
|
||||
import { createProviderUsageFetch, makeResponse } from "../../test-utils/provider-usage-fetch.js";
|
||||
import type { ProviderPlugin, ProviderRuntimeModel } from "../types.js";
|
||||
|
||||
const CONTRACT_SETUP_TIMEOUT_MS = 300_000;
|
||||
|
||||
const refreshOpenAICodexTokenMock = vi.hoisted(() => vi.fn());
|
||||
const getOAuthProvidersMock = vi.hoisted(() =>
|
||||
vi.fn(() => [
|
||||
{ id: "anthropic", envApiKey: "ANTHROPIC_API_KEY", oauthTokenEnv: "ANTHROPIC_OAUTH_TOKEN" }, // pragma: allowlist secret
|
||||
{ id: "google", envApiKey: "GOOGLE_API_KEY", oauthTokenEnv: "GOOGLE_OAUTH_TOKEN" }, // pragma: allowlist secret
|
||||
{ id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" }, // pragma: allowlist secret
|
||||
]),
|
||||
);
|
||||
|
||||
vi.mock("@mariozechner/pi-ai/oauth", async () => {
|
||||
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai/oauth")>(
|
||||
"@mariozechner/pi-ai/oauth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
refreshOpenAICodexToken: refreshOpenAICodexTokenMock,
|
||||
getOAuthProviders: getOAuthProvidersMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../extensions/openai/openai-codex-provider.runtime.js", () => ({
|
||||
refreshOpenAICodexToken: refreshOpenAICodexTokenMock,
|
||||
}));
|
||||
|
||||
function createModel(overrides: Partial<ProviderRuntimeModel> & Pick<ProviderRuntimeModel, "id">) {
|
||||
return {
|
||||
id: overrides.id,
|
||||
name: overrides.name ?? overrides.id,
|
||||
api: overrides.api ?? "openai-responses",
|
||||
provider: overrides.provider ?? "demo",
|
||||
baseUrl: overrides.baseUrl ?? "https://api.example.com/v1",
|
||||
reasoning: overrides.reasoning ?? true,
|
||||
input: overrides.input ?? ["text"],
|
||||
cost: overrides.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: overrides.contextWindow ?? 200_000,
|
||||
maxTokens: overrides.maxTokens ?? 8_192,
|
||||
} satisfies ProviderRuntimeModel;
|
||||
}
|
||||
|
||||
type ProviderRuntimeContractFixture = {
|
||||
providerIds: string[];
|
||||
pluginId: string;
|
||||
name: string;
|
||||
load: () => Promise<{ default: Parameters<typeof registerProviderPlugin>[0]["plugin"] }>;
|
||||
};
|
||||
|
||||
const PROVIDER_RUNTIME_CONTRACT_FIXTURES: readonly ProviderRuntimeContractFixture[] = [
|
||||
{
|
||||
providerIds: ["anthropic"],
|
||||
pluginId: "anthropic",
|
||||
name: "Anthropic",
|
||||
load: async () => await import("../../../extensions/anthropic/index.ts"),
|
||||
},
|
||||
{
|
||||
providerIds: ["github-copilot"],
|
||||
pluginId: "github-copilot",
|
||||
name: "GitHub Copilot",
|
||||
load: async () => await import("../../../extensions/github-copilot/index.ts"),
|
||||
},
|
||||
{
|
||||
providerIds: ["google", "google-gemini-cli"],
|
||||
pluginId: "google",
|
||||
name: "Google",
|
||||
load: async () => await import("../../../extensions/google/index.ts"),
|
||||
},
|
||||
{
|
||||
providerIds: ["openai", "openai-codex"],
|
||||
pluginId: "openai",
|
||||
name: "OpenAI",
|
||||
load: async () => await import("../../../extensions/openai/index.ts"),
|
||||
},
|
||||
{
|
||||
providerIds: ["openrouter"],
|
||||
pluginId: "openrouter",
|
||||
name: "OpenRouter",
|
||||
load: async () => await import("../../../extensions/openrouter/index.ts"),
|
||||
},
|
||||
{
|
||||
providerIds: ["venice"],
|
||||
pluginId: "venice",
|
||||
name: "Venice",
|
||||
load: async () => await import("../../../extensions/venice/index.ts"),
|
||||
},
|
||||
{
|
||||
providerIds: ["xai"],
|
||||
pluginId: "xai",
|
||||
name: "xAI",
|
||||
load: async () => await import("../../../extensions/xai/index.ts"),
|
||||
},
|
||||
{
|
||||
providerIds: ["zai"],
|
||||
pluginId: "zai",
|
||||
name: "Z.AI",
|
||||
load: async () => await import("../../../extensions/zai/index.ts"),
|
||||
},
|
||||
] as const;
|
||||
|
||||
const providerRuntimeContractProviders = new Map<string, ProviderPlugin>();
|
||||
|
||||
function requireProviderContractProvider(providerId: string): ProviderPlugin {
|
||||
const provider = providerRuntimeContractProviders.get(providerId);
|
||||
if (!provider) {
|
||||
throw new Error(`provider runtime contract fixture missing for ${providerId}`);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
describe("provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => {
|
||||
beforeAll(async () => {
|
||||
providerRuntimeContractProviders.clear();
|
||||
const registeredFixtures = await Promise.all(
|
||||
PROVIDER_RUNTIME_CONTRACT_FIXTURES.map(async (fixture) => {
|
||||
const plugin = await fixture.load();
|
||||
return {
|
||||
fixture,
|
||||
providers: registerProviderPlugin({
|
||||
plugin: plugin.default,
|
||||
id: fixture.pluginId,
|
||||
name: fixture.name,
|
||||
}).providers,
|
||||
};
|
||||
}),
|
||||
);
|
||||
for (const { fixture, providers } of registeredFixtures) {
|
||||
for (const providerId of fixture.providerIds) {
|
||||
providerRuntimeContractProviders.set(
|
||||
providerId,
|
||||
requireRegisteredProvider(providers, providerId, "provider"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}, CONTRACT_SETUP_TIMEOUT_MS);
|
||||
beforeEach(() => {
|
||||
refreshOpenAICodexTokenMock.mockReset();
|
||||
getOAuthProvidersMock.mockClear();
|
||||
}, CONTRACT_SETUP_TIMEOUT_MS);
|
||||
|
||||
describe("anthropic", () => {
|
||||
it(
|
||||
"owns anthropic 4.6 forward-compat resolution",
|
||||
() => {
|
||||
const provider = requireProviderContractProvider("anthropic");
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4.6-20260219",
|
||||
modelRegistry: {
|
||||
find: (_provider: string, id: string) =>
|
||||
id === "claude-sonnet-4.5-20260219"
|
||||
? createModel({
|
||||
id: id,
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
})
|
||||
: null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "claude-sonnet-4.6-20260219",
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
});
|
||||
},
|
||||
CONTRACT_SETUP_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it("owns usage auth resolution", async () => {
|
||||
const provider = requireProviderContractProvider("anthropic");
|
||||
await expect(
|
||||
provider.resolveUsageAuth?.({
|
||||
config: {} as never,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
provider: "anthropic",
|
||||
resolveApiKeyFromConfigAndStore: () => undefined,
|
||||
resolveOAuthToken: async () => ({
|
||||
token: "anthropic-oauth-token",
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
token: "anthropic-oauth-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("owns auth doctor hint generation", () => {
|
||||
const provider = requireProviderContractProvider("anthropic");
|
||||
const hint = provider.buildAuthDoctorHint?.({
|
||||
provider: "anthropic",
|
||||
profileId: "anthropic:default",
|
||||
config: {
|
||||
auth: {
|
||||
profiles: {
|
||||
"anthropic:default": {
|
||||
provider: "anthropic",
|
||||
mode: "oauth",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:oauth-user@example.com": {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "oauth-access",
|
||||
refresh: "oauth-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(hint).toContain("suggested profile: anthropic:oauth-user@example.com");
|
||||
expect(hint).toContain("openclaw doctor --yes");
|
||||
});
|
||||
|
||||
it("owns usage snapshot fetching", async () => {
|
||||
const provider = requireProviderContractProvider("anthropic");
|
||||
const mockFetch = createProviderUsageFetch(async (url) => {
|
||||
if (url.includes("api.anthropic.com/api/oauth/usage")) {
|
||||
return makeResponse(200, {
|
||||
five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" },
|
||||
seven_day: { utilization: 35, resets_at: "2026-01-09T01:00:00Z" },
|
||||
});
|
||||
}
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.fetchUsageSnapshot?.({
|
||||
config: {} as never,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
provider: "anthropic",
|
||||
token: "anthropic-oauth-token",
|
||||
timeoutMs: 5_000,
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
provider: "anthropic",
|
||||
displayName: "Claude",
|
||||
windows: [
|
||||
{ label: "5h", usedPercent: 20, resetAt: Date.parse("2026-01-07T01:00:00Z") },
|
||||
{ label: "Week", usedPercent: 35, resetAt: Date.parse("2026-01-09T01:00:00Z") },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("github-copilot", () => {
|
||||
it("owns Copilot-specific forward-compat fallbacks", () => {
|
||||
const provider = requireProviderContractProvider("github-copilot");
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "github-copilot",
|
||||
modelId: "gpt-5.4",
|
||||
modelRegistry: {
|
||||
find: (_provider: string, id: string) =>
|
||||
id === "gpt-5.2-codex"
|
||||
? createModel({
|
||||
id,
|
||||
api: "openai-codex-responses",
|
||||
provider: "github-copilot",
|
||||
baseUrl: "https://api.copilot.example",
|
||||
})
|
||||
: null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "gpt-5.4",
|
||||
provider: "github-copilot",
|
||||
api: "openai-codex-responses",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("google", () => {
|
||||
it("owns google direct gemini 3.1 forward-compat resolution", () => {
|
||||
const provider = requireProviderContractProvider("google");
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "google",
|
||||
modelId: "gemini-3.1-pro-preview",
|
||||
modelRegistry: {
|
||||
find: (_provider: string, id: string) =>
|
||||
id === "gemini-3-pro-preview"
|
||||
? createModel({
|
||||
id,
|
||||
api: "google-generative-ai",
|
||||
provider: "google",
|
||||
baseUrl: "https://generativelanguage.googleapis.com",
|
||||
reasoning: false,
|
||||
contextWindow: 1_048_576,
|
||||
maxTokens: 65_536,
|
||||
})
|
||||
: null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "gemini-3.1-pro-preview",
|
||||
provider: "google",
|
||||
api: "google-generative-ai",
|
||||
baseUrl: "https://generativelanguage.googleapis.com",
|
||||
reasoning: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("google-gemini-cli", () => {
|
||||
it("owns gemini cli 3.1 forward-compat resolution", () => {
|
||||
const provider = requireProviderContractProvider("google-gemini-cli");
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "google-gemini-cli",
|
||||
modelId: "gemini-3.1-pro-preview",
|
||||
modelRegistry: {
|
||||
find: (_provider: string, id: string) =>
|
||||
id === "gemini-3-pro-preview"
|
||||
? createModel({
|
||||
id,
|
||||
api: "google-gemini-cli",
|
||||
provider: "google-gemini-cli",
|
||||
baseUrl: "https://cloudcode-pa.googleapis.com",
|
||||
reasoning: false,
|
||||
contextWindow: 1_048_576,
|
||||
maxTokens: 65_536,
|
||||
})
|
||||
: null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "gemini-3.1-pro-preview",
|
||||
provider: "google-gemini-cli",
|
||||
reasoning: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("owns usage-token parsing", async () => {
|
||||
const provider = requireProviderContractProvider("google-gemini-cli");
|
||||
await expect(
|
||||
provider.resolveUsageAuth?.({
|
||||
config: {} as never,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
provider: "google-gemini-cli",
|
||||
resolveApiKeyFromConfigAndStore: () => undefined,
|
||||
resolveOAuthToken: async () => ({
|
||||
token: '{"token":"google-oauth-token"}',
|
||||
accountId: "google-account",
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
token: "google-oauth-token",
|
||||
accountId: "google-account",
|
||||
});
|
||||
});
|
||||
|
||||
it("owns OAuth auth-profile formatting", () => {
|
||||
const provider = requireProviderContractProvider("google-gemini-cli");
|
||||
|
||||
expect(
|
||||
provider.formatApiKey?.({
|
||||
type: "oauth",
|
||||
provider: "google-gemini-cli",
|
||||
access: "google-oauth-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
projectId: "proj-123",
|
||||
}),
|
||||
).toBe('{"token":"google-oauth-token","projectId":"proj-123"}');
|
||||
});
|
||||
|
||||
it("owns usage snapshot fetching", async () => {
|
||||
const provider = requireProviderContractProvider("google-gemini-cli");
|
||||
const mockFetch = createProviderUsageFetch(async (url) => {
|
||||
if (url.includes("cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota")) {
|
||||
return makeResponse(200, {
|
||||
buckets: [
|
||||
{ modelId: "gemini-3.1-pro-preview", remainingFraction: 0.4 },
|
||||
{ modelId: "gemini-3.1-flash-preview", remainingFraction: 0.8 },
|
||||
],
|
||||
});
|
||||
}
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const snapshot = await provider.fetchUsageSnapshot?.({
|
||||
config: {} as never,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
provider: "google-gemini-cli",
|
||||
token: "google-oauth-token",
|
||||
timeoutMs: 5_000,
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
provider: "google-gemini-cli",
|
||||
displayName: "Gemini",
|
||||
});
|
||||
expect(snapshot?.windows[0]).toEqual({ label: "Pro", usedPercent: 60 });
|
||||
expect(snapshot?.windows[1]?.label).toBe("Flash");
|
||||
expect(snapshot?.windows[1]?.usedPercent).toBeCloseTo(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openai", () => {
|
||||
it("owns openai gpt-5.4 forward-compat resolution", () => {
|
||||
const provider = requireProviderContractProvider("openai");
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.4-pro",
|
||||
modelRegistry: {
|
||||
find: (_provider: string, id: string) =>
|
||||
id === "gpt-5.2-pro"
|
||||
? createModel({
|
||||
id,
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
input: ["text", "image"],
|
||||
})
|
||||
: null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "gpt-5.4-pro",
|
||||
provider: "openai",
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
contextWindow: 1_050_000,
|
||||
maxTokens: 128_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("owns openai gpt-5.4 mini forward-compat resolution", () => {
|
||||
const provider = requireProviderContractProvider("openai");
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.4-mini",
|
||||
modelRegistry: {
|
||||
find: (_provider: string, id: string) =>
|
||||
id === "gpt-5-mini"
|
||||
? createModel({
|
||||
id,
|
||||
provider: "openai",
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
input: ["text", "image"],
|
||||
reasoning: true,
|
||||
contextWindow: 400_000,
|
||||
maxTokens: 128_000,
|
||||
})
|
||||
: null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "gpt-5.4-mini",
|
||||
provider: "openai",
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
contextWindow: 400_000,
|
||||
maxTokens: 128_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("owns direct openai transport normalization", () => {
|
||||
const provider = requireProviderContractProvider("openai");
|
||||
expect(
|
||||
provider.normalizeResolvedModel?.({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.4",
|
||||
model: createModel({
|
||||
id: "gpt-5.4",
|
||||
provider: "openai",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
input: ["text", "image"],
|
||||
contextWindow: 1_050_000,
|
||||
maxTokens: 128_000,
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
api: "openai-responses",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("xai", () => {
|
||||
it("owns Grok forward-compat resolution for newer fast models", () => {
|
||||
const provider = requireProviderContractProvider("xai");
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "xai",
|
||||
modelId: "grok-4-1-fast-reasoning",
|
||||
modelRegistry: {
|
||||
find: () => null,
|
||||
} as never,
|
||||
providerConfig: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "grok-4-1-fast-reasoning",
|
||||
provider: "xai",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
reasoning: true,
|
||||
contextWindow: 2_000_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("owns xai modern-model matching without accepting multi-agent ids", () => {
|
||||
const provider = requireProviderContractProvider("xai");
|
||||
|
||||
expect(
|
||||
provider.isModernModelRef?.({
|
||||
provider: "xai",
|
||||
modelId: "grok-4-1-fast-reasoning",
|
||||
} as never),
|
||||
).toBe(true);
|
||||
expect(
|
||||
provider.isModernModelRef?.({
|
||||
provider: "xai",
|
||||
modelId: "grok-4.20-multi-agent-experimental-beta-0304",
|
||||
} as never),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("owns direct xai compat flags on resolved models", () => {
|
||||
const provider = requireProviderContractProvider("xai");
|
||||
|
||||
expect(
|
||||
provider.normalizeResolvedModel?.({
|
||||
provider: "xai",
|
||||
modelId: "grok-4-1-fast",
|
||||
model: createModel({
|
||||
id: "grok-4-1-fast",
|
||||
provider: "xai",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
}),
|
||||
} as never),
|
||||
).toMatchObject({
|
||||
compat: {
|
||||
toolSchemaProfile: "xai",
|
||||
nativeWebSearchTool: true,
|
||||
toolCallArgumentsEncoding: "html-entities",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("openrouter", () => {
|
||||
it("owns xai downstream compat flags for x-ai routed models", () => {
|
||||
const provider = requireProviderContractProvider("openrouter");
|
||||
expect(
|
||||
provider.normalizeResolvedModel?.({
|
||||
provider: "openrouter",
|
||||
modelId: "x-ai/grok-4-1-fast",
|
||||
model: createModel({
|
||||
id: "x-ai/grok-4-1-fast",
|
||||
provider: "openrouter",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
compat: {
|
||||
toolSchemaProfile: "xai",
|
||||
nativeWebSearchTool: true,
|
||||
toolCallArgumentsEncoding: "html-entities",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("venice", () => {
|
||||
it("owns xai downstream compat flags for grok-backed Venice models", () => {
|
||||
const provider = requireProviderContractProvider("venice");
|
||||
expect(
|
||||
provider.normalizeResolvedModel?.({
|
||||
provider: "venice",
|
||||
modelId: "grok-41-fast",
|
||||
model: createModel({
|
||||
id: "grok-41-fast",
|
||||
provider: "venice",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.venice.ai/api/v1",
|
||||
}),
|
||||
}),
|
||||
).toMatchObject({
|
||||
compat: {
|
||||
toolSchemaProfile: "xai",
|
||||
nativeWebSearchTool: true,
|
||||
toolCallArgumentsEncoding: "html-entities",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("openai-codex", () => {
|
||||
it(
|
||||
"owns refresh fallback for accountId extraction failures",
|
||||
{ timeout: CONTRACT_SETUP_TIMEOUT_MS },
|
||||
async () => {
|
||||
const provider = requireProviderContractProvider("openai-codex");
|
||||
const credential = {
|
||||
type: "oauth" as const,
|
||||
provider: "openai-codex",
|
||||
access: "cached-access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() - 60_000,
|
||||
};
|
||||
|
||||
refreshOpenAICodexTokenMock.mockRejectedValueOnce(
|
||||
new Error("Failed to extract accountId from token"),
|
||||
);
|
||||
|
||||
await expect(provider.refreshOAuth?.(credential)).resolves.toEqual(credential);
|
||||
},
|
||||
);
|
||||
|
||||
it("owns forward-compat codex models", () => {
|
||||
const provider = requireProviderContractProvider("openai-codex");
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "openai-codex",
|
||||
modelId: "gpt-5.4",
|
||||
modelRegistry: {
|
||||
find: (_provider: string, id: string) =>
|
||||
id === "gpt-5.2-codex"
|
||||
? createModel({
|
||||
id,
|
||||
api: "openai-codex-responses",
|
||||
provider: "openai-codex",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
})
|
||||
: null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "gpt-5.4",
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
contextWindow: 1_050_000,
|
||||
maxTokens: 128_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("owns codex transport defaults", () => {
|
||||
const provider = requireProviderContractProvider("openai-codex");
|
||||
expect(
|
||||
provider.prepareExtraParams?.({
|
||||
provider: "openai-codex",
|
||||
modelId: "gpt-5.4",
|
||||
extraParams: { temperature: 0.2 },
|
||||
}),
|
||||
).toEqual({
|
||||
temperature: 0.2,
|
||||
transport: "auto",
|
||||
});
|
||||
});
|
||||
|
||||
it("owns usage snapshot fetching", async () => {
|
||||
const provider = requireProviderContractProvider("openai-codex");
|
||||
const mockFetch = createProviderUsageFetch(async (url) => {
|
||||
if (url.includes("chatgpt.com/backend-api/wham/usage")) {
|
||||
return makeResponse(200, {
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 12,
|
||||
limit_window_seconds: 10800,
|
||||
reset_at: 1_705_000,
|
||||
},
|
||||
},
|
||||
plan_type: "Plus",
|
||||
});
|
||||
}
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.fetchUsageSnapshot?.({
|
||||
config: {} as never,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
provider: "openai-codex",
|
||||
token: "codex-token",
|
||||
accountId: "acc-1",
|
||||
timeoutMs: 5_000,
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
provider: "openai-codex",
|
||||
displayName: "Codex",
|
||||
windows: [{ label: "3h", usedPercent: 12, resetAt: 1_705_000_000 }],
|
||||
plan: "Plus",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("zai", () => {
|
||||
it("owns glm-5 forward-compat resolution", () => {
|
||||
const provider = requireProviderContractProvider("zai");
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "zai",
|
||||
modelId: "glm-5",
|
||||
modelRegistry: {
|
||||
find: (_provider: string, id: string) =>
|
||||
id === "glm-4.7"
|
||||
? createModel({
|
||||
id,
|
||||
api: "openai-completions",
|
||||
provider: "zai",
|
||||
baseUrl: "https://api.z.ai/api/paas/v4",
|
||||
reasoning: false,
|
||||
contextWindow: 202_752,
|
||||
maxTokens: 16_384,
|
||||
})
|
||||
: null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "glm-5",
|
||||
provider: "zai",
|
||||
api: "openai-completions",
|
||||
reasoning: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("owns usage auth resolution", async () => {
|
||||
const provider = requireProviderContractProvider("zai");
|
||||
await expect(
|
||||
provider.resolveUsageAuth?.({
|
||||
config: {} as never,
|
||||
env: {
|
||||
ZAI_API_KEY: "env-zai-token",
|
||||
} as NodeJS.ProcessEnv,
|
||||
provider: "zai",
|
||||
resolveApiKeyFromConfigAndStore: () => "env-zai-token",
|
||||
resolveOAuthToken: async () => null,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
token: "env-zai-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to legacy pi auth tokens for usage auth", async () => {
|
||||
const provider = requireProviderContractProvider("zai");
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-zai-contract-"));
|
||||
await fs.mkdir(path.join(home, ".pi", "agent"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(home, ".pi", "agent", "auth.json"),
|
||||
`${JSON.stringify({ "z-ai": { access: "legacy-zai-token" } }, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
provider.resolveUsageAuth?.({
|
||||
config: {} as never,
|
||||
env: { HOME: home } as NodeJS.ProcessEnv,
|
||||
provider: "zai",
|
||||
resolveApiKeyFromConfigAndStore: () => undefined,
|
||||
resolveOAuthToken: async () => null,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
token: "legacy-zai-token",
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("owns usage snapshot fetching", async () => {
|
||||
const provider = requireProviderContractProvider("zai");
|
||||
const mockFetch = createProviderUsageFetch(async (url) => {
|
||||
if (url.includes("api.z.ai/api/monitor/usage/quota/limit")) {
|
||||
return makeResponse(200, {
|
||||
success: true,
|
||||
code: 200,
|
||||
data: {
|
||||
planName: "Pro",
|
||||
limits: [
|
||||
{
|
||||
type: "TOKENS_LIMIT",
|
||||
percentage: 25,
|
||||
unit: 3,
|
||||
number: 6,
|
||||
nextResetTime: "2026-01-07T06:00:00Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.fetchUsageSnapshot?.({
|
||||
config: {} as never,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
provider: "zai",
|
||||
token: "env-zai-token",
|
||||
timeoutMs: 5_000,
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
provider: "zai",
|
||||
displayName: "z.ai",
|
||||
windows: [{ label: "Tokens (6h)", usedPercent: 25, resetAt: 1_767_765_600_000 }],
|
||||
plan: "Pro",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildElevenLabsSpeechProvider } from "../../../extensions/elevenlabs/test-api.js";
|
||||
import { buildMicrosoftSpeechProvider } from "../../../extensions/microsoft/test-api.js";
|
||||
import { buildOpenAISpeechProvider } from "../../../extensions/openai/test-api.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import type { SpeechProviderPlugin } from "../../plugins/types.js";
|
||||
import { withEnv } from "../../test-utils/env.js";
|
||||
import * as tts from "../../tts/tts.js";
|
||||
|
||||
@@ -124,6 +122,185 @@ function createOpenAiTelephonyCfg(model: "tts-1" | "gpt-4o-mini-tts"): OpenClawC
|
||||
};
|
||||
}
|
||||
|
||||
function createAudioBuffer(length = 2): Buffer {
|
||||
return Buffer.from(new Uint8Array(length).fill(1));
|
||||
}
|
||||
|
||||
function resolveBaseUrl(rawValue: unknown, fallback: string): string {
|
||||
return typeof rawValue === "string" && rawValue.trim() ? rawValue.replace(/\/+$/u, "") : fallback;
|
||||
}
|
||||
|
||||
function buildTestOpenAISpeechProvider(): SpeechProviderPlugin {
|
||||
return {
|
||||
id: "openai",
|
||||
label: "OpenAI",
|
||||
autoSelectOrder: 10,
|
||||
resolveConfig: ({ rawConfig }) => {
|
||||
const config = (rawConfig.openai ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
...config,
|
||||
baseUrl: resolveBaseUrl(
|
||||
config.baseUrl ?? process.env.OPENAI_TTS_BASE_URL,
|
||||
"https://api.openai.com/v1",
|
||||
),
|
||||
};
|
||||
},
|
||||
parseDirectiveToken: ({ key, value, providerConfig }) => {
|
||||
if (key === "voice") {
|
||||
const baseUrl = resolveBaseUrl(
|
||||
(providerConfig as Record<string, unknown> | undefined)?.baseUrl,
|
||||
"https://api.openai.com/v1",
|
||||
);
|
||||
const isDefaultEndpoint = baseUrl === "https://api.openai.com/v1";
|
||||
const allowedVoices = new Set([
|
||||
"alloy",
|
||||
"ash",
|
||||
"ballad",
|
||||
"coral",
|
||||
"echo",
|
||||
"sage",
|
||||
"shimmer",
|
||||
"verse",
|
||||
]);
|
||||
if (isDefaultEndpoint && !allowedVoices.has(value)) {
|
||||
return { handled: true, warnings: [`invalid OpenAI voice "${value}"`] };
|
||||
}
|
||||
return { handled: true, overrides: { voice: value } };
|
||||
}
|
||||
if (key === "model") {
|
||||
const baseUrl = resolveBaseUrl(
|
||||
(providerConfig as Record<string, unknown> | undefined)?.baseUrl,
|
||||
"https://api.openai.com/v1",
|
||||
);
|
||||
const isDefaultEndpoint = baseUrl === "https://api.openai.com/v1";
|
||||
const allowedModels = new Set(["tts-1", "tts-1-hd", "gpt-4o-mini-tts"]);
|
||||
if (isDefaultEndpoint && !allowedModels.has(value)) {
|
||||
return { handled: true, warnings: [`invalid OpenAI model "${value}"`] };
|
||||
}
|
||||
return { handled: true, overrides: { model: value } };
|
||||
}
|
||||
return { handled: false };
|
||||
},
|
||||
isConfigured: ({ providerConfig }) =>
|
||||
typeof (providerConfig as Record<string, unknown> | undefined)?.apiKey === "string" ||
|
||||
typeof process.env.OPENAI_API_KEY === "string",
|
||||
synthesize: async ({ text, providerConfig, providerOverrides }) => {
|
||||
const config = providerConfig as Record<string, unknown> | undefined;
|
||||
await fetch(`${resolveBaseUrl(config?.baseUrl, "https://api.openai.com/v1")}/audio/speech`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
input: text,
|
||||
model: providerOverrides?.model ?? config?.model ?? "gpt-4o-mini-tts",
|
||||
voice: providerOverrides?.voice ?? config?.voice ?? "alloy",
|
||||
}),
|
||||
});
|
||||
return {
|
||||
audioBuffer: createAudioBuffer(1),
|
||||
outputFormat: "mp3",
|
||||
fileExtension: ".mp3",
|
||||
voiceCompatible: true,
|
||||
};
|
||||
},
|
||||
synthesizeTelephony: async ({ text, providerConfig }) => {
|
||||
const config = providerConfig as Record<string, unknown> | undefined;
|
||||
const configuredModel = typeof config?.model === "string" ? config.model : undefined;
|
||||
const model = configuredModel ?? "tts-1";
|
||||
const configuredInstructions =
|
||||
typeof config?.instructions === "string" ? config.instructions : undefined;
|
||||
const instructions =
|
||||
model === "gpt-4o-mini-tts" ? configuredInstructions || undefined : undefined;
|
||||
await fetch(`${resolveBaseUrl(config?.baseUrl, "https://api.openai.com/v1")}/audio/speech`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
input: text,
|
||||
model,
|
||||
voice: config?.voice ?? "alloy",
|
||||
instructions,
|
||||
}),
|
||||
});
|
||||
return {
|
||||
audioBuffer: createAudioBuffer(2),
|
||||
outputFormat: "mp3",
|
||||
sampleRate: 24000,
|
||||
};
|
||||
},
|
||||
listVoices: async () => [{ id: "alloy", label: "Alloy" }],
|
||||
};
|
||||
}
|
||||
|
||||
function buildTestMicrosoftSpeechProvider(): SpeechProviderPlugin {
|
||||
return {
|
||||
id: "microsoft",
|
||||
label: "Microsoft",
|
||||
aliases: ["edge"],
|
||||
autoSelectOrder: 30,
|
||||
resolveConfig: ({ rawConfig }) => {
|
||||
const edgeConfig = (rawConfig.edge ?? rawConfig.microsoft ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
...edgeConfig,
|
||||
outputFormat: edgeConfig.outputFormat ?? "audio-24khz-48kbitrate-mono-mp3",
|
||||
};
|
||||
},
|
||||
isConfigured: () => true,
|
||||
synthesize: async () => ({
|
||||
audioBuffer: createAudioBuffer(),
|
||||
outputFormat: "mp3",
|
||||
fileExtension: ".mp3",
|
||||
voiceCompatible: true,
|
||||
}),
|
||||
listVoices: async () => [{ id: "edge", label: "Edge" }],
|
||||
};
|
||||
}
|
||||
|
||||
function buildTestElevenLabsSpeechProvider(): SpeechProviderPlugin {
|
||||
return {
|
||||
id: "elevenlabs",
|
||||
label: "ElevenLabs",
|
||||
autoSelectOrder: 20,
|
||||
parseDirectiveToken: ({ key, value, currentOverrides }) => {
|
||||
if (key === "voiceid") {
|
||||
return { handled: true, overrides: { voiceId: value } };
|
||||
}
|
||||
if (key === "stability") {
|
||||
return {
|
||||
handled: true,
|
||||
overrides: {
|
||||
voiceSettings: {
|
||||
...(currentOverrides as { voiceSettings?: Record<string, unknown> } | undefined)
|
||||
?.voiceSettings,
|
||||
stability: Number(value),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (key === "speed") {
|
||||
return {
|
||||
handled: true,
|
||||
overrides: {
|
||||
voiceSettings: {
|
||||
...(currentOverrides as { voiceSettings?: Record<string, unknown> } | undefined)
|
||||
?.voiceSettings,
|
||||
speed: Number(value),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return { handled: false };
|
||||
},
|
||||
isConfigured: ({ providerConfig }) =>
|
||||
typeof (providerConfig as Record<string, unknown> | undefined)?.apiKey === "string" ||
|
||||
typeof process.env.ELEVENLABS_API_KEY === "string" ||
|
||||
typeof process.env.XI_API_KEY === "string",
|
||||
synthesize: async () => ({
|
||||
audioBuffer: createAudioBuffer(),
|
||||
outputFormat: "mp3",
|
||||
fileExtension: ".mp3",
|
||||
voiceCompatible: true,
|
||||
}),
|
||||
listVoices: async () => [{ id: "eleven", label: "Eleven" }],
|
||||
};
|
||||
}
|
||||
|
||||
describe("tts", () => {
|
||||
beforeEach(async () => {
|
||||
({ completeSimple } = await import("@mariozechner/pi-ai"));
|
||||
@@ -136,9 +313,9 @@ describe("tts", () => {
|
||||
prepareModelForSimpleCompletionMock = vi.fn(({ model }) => model);
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.speechProviders = [
|
||||
{ pluginId: "openai", provider: buildOpenAISpeechProvider(), source: "test" },
|
||||
{ pluginId: "microsoft", provider: buildMicrosoftSpeechProvider(), source: "test" },
|
||||
{ pluginId: "elevenlabs", provider: buildElevenLabsSpeechProvider(), source: "test" },
|
||||
{ pluginId: "openai", provider: buildTestOpenAISpeechProvider(), source: "test" },
|
||||
{ pluginId: "microsoft", provider: buildTestMicrosoftSpeechProvider(), source: "test" },
|
||||
{ pluginId: "elevenlabs", provider: buildTestElevenLabsSpeechProvider(), source: "test" },
|
||||
];
|
||||
setActivePluginRegistry(registry, "tts-test");
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { webSearchProviderContractRegistry } from "./registry.js";
|
||||
import { installWebSearchProviderContractSuite } from "./suites.js";
|
||||
|
||||
describe("web search provider contract registry load", () => {
|
||||
it("loads bundled web search providers", () => {
|
||||
expect(webSearchProviderContractRegistry.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
for (const entry of webSearchProviderContractRegistry) {
|
||||
describe(`${entry.pluginId}:${entry.provider.id} web search contract`, () => {
|
||||
installWebSearchProviderContractSuite({
|
||||
provider: entry.provider,
|
||||
credentialValue: entry.credentialValue,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isAtLeast, parseSemver } from "../infra/runtime-guard.js";
|
||||
import { parseMinHostVersionRequirement } from "./min-host-version.js";
|
||||
|
||||
const MIN_HOST_VERSION_BASELINE = "2026.3.22";
|
||||
const PLUGIN_MANIFEST_PATHS_REQUIRING_MIN_HOST_VERSION = [
|
||||
"extensions/bluebubbles/package.json",
|
||||
"extensions/discord/package.json",
|
||||
"extensions/feishu/package.json",
|
||||
"extensions/googlechat/package.json",
|
||||
"extensions/irc/package.json",
|
||||
"extensions/line/package.json",
|
||||
"extensions/matrix/package.json",
|
||||
"extensions/mattermost/package.json",
|
||||
"extensions/memory-lancedb/package.json",
|
||||
"extensions/msteams/package.json",
|
||||
"extensions/nextcloud-talk/package.json",
|
||||
"extensions/nostr/package.json",
|
||||
"extensions/synology-chat/package.json",
|
||||
"extensions/tlon/package.json",
|
||||
"extensions/twitch/package.json",
|
||||
"extensions/voice-call/package.json",
|
||||
"extensions/whatsapp/package.json",
|
||||
"extensions/zalo/package.json",
|
||||
"extensions/zalouser/package.json",
|
||||
] as const;
|
||||
|
||||
type PackageJsonLike = {
|
||||
openclaw?: {
|
||||
install?: {
|
||||
minHostVersion?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
describe("install minHostVersion guardrails", () => {
|
||||
it("requires published plugins that depend on new sdk subpaths to declare a host floor", () => {
|
||||
const baseline = parseSemver(MIN_HOST_VERSION_BASELINE);
|
||||
expect(baseline).not.toBeNull();
|
||||
if (!baseline) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const relativePath of PLUGIN_MANIFEST_PATHS_REQUIRING_MIN_HOST_VERSION) {
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.resolve(relativePath), "utf-8"),
|
||||
) as PackageJsonLike;
|
||||
const requirement = parseMinHostVersionRequirement(
|
||||
manifest.openclaw?.install?.minHostVersion,
|
||||
);
|
||||
|
||||
expect(
|
||||
requirement,
|
||||
`${relativePath} should declare openclaw.install.minHostVersion`,
|
||||
).not.toBeNull();
|
||||
if (!requirement) {
|
||||
continue;
|
||||
}
|
||||
const minimum = parseSemver(requirement.minimumLabel);
|
||||
expect(minimum, `${relativePath} should use a parseable semver floor`).not.toBeNull();
|
||||
if (!minimum) {
|
||||
continue;
|
||||
}
|
||||
expect(
|
||||
isAtLeast(minimum, baseline),
|
||||
`${relativePath} should require at least OpenClaw ${MIN_HOST_VERSION_BASELINE}`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,8 @@ import { createHookRunner } from "./hooks.js";
|
||||
import { createMockPluginRegistry } from "./hooks.test-helpers.js";
|
||||
|
||||
describe("message_sending hook runner", () => {
|
||||
const demoChannelCtx = { channelId: "demo-channel" };
|
||||
|
||||
it("runMessageSending invokes registered hooks and returns modified content", async () => {
|
||||
const handler = vi.fn().mockReturnValue({ content: "modified content" });
|
||||
const registry = createMockPluginRegistry([{ hookName: "message_sending", handler }]);
|
||||
@@ -15,12 +17,12 @@ describe("message_sending hook runner", () => {
|
||||
|
||||
const result = await runner.runMessageSending(
|
||||
{ to: "user-123", content: "original content" },
|
||||
{ channelId: "telegram" },
|
||||
demoChannelCtx,
|
||||
);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
{ to: "user-123", content: "original content" },
|
||||
{ channelId: "telegram" },
|
||||
demoChannelCtx,
|
||||
);
|
||||
expect(result?.content).toBe("modified content");
|
||||
});
|
||||
@@ -32,7 +34,7 @@ describe("message_sending hook runner", () => {
|
||||
|
||||
const result = await runner.runMessageSending(
|
||||
{ to: "user-123", content: "blocked" },
|
||||
{ channelId: "telegram" },
|
||||
demoChannelCtx,
|
||||
);
|
||||
|
||||
expect(result?.cancel).toBe(true);
|
||||
@@ -40,6 +42,8 @@ describe("message_sending hook runner", () => {
|
||||
});
|
||||
|
||||
describe("message_sent hook runner", () => {
|
||||
const demoChannelCtx = { channelId: "demo-channel" };
|
||||
|
||||
it("runMessageSent invokes registered hooks with success=true", async () => {
|
||||
const handler = vi.fn();
|
||||
const registry = createMockPluginRegistry([{ hookName: "message_sent", handler }]);
|
||||
@@ -47,12 +51,12 @@ describe("message_sent hook runner", () => {
|
||||
|
||||
await runner.runMessageSent(
|
||||
{ to: "user-123", content: "hello", success: true },
|
||||
{ channelId: "telegram" },
|
||||
demoChannelCtx,
|
||||
);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
{ to: "user-123", content: "hello", success: true },
|
||||
{ channelId: "telegram" },
|
||||
demoChannelCtx,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -63,12 +67,12 @@ describe("message_sent hook runner", () => {
|
||||
|
||||
await runner.runMessageSent(
|
||||
{ to: "user-123", content: "hello", success: false, error: "timeout" },
|
||||
{ channelId: "telegram" },
|
||||
demoChannelCtx,
|
||||
);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
{ to: "user-123", content: "hello", success: false, error: "timeout" },
|
||||
{ channelId: "telegram" },
|
||||
demoChannelCtx,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user