mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 18:50:42 +00:00
refactor: promote plugin test helpers to sdk
This commit is contained in:
@@ -1,109 +0,0 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
createPluginRecord,
|
||||
createPluginRegistry,
|
||||
registerProviderPlugins as registerProviders,
|
||||
requireRegisteredProvider as requireProvider,
|
||||
type OpenClawConfig,
|
||||
type PluginRecord,
|
||||
type PluginRuntime,
|
||||
} from "openclaw/plugin-sdk/testing";
|
||||
|
||||
export { registerProviders, requireProvider };
|
||||
|
||||
export function uniqueSortedStrings(values: readonly string[]) {
|
||||
return [...new Set(values)].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function formatImportSideEffectCall(args: readonly unknown[]): string {
|
||||
if (args.length === 0) {
|
||||
return "(no args)";
|
||||
}
|
||||
return args
|
||||
.map((arg) => {
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
export function assertNoImportTimeSideEffects(params: {
|
||||
moduleId: string;
|
||||
forbiddenSeam: string;
|
||||
calls: readonly (readonly unknown[])[];
|
||||
why: string;
|
||||
fixHint: string;
|
||||
}) {
|
||||
if (params.calls.length === 0) {
|
||||
return;
|
||||
}
|
||||
const observedCalls = params.calls
|
||||
.slice(0, 3)
|
||||
.map((call, index) => ` ${index + 1}. ${formatImportSideEffectCall(call)}`)
|
||||
.join("\n");
|
||||
throw new Error(
|
||||
[
|
||||
`[runtime contract] ${params.moduleId} touched ${params.forbiddenSeam} during module import.`,
|
||||
`why this is banned: ${params.why}`,
|
||||
`expected fix: ${params.fixHint}`,
|
||||
`observed calls (${params.calls.length}):`,
|
||||
observedCalls,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createPluginRegistryFixture(config = {} as OpenClawConfig) {
|
||||
return {
|
||||
config,
|
||||
registry: createPluginRegistry({
|
||||
logger: {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
},
|
||||
runtime: {} as PluginRuntime,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerTestPlugin(params: {
|
||||
registry: ReturnType<typeof createPluginRegistry>;
|
||||
config: OpenClawConfig;
|
||||
record: PluginRecord;
|
||||
register(api: OpenClawPluginApi): void;
|
||||
}) {
|
||||
params.registry.registry.plugins.push(params.record);
|
||||
params.register(
|
||||
params.registry.createApi(params.record, {
|
||||
config: params.config,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function registerVirtualTestPlugin(params: {
|
||||
registry: ReturnType<typeof createPluginRegistry>;
|
||||
config: OpenClawConfig;
|
||||
id: string;
|
||||
name: string;
|
||||
source?: string;
|
||||
kind?: PluginRecord["kind"];
|
||||
contracts?: PluginRecord["contracts"];
|
||||
register(this: void, api: OpenClawPluginApi): void;
|
||||
}) {
|
||||
registerTestPlugin({
|
||||
registry: params.registry,
|
||||
config: params.config,
|
||||
record: createPluginRecord({
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
source: params.source ?? `/virtual/${params.id}/index.ts`,
|
||||
...(params.kind ? { kind: params.kind } : {}),
|
||||
...(params.contracts ? { contracts: params.contracts } : {}),
|
||||
}),
|
||||
register: params.register,
|
||||
});
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
|
||||
const SHARED_IMPORT_ENV = {
|
||||
HOME: process.env.HOME,
|
||||
NODE_OPTIONS: process.env.NODE_OPTIONS,
|
||||
NODE_PATH: process.env.NODE_PATH,
|
||||
PATH: process.env.PATH,
|
||||
TERM: process.env.TERM,
|
||||
} satisfies NodeJS.ProcessEnv;
|
||||
|
||||
export async function runDirectImportSmoke(code: string): Promise<string> {
|
||||
const { stdout } = await execFileAsync(process.execPath, ["--import", "tsx", "-e", code], {
|
||||
cwd: repoRoot,
|
||||
env: SHARED_IMPORT_ENV,
|
||||
timeout: 40_000,
|
||||
});
|
||||
|
||||
return stdout;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { ChannelDirectoryAdapter } from "openclaw/plugin-sdk/channel-contract";
|
||||
|
||||
type DirectorySurface = {
|
||||
listPeers: NonNullable<ChannelDirectoryAdapter["listPeers"]>;
|
||||
listGroups: NonNullable<ChannelDirectoryAdapter["listGroups"]>;
|
||||
};
|
||||
|
||||
export function createDirectoryTestRuntime() {
|
||||
return {
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function expectDirectorySurface(directory: unknown): DirectorySurface {
|
||||
if (!directory || typeof directory !== "object") {
|
||||
throw new Error("expected directory");
|
||||
}
|
||||
const { listPeers, listGroups } = directory as ChannelDirectoryAdapter;
|
||||
if (!listPeers) {
|
||||
throw new Error("expected listPeers");
|
||||
}
|
||||
if (!listGroups) {
|
||||
throw new Error("expected listGroups");
|
||||
}
|
||||
return {
|
||||
listPeers,
|
||||
listGroups,
|
||||
};
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import ts from "typescript";
|
||||
|
||||
const JITI_EXTENSIONS = [
|
||||
".ts",
|
||||
".tsx",
|
||||
".mts",
|
||||
".cts",
|
||||
".mtsx",
|
||||
".ctsx",
|
||||
".js",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".json",
|
||||
] as const;
|
||||
|
||||
const PLUGIN_SDK_SPECIFIER_PREFIX = "openclaw/plugin-sdk/";
|
||||
const SOURCE_MODULE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"] as const;
|
||||
|
||||
type SourceModuleRef = {
|
||||
specifier: string;
|
||||
typeOnly: boolean;
|
||||
};
|
||||
|
||||
function listPluginSdkExportedSubpaths(root: string): string[] {
|
||||
const packageJsonPath = path.join(root, "package.json");
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
||||
exports?: Record<string, unknown>;
|
||||
};
|
||||
return Object.keys(packageJson.exports ?? {})
|
||||
.filter((key) => key.startsWith("./plugin-sdk/"))
|
||||
.map((key) => key.slice("./plugin-sdk/".length));
|
||||
}
|
||||
|
||||
function resolvePluginSdkAliasTarget(root: string, subpath: string): string | null {
|
||||
const distCandidate = path.join(root, "dist", "plugin-sdk", `${subpath}.js`);
|
||||
if (existsSync(distCandidate)) {
|
||||
return distCandidate;
|
||||
}
|
||||
|
||||
for (const ext of SOURCE_MODULE_EXTENSIONS) {
|
||||
const srcCandidate = path.join(root, "src", "plugin-sdk", `${subpath}${ext}`);
|
||||
if (existsSync(srcCandidate)) {
|
||||
return srcCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveLocalModulePath(filePath: string, specifier: string): string | null {
|
||||
const basePath = path.resolve(path.dirname(filePath), specifier);
|
||||
const candidates = new Set<string>([basePath]);
|
||||
|
||||
for (const ext of SOURCE_MODULE_EXTENSIONS) {
|
||||
candidates.add(`${basePath}${ext}`);
|
||||
}
|
||||
|
||||
if (/\.[cm]?[jt]sx?$/u.test(basePath)) {
|
||||
const withoutExt = basePath.replace(/\.[cm]?[jt]sx?$/u, "");
|
||||
for (const ext of SOURCE_MODULE_EXTENSIONS) {
|
||||
candidates.add(`${withoutExt}${ext}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const ext of SOURCE_MODULE_EXTENSIONS) {
|
||||
candidates.add(path.join(basePath, `index${ext}`));
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectSourceModuleRefs(filePath: string): SourceModuleRef[] {
|
||||
const sourceText = readFileSync(filePath, "utf8");
|
||||
const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true);
|
||||
const refs: SourceModuleRef[] = [];
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (ts.isImportDeclaration(statement)) {
|
||||
const specifier =
|
||||
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
|
||||
? statement.moduleSpecifier.text
|
||||
: undefined;
|
||||
if (specifier) {
|
||||
refs.push({
|
||||
specifier,
|
||||
typeOnly: Boolean(statement.importClause?.isTypeOnly),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ts.isExportDeclaration(statement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const specifier =
|
||||
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
|
||||
? statement.moduleSpecifier.text
|
||||
: undefined;
|
||||
if (!specifier) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const typeOnly = Boolean(
|
||||
statement.isTypeOnly ||
|
||||
(statement.exportClause &&
|
||||
ts.isNamedExports(statement.exportClause) &&
|
||||
statement.exportClause.elements.length > 0 &&
|
||||
statement.exportClause.elements.every((element) => element.isTypeOnly)),
|
||||
);
|
||||
|
||||
refs.push({ specifier, typeOnly });
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
function collectPluginSdkAliases(params: {
|
||||
modulePath: string;
|
||||
root: string;
|
||||
realPluginSdkSpecifiers?: readonly string[];
|
||||
}): Record<string, string> {
|
||||
const realSpecifiers = new Set<string>();
|
||||
const stubSpecifiers = new Set<string>();
|
||||
const visitedFiles = new Set<string>();
|
||||
const stubPath = path.join(params.root, "test", "helpers", "plugins", "plugin-sdk-stub.cjs");
|
||||
const explicitRealSpecifiers = new Set(params.realPluginSdkSpecifiers ?? []);
|
||||
|
||||
function visitModule(filePath: string, rootModule: boolean): void {
|
||||
if (visitedFiles.has(filePath)) {
|
||||
return;
|
||||
}
|
||||
visitedFiles.add(filePath);
|
||||
|
||||
for (const ref of collectSourceModuleRefs(filePath)) {
|
||||
if (ref.specifier.startsWith(PLUGIN_SDK_SPECIFIER_PREFIX)) {
|
||||
const shouldKeepReal =
|
||||
rootModule &&
|
||||
!ref.typeOnly &&
|
||||
(explicitRealSpecifiers.size === 0 || explicitRealSpecifiers.has(ref.specifier));
|
||||
if (shouldKeepReal) {
|
||||
realSpecifiers.add(ref.specifier);
|
||||
const subpath = ref.specifier.slice(PLUGIN_SDK_SPECIFIER_PREFIX.length);
|
||||
const target = resolvePluginSdkAliasTarget(params.root, subpath);
|
||||
if (target?.endsWith(".ts")) {
|
||||
visitModule(target, false);
|
||||
}
|
||||
} else {
|
||||
stubSpecifiers.add(ref.specifier);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ref.specifier.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = resolveLocalModulePath(filePath, ref.specifier);
|
||||
if (resolved) {
|
||||
visitModule(resolved, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visitModule(params.modulePath, true);
|
||||
|
||||
const aliasEntries = new Map<string, string>();
|
||||
for (const specifier of listPluginSdkExportedSubpaths(params.root).map(
|
||||
(subpath) => `${PLUGIN_SDK_SPECIFIER_PREFIX}${subpath}`,
|
||||
)) {
|
||||
if (realSpecifiers.has(specifier)) {
|
||||
const subpath = specifier.slice(PLUGIN_SDK_SPECIFIER_PREFIX.length);
|
||||
aliasEntries.set(specifier, resolvePluginSdkAliasTarget(params.root, subpath) ?? stubPath);
|
||||
continue;
|
||||
}
|
||||
if (stubSpecifiers.has(specifier)) {
|
||||
aliasEntries.set(specifier, stubPath);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.fromEntries(aliasEntries);
|
||||
}
|
||||
|
||||
export function loadRuntimeApiExportTypesViaJiti(params: {
|
||||
modulePath: string;
|
||||
exportNames: readonly string[];
|
||||
additionalAliases?: Record<string, string>;
|
||||
realPluginSdkSpecifiers?: readonly string[];
|
||||
}): Record<string, string> {
|
||||
const root = process.cwd();
|
||||
const alias = {
|
||||
...collectPluginSdkAliases({
|
||||
modulePath: params.modulePath,
|
||||
root,
|
||||
realPluginSdkSpecifiers: params.realPluginSdkSpecifiers,
|
||||
}),
|
||||
...params.additionalAliases,
|
||||
};
|
||||
|
||||
const script = `
|
||||
import path from "node:path";
|
||||
import { createJiti } from "jiti";
|
||||
|
||||
const modulePath = ${JSON.stringify(params.modulePath)};
|
||||
const exportNames = ${JSON.stringify(params.exportNames)};
|
||||
const alias = ${JSON.stringify(alias)};
|
||||
const jiti = createJiti(path.join(${JSON.stringify(root)}, "openclaw.mjs"), {
|
||||
interopDefault: true,
|
||||
tryNative: false,
|
||||
fsCache: false,
|
||||
moduleCache: false,
|
||||
extensions: ${JSON.stringify(JITI_EXTENSIONS)},
|
||||
alias,
|
||||
});
|
||||
const mod = jiti(modulePath);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
Object.fromEntries(exportNames.map((name) => [name, typeof mod[name]])),
|
||||
),
|
||||
);
|
||||
`;
|
||||
|
||||
const raw = execFileSync(process.execPath, ["--input-type=module", "--eval", script], {
|
||||
cwd: root,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
|
||||
return JSON.parse(raw) as Record<string, string>;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { ModelApi } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/testing";
|
||||
|
||||
export const EXPECTED_FALLBACKS = ["anthropic/claude-opus-4-5"] as const;
|
||||
|
||||
export function createLegacyProviderConfig(params: {
|
||||
providerId: string;
|
||||
api: ModelApi;
|
||||
modelId?: string;
|
||||
modelName?: string;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
}): OpenClawConfig {
|
||||
return {
|
||||
models: {
|
||||
providers: {
|
||||
[params.providerId]: {
|
||||
baseUrl: params.baseUrl ?? "https://old.example.com",
|
||||
apiKey: params.apiKey ?? "old-key",
|
||||
api: params.api,
|
||||
models: [
|
||||
{
|
||||
id: params.modelId ?? "old-model",
|
||||
name: params.modelName ?? "Old",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1000,
|
||||
maxTokens: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
export function createConfigWithFallbacks(): OpenClawConfig {
|
||||
return {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { fallbacks: [...EXPECTED_FALLBACKS] },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export {
|
||||
addTestHook,
|
||||
createEmptyPluginRegistry,
|
||||
createOutboundTestPlugin,
|
||||
createTestRegistry,
|
||||
deliverOutboundPayloads,
|
||||
initializeGlobalHookRunner,
|
||||
releasePinnedPluginChannelRegistry,
|
||||
resetGlobalHookRunner,
|
||||
setActivePluginRegistry,
|
||||
type PluginHookRegistration,
|
||||
} from "openclaw/plugin-sdk/testing";
|
||||
@@ -1,109 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
isAtLeast,
|
||||
parseMinHostVersionRequirement,
|
||||
parseSemver,
|
||||
} from "openclaw/plugin-sdk/testing";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { bundledPluginFile } from "../bundled-plugin-paths.js";
|
||||
|
||||
type PackageManifest = {
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
openclaw?: {
|
||||
install?: {
|
||||
minHostVersion?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type PackageManifestContractParams = {
|
||||
pluginId: string;
|
||||
pluginLocalRuntimeDeps?: string[];
|
||||
mirroredRootRuntimeDeps?: string[];
|
||||
minHostVersionBaseline?: string;
|
||||
};
|
||||
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Test helper lets assertions ascribe package manifest shape.
|
||||
function readJson<T>(relativePath: string): T {
|
||||
const absolutePath = path.resolve(process.cwd(), relativePath);
|
||||
return JSON.parse(fs.readFileSync(absolutePath, "utf8")) as T;
|
||||
}
|
||||
|
||||
export function describePackageManifestContract(params: PackageManifestContractParams) {
|
||||
const packagePath = bundledPluginFile(params.pluginId, "package.json");
|
||||
|
||||
describe(`${params.pluginId} package manifest contract`, () => {
|
||||
if (params.pluginLocalRuntimeDeps?.length) {
|
||||
for (const dependencyName of params.pluginLocalRuntimeDeps) {
|
||||
it(`keeps ${dependencyName} plugin-local`, () => {
|
||||
const rootManifest = readJson("package.json") as PackageManifest;
|
||||
const pluginManifest = readJson(packagePath) as PackageManifest;
|
||||
const pluginSpec =
|
||||
pluginManifest.dependencies?.[dependencyName] ??
|
||||
pluginManifest.optionalDependencies?.[dependencyName];
|
||||
const rootSpec =
|
||||
rootManifest.dependencies?.[dependencyName] ??
|
||||
rootManifest.optionalDependencies?.[dependencyName];
|
||||
|
||||
expect(pluginSpec).toBeTruthy();
|
||||
expect(rootSpec).toBeUndefined();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (params.mirroredRootRuntimeDeps?.length) {
|
||||
for (const dependencyName of params.mirroredRootRuntimeDeps) {
|
||||
it(`mirrors ${dependencyName} at the root package`, () => {
|
||||
const rootManifest = readJson<PackageManifest>("package.json");
|
||||
const pluginManifest = readJson<PackageManifest>(packagePath);
|
||||
const pluginSpec =
|
||||
pluginManifest.dependencies?.[dependencyName] ??
|
||||
pluginManifest.optionalDependencies?.[dependencyName];
|
||||
const rootSpec =
|
||||
rootManifest.dependencies?.[dependencyName] ??
|
||||
rootManifest.optionalDependencies?.[dependencyName];
|
||||
|
||||
expect(pluginSpec).toBeTruthy();
|
||||
expect(rootSpec).toBe(pluginSpec);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const minHostVersionBaseline = params.minHostVersionBaseline;
|
||||
if (minHostVersionBaseline) {
|
||||
it("declares a parseable minHostVersion floor at or above the baseline", () => {
|
||||
const baseline = parseSemver(minHostVersionBaseline);
|
||||
expect(baseline).not.toBeNull();
|
||||
if (!baseline) {
|
||||
return;
|
||||
}
|
||||
|
||||
const manifest = readJson<PackageManifest>(packagePath);
|
||||
const requirement = parseMinHostVersionRequirement(
|
||||
manifest.openclaw?.install?.minHostVersion ?? null,
|
||||
);
|
||||
|
||||
expect(
|
||||
requirement,
|
||||
`${packagePath} should declare openclaw.install.minHostVersion`,
|
||||
).not.toBeNull();
|
||||
if (!requirement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const minimum = parseSemver(requirement.minimumLabel);
|
||||
expect(minimum, `${packagePath} should use a parseable semver floor`).not.toBeNull();
|
||||
if (!minimum) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(
|
||||
isAtLeast(minimum, baseline),
|
||||
`${packagePath} should require at least OpenClaw ${minHostVersionBaseline}`,
|
||||
).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { describePluginRegistrationContract } from "./plugin-registration-contract.js";
|
||||
|
||||
type PluginRegistrationContractParams = Parameters<typeof describePluginRegistrationContract>[0];
|
||||
|
||||
export const pluginRegistrationContractCases = {
|
||||
anthropic: {
|
||||
pluginId: "anthropic",
|
||||
providerIds: ["anthropic"],
|
||||
mediaUnderstandingProviderIds: ["anthropic"],
|
||||
cliBackendIds: ["claude-cli"],
|
||||
requireDescribeImages: true,
|
||||
},
|
||||
brave: {
|
||||
pluginId: "brave",
|
||||
webSearchProviderIds: ["brave"],
|
||||
},
|
||||
comfy: {
|
||||
pluginId: "comfy",
|
||||
providerIds: ["comfy"],
|
||||
imageGenerationProviderIds: ["comfy"],
|
||||
musicGenerationProviderIds: ["comfy"],
|
||||
videoGenerationProviderIds: ["comfy"],
|
||||
requireGenerateImage: true,
|
||||
requireGenerateVideo: true,
|
||||
},
|
||||
deepgram: {
|
||||
pluginId: "deepgram",
|
||||
mediaUnderstandingProviderIds: ["deepgram"],
|
||||
},
|
||||
duckduckgo: {
|
||||
pluginId: "duckduckgo",
|
||||
webSearchProviderIds: ["duckduckgo"],
|
||||
},
|
||||
elevenlabs: {
|
||||
pluginId: "elevenlabs",
|
||||
speechProviderIds: ["elevenlabs"],
|
||||
requireSpeechVoices: true,
|
||||
},
|
||||
exa: {
|
||||
pluginId: "exa",
|
||||
webSearchProviderIds: ["exa"],
|
||||
},
|
||||
fal: {
|
||||
pluginId: "fal",
|
||||
providerIds: ["fal"],
|
||||
imageGenerationProviderIds: ["fal"],
|
||||
},
|
||||
firecrawl: {
|
||||
pluginId: "firecrawl",
|
||||
webFetchProviderIds: ["firecrawl"],
|
||||
webSearchProviderIds: ["firecrawl"],
|
||||
toolNames: ["firecrawl_search", "firecrawl_scrape"],
|
||||
},
|
||||
google: {
|
||||
pluginId: "google",
|
||||
providerIds: ["google", "google-gemini-cli", "google-vertex"],
|
||||
webSearchProviderIds: ["gemini"],
|
||||
realtimeVoiceProviderIds: ["google"],
|
||||
speechProviderIds: ["google"],
|
||||
mediaUnderstandingProviderIds: ["google"],
|
||||
imageGenerationProviderIds: ["google"],
|
||||
requireDescribeImages: true,
|
||||
requireGenerateImage: true,
|
||||
},
|
||||
groq: {
|
||||
pluginId: "groq",
|
||||
mediaUnderstandingProviderIds: ["groq"],
|
||||
},
|
||||
microsoft: {
|
||||
pluginId: "microsoft",
|
||||
speechProviderIds: ["microsoft"],
|
||||
requireSpeechVoices: true,
|
||||
},
|
||||
minimax: {
|
||||
pluginId: "minimax",
|
||||
providerIds: ["minimax", "minimax-portal"],
|
||||
mediaUnderstandingProviderIds: ["minimax", "minimax-portal"],
|
||||
imageGenerationProviderIds: ["minimax", "minimax-portal"],
|
||||
requireDescribeImages: true,
|
||||
requireGenerateImage: true,
|
||||
},
|
||||
mistral: {
|
||||
pluginId: "mistral",
|
||||
mediaUnderstandingProviderIds: ["mistral"],
|
||||
},
|
||||
moonshot: {
|
||||
pluginId: "moonshot",
|
||||
providerIds: ["moonshot"],
|
||||
webSearchProviderIds: ["kimi"],
|
||||
mediaUnderstandingProviderIds: ["moonshot"],
|
||||
requireDescribeImages: true,
|
||||
manifestAuthChoice: {
|
||||
pluginId: "kimi",
|
||||
choiceId: "kimi-code-api-key",
|
||||
choiceLabel: "Kimi Code API key (subscription)",
|
||||
groupId: "moonshot",
|
||||
groupLabel: "Moonshot AI (Kimi K2.6)",
|
||||
groupHint: "Kimi K2.6",
|
||||
},
|
||||
},
|
||||
openai: {
|
||||
pluginId: "openai",
|
||||
providerIds: ["openai", "openai-codex"],
|
||||
speechProviderIds: ["openai"],
|
||||
realtimeTranscriptionProviderIds: ["openai"],
|
||||
realtimeVoiceProviderIds: ["openai"],
|
||||
mediaUnderstandingProviderIds: ["openai", "openai-codex"],
|
||||
imageGenerationProviderIds: ["openai"],
|
||||
requireSpeechVoices: true,
|
||||
requireDescribeImages: true,
|
||||
requireGenerateImage: true,
|
||||
},
|
||||
openrouter: {
|
||||
pluginId: "openrouter",
|
||||
providerIds: ["openrouter"],
|
||||
mediaUnderstandingProviderIds: ["openrouter"],
|
||||
imageGenerationProviderIds: ["openrouter"],
|
||||
requireDescribeImages: true,
|
||||
requireGenerateImage: true,
|
||||
},
|
||||
perplexity: {
|
||||
pluginId: "perplexity",
|
||||
webSearchProviderIds: ["perplexity"],
|
||||
},
|
||||
senseaudio: {
|
||||
pluginId: "senseaudio",
|
||||
mediaUnderstandingProviderIds: ["senseaudio"],
|
||||
},
|
||||
tavily: {
|
||||
pluginId: "tavily",
|
||||
webSearchProviderIds: ["tavily"],
|
||||
toolNames: ["tavily_search", "tavily_extract"],
|
||||
},
|
||||
"tts-local-cli": {
|
||||
pluginId: "tts-local-cli",
|
||||
speechProviderIds: ["tts-local-cli", "cli"],
|
||||
},
|
||||
xai: {
|
||||
pluginId: "xai",
|
||||
providerIds: ["xai"],
|
||||
webSearchProviderIds: ["grok"],
|
||||
realtimeTranscriptionProviderIds: ["xai"],
|
||||
mediaUnderstandingProviderIds: ["xai"],
|
||||
},
|
||||
zai: {
|
||||
pluginId: "zai",
|
||||
mediaUnderstandingProviderIds: ["zai"],
|
||||
requireDescribeImages: true,
|
||||
},
|
||||
} satisfies Record<string, PluginRegistrationContractParams>;
|
||||
@@ -1,158 +0,0 @@
|
||||
import {
|
||||
loadPluginManifestRegistry,
|
||||
pluginRegistrationContractRegistry,
|
||||
} from "openclaw/plugin-sdk/testing";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type PluginRegistrationContractParams = {
|
||||
pluginId: string;
|
||||
cliBackendIds?: string[];
|
||||
providerIds?: string[];
|
||||
webFetchProviderIds?: string[];
|
||||
webSearchProviderIds?: string[];
|
||||
speechProviderIds?: string[];
|
||||
realtimeTranscriptionProviderIds?: string[];
|
||||
realtimeVoiceProviderIds?: string[];
|
||||
mediaUnderstandingProviderIds?: string[];
|
||||
imageGenerationProviderIds?: string[];
|
||||
videoGenerationProviderIds?: string[];
|
||||
musicGenerationProviderIds?: string[];
|
||||
toolNames?: string[];
|
||||
requireSpeechVoices?: boolean;
|
||||
requireDescribeImages?: boolean;
|
||||
requireGenerateImage?: boolean;
|
||||
requireGenerateVideo?: boolean;
|
||||
manifestAuthChoice?: {
|
||||
pluginId: string;
|
||||
choiceId: string;
|
||||
choiceLabel: string;
|
||||
groupId: string;
|
||||
groupLabel: string;
|
||||
groupHint: string;
|
||||
};
|
||||
};
|
||||
|
||||
function findRegistration(pluginId: string) {
|
||||
const entry = pluginRegistrationContractRegistry.find(
|
||||
(candidate) => candidate.pluginId === pluginId,
|
||||
);
|
||||
if (!entry) {
|
||||
throw new Error(`plugin registration contract missing for ${pluginId}`);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function describePluginRegistrationContract(params: PluginRegistrationContractParams) {
|
||||
describe(`${params.pluginId} plugin registration contract`, () => {
|
||||
if (params.cliBackendIds) {
|
||||
it("keeps bundled cli-backend ownership explicit", () => {
|
||||
expect(findRegistration(params.pluginId).cliBackendIds).toEqual(params.cliBackendIds);
|
||||
});
|
||||
}
|
||||
|
||||
if (params.providerIds) {
|
||||
it("keeps bundled provider ownership explicit", () => {
|
||||
expect(findRegistration(params.pluginId).providerIds).toEqual(params.providerIds);
|
||||
});
|
||||
}
|
||||
|
||||
if (params.webSearchProviderIds) {
|
||||
it("keeps bundled web search ownership explicit", () => {
|
||||
expect(findRegistration(params.pluginId).webSearchProviderIds).toEqual(
|
||||
params.webSearchProviderIds,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (params.webFetchProviderIds) {
|
||||
it("keeps bundled web fetch ownership explicit", () => {
|
||||
expect(findRegistration(params.pluginId).webFetchProviderIds).toEqual(
|
||||
params.webFetchProviderIds,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (params.speechProviderIds) {
|
||||
it("keeps bundled speech ownership explicit", () => {
|
||||
expect(findRegistration(params.pluginId).speechProviderIds).toEqual(
|
||||
params.speechProviderIds,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (params.realtimeTranscriptionProviderIds) {
|
||||
it("keeps bundled realtime-transcription ownership explicit", () => {
|
||||
expect(findRegistration(params.pluginId).realtimeTranscriptionProviderIds).toEqual(
|
||||
params.realtimeTranscriptionProviderIds,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (params.realtimeVoiceProviderIds) {
|
||||
it("keeps bundled realtime-voice ownership explicit", () => {
|
||||
expect(findRegistration(params.pluginId).realtimeVoiceProviderIds).toEqual(
|
||||
params.realtimeVoiceProviderIds,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (params.mediaUnderstandingProviderIds) {
|
||||
it("keeps bundled media-understanding ownership explicit", () => {
|
||||
expect(findRegistration(params.pluginId).mediaUnderstandingProviderIds).toEqual(
|
||||
params.mediaUnderstandingProviderIds,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (params.imageGenerationProviderIds) {
|
||||
it("keeps bundled image-generation ownership explicit", () => {
|
||||
expect(findRegistration(params.pluginId).imageGenerationProviderIds).toEqual(
|
||||
params.imageGenerationProviderIds,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (params.videoGenerationProviderIds) {
|
||||
it("keeps bundled video-generation ownership explicit", () => {
|
||||
expect(findRegistration(params.pluginId).videoGenerationProviderIds).toEqual(
|
||||
params.videoGenerationProviderIds,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (params.musicGenerationProviderIds) {
|
||||
it("keeps bundled music-generation ownership explicit", () => {
|
||||
expect(findRegistration(params.pluginId).musicGenerationProviderIds).toEqual(
|
||||
params.musicGenerationProviderIds,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (params.toolNames) {
|
||||
it("keeps bundled tool ownership explicit", () => {
|
||||
expect(findRegistration(params.pluginId).toolNames).toEqual(params.toolNames);
|
||||
});
|
||||
}
|
||||
|
||||
const manifestAuthChoice = params.manifestAuthChoice;
|
||||
if (manifestAuthChoice) {
|
||||
it("keeps onboarding auth grouping explicit", () => {
|
||||
const plugin = loadPluginManifestRegistry({}).plugins.find(
|
||||
(entry) => entry.origin === "bundled" && entry.id === manifestAuthChoice.pluginId,
|
||||
);
|
||||
|
||||
expect(plugin?.providerAuthChoices).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
choiceId: manifestAuthChoice.choiceId,
|
||||
choiceLabel: manifestAuthChoice.choiceLabel,
|
||||
groupId: manifestAuthChoice.groupId,
|
||||
groupLabel: manifestAuthChoice.groupLabel,
|
||||
groupHint: manifestAuthChoice.groupHint,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,495 +0,0 @@
|
||||
import {
|
||||
implicitMentionKindWhen,
|
||||
resolveInboundMentionDecision,
|
||||
} from "openclaw/plugin-sdk/channel-mention-gating";
|
||||
import {
|
||||
createAckReactionHandle,
|
||||
removeAckReactionAfterReply,
|
||||
removeAckReactionHandleAfterReply,
|
||||
shouldAckReaction,
|
||||
} from "openclaw/plugin-sdk/testing";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/testing";
|
||||
import { vi } from "vitest";
|
||||
|
||||
const DEFAULT_PROVIDER = "openai";
|
||||
const DEFAULT_MODEL = "gpt-5.5";
|
||||
|
||||
type DeepPartial<T> = {
|
||||
[K in keyof T]?: T[K] extends (...args: never[]) => unknown
|
||||
? T[K]
|
||||
: T[K] extends ReadonlyArray<unknown>
|
||||
? T[K]
|
||||
: T[K] extends object
|
||||
? DeepPartial<T[K]>
|
||||
: T[K];
|
||||
};
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function mergeDeep<T>(base: T, overrides: DeepPartial<T>): T {
|
||||
const result: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
||||
for (const [key, overrideValue] of Object.entries(overrides as Record<string, unknown>)) {
|
||||
if (overrideValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
const baseValue = result[key];
|
||||
if (isObject(baseValue) && isObject(overrideValue)) {
|
||||
result[key] = mergeDeep(baseValue, overrideValue);
|
||||
continue;
|
||||
}
|
||||
result[key] = overrideValue;
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
|
||||
function createTaskFlowSessionMock() {
|
||||
return {
|
||||
sessionKey: "agent:main:main",
|
||||
createManaged: vi.fn(),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(() => []),
|
||||
findLatest: vi.fn(),
|
||||
resolve: vi.fn(),
|
||||
getTaskSummary: vi.fn(),
|
||||
setWaiting: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
finish: vi.fn(),
|
||||
fail: vi.fn(),
|
||||
requestCancel: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
runTask: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createDeprecatedRuntimeConfigError(name: "loadConfig" | "writeConfigFile"): Error {
|
||||
return new Error(
|
||||
`Plugin runtime config.${name}() is deprecated in tests; pass cfg/current() or use mutateConfigFile()/replaceConfigFile().`,
|
||||
);
|
||||
}
|
||||
|
||||
export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> = {}): PluginRuntime {
|
||||
const taskFlow = {
|
||||
bindSession: vi.fn(
|
||||
createTaskFlowSessionMock,
|
||||
) as unknown as PluginRuntime["taskFlow"]["bindSession"],
|
||||
fromToolContext: vi.fn(
|
||||
createTaskFlowSessionMock,
|
||||
) as unknown as PluginRuntime["taskFlow"]["fromToolContext"],
|
||||
};
|
||||
const base: PluginRuntime = {
|
||||
version: "1.0.0-test",
|
||||
config: {
|
||||
current: vi.fn(() => ({})) as unknown as PluginRuntime["config"]["current"],
|
||||
mutateConfigFile: vi.fn(async () => ({
|
||||
path: "/tmp/openclaw.json",
|
||||
previousHash: null,
|
||||
snapshot: {} as never,
|
||||
nextConfig: {},
|
||||
afterWrite: { mode: "auto" },
|
||||
followUp: { mode: "auto", requiresRestart: false },
|
||||
result: undefined,
|
||||
})) as unknown as PluginRuntime["config"]["mutateConfigFile"],
|
||||
replaceConfigFile: vi.fn(async ({ nextConfig }) => ({
|
||||
path: "/tmp/openclaw.json",
|
||||
previousHash: null,
|
||||
snapshot: {} as never,
|
||||
nextConfig,
|
||||
afterWrite: { mode: "auto" },
|
||||
followUp: { mode: "auto", requiresRestart: false },
|
||||
})) as unknown as PluginRuntime["config"]["replaceConfigFile"],
|
||||
loadConfig: vi.fn(() => {
|
||||
throw createDeprecatedRuntimeConfigError("loadConfig");
|
||||
}) as unknown as PluginRuntime["config"]["loadConfig"],
|
||||
writeConfigFile: vi.fn(async () => {
|
||||
throw createDeprecatedRuntimeConfigError("writeConfigFile");
|
||||
}) as unknown as PluginRuntime["config"]["writeConfigFile"],
|
||||
},
|
||||
agent: {
|
||||
defaults: {
|
||||
model: DEFAULT_MODEL,
|
||||
provider: DEFAULT_PROVIDER,
|
||||
},
|
||||
resolveAgentDir: vi.fn(
|
||||
() => "/tmp/agent",
|
||||
) as unknown as PluginRuntime["agent"]["resolveAgentDir"],
|
||||
resolveAgentWorkspaceDir: vi.fn(
|
||||
() => "/tmp/workspace",
|
||||
) as unknown as PluginRuntime["agent"]["resolveAgentWorkspaceDir"],
|
||||
resolveAgentIdentity: vi.fn(() => ({
|
||||
name: "test-agent",
|
||||
})) as unknown as PluginRuntime["agent"]["resolveAgentIdentity"],
|
||||
resolveThinkingDefault: vi.fn(
|
||||
() => "off",
|
||||
) as unknown as PluginRuntime["agent"]["resolveThinkingDefault"],
|
||||
normalizeThinkingLevel: vi.fn(
|
||||
(raw?: string | null) => raw,
|
||||
) as unknown as PluginRuntime["agent"]["normalizeThinkingLevel"],
|
||||
resolveThinkingPolicy: vi.fn(() => ({
|
||||
levels: [
|
||||
{ id: "off", label: "off" },
|
||||
{ id: "minimal", label: "minimal" },
|
||||
{ id: "low", label: "low" },
|
||||
{ id: "medium", label: "medium" },
|
||||
{ id: "high", label: "high" },
|
||||
],
|
||||
})) as unknown as PluginRuntime["agent"]["resolveThinkingPolicy"],
|
||||
runEmbeddedPiAgent: vi.fn().mockResolvedValue({
|
||||
payloads: [],
|
||||
meta: {},
|
||||
}) as unknown as PluginRuntime["agent"]["runEmbeddedPiAgent"],
|
||||
runEmbeddedAgent: vi.fn().mockResolvedValue({
|
||||
payloads: [],
|
||||
meta: {},
|
||||
}) as unknown as PluginRuntime["agent"]["runEmbeddedAgent"],
|
||||
resolveAgentTimeoutMs: vi.fn(
|
||||
() => 30_000,
|
||||
) as unknown as PluginRuntime["agent"]["resolveAgentTimeoutMs"],
|
||||
ensureAgentWorkspace: vi
|
||||
.fn()
|
||||
.mockResolvedValue(undefined) as unknown as PluginRuntime["agent"]["ensureAgentWorkspace"],
|
||||
session: {
|
||||
resolveStorePath: vi.fn(
|
||||
() => "/tmp/agent-sessions.json",
|
||||
) as unknown as PluginRuntime["agent"]["session"]["resolveStorePath"],
|
||||
loadSessionStore: vi.fn(
|
||||
() => ({}),
|
||||
) as unknown as PluginRuntime["agent"]["session"]["loadSessionStore"],
|
||||
saveSessionStore: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
undefined,
|
||||
) as unknown as PluginRuntime["agent"]["session"]["saveSessionStore"],
|
||||
resolveSessionFilePath: vi.fn(
|
||||
(sessionId: string) => `/tmp/${sessionId}.json`,
|
||||
) as unknown as PluginRuntime["agent"]["session"]["resolveSessionFilePath"],
|
||||
},
|
||||
},
|
||||
system: {
|
||||
enqueueSystemEvent: vi.fn() as unknown as PluginRuntime["system"]["enqueueSystemEvent"],
|
||||
requestHeartbeatNow: vi.fn() as unknown as PluginRuntime["system"]["requestHeartbeatNow"],
|
||||
runHeartbeatOnce: vi.fn(async () => ({
|
||||
status: "ran" as const,
|
||||
durationMs: 0,
|
||||
})) as unknown as PluginRuntime["system"]["runHeartbeatOnce"],
|
||||
runCommandWithTimeout: vi.fn() as unknown as PluginRuntime["system"]["runCommandWithTimeout"],
|
||||
formatNativeDependencyHint: vi.fn(
|
||||
() => "",
|
||||
) as unknown as PluginRuntime["system"]["formatNativeDependencyHint"],
|
||||
},
|
||||
media: {
|
||||
loadWebMedia: vi.fn() as unknown as PluginRuntime["media"]["loadWebMedia"],
|
||||
detectMime: vi.fn() as unknown as PluginRuntime["media"]["detectMime"],
|
||||
mediaKindFromMime: vi.fn() as unknown as PluginRuntime["media"]["mediaKindFromMime"],
|
||||
isVoiceCompatibleAudio:
|
||||
vi.fn() as unknown as PluginRuntime["media"]["isVoiceCompatibleAudio"],
|
||||
getImageMetadata: vi.fn() as unknown as PluginRuntime["media"]["getImageMetadata"],
|
||||
resizeToJpeg: vi.fn() as unknown as PluginRuntime["media"]["resizeToJpeg"],
|
||||
},
|
||||
tts: {
|
||||
textToSpeech: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeech"],
|
||||
textToSpeechTelephony: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeechTelephony"],
|
||||
listVoices: vi.fn() as unknown as PluginRuntime["tts"]["listVoices"],
|
||||
},
|
||||
mediaUnderstanding: {
|
||||
runFile: vi.fn() as unknown as PluginRuntime["mediaUnderstanding"]["runFile"],
|
||||
describeImageFile:
|
||||
vi.fn() as unknown as PluginRuntime["mediaUnderstanding"]["describeImageFile"],
|
||||
describeImageFileWithModel:
|
||||
vi.fn() as unknown as PluginRuntime["mediaUnderstanding"]["describeImageFileWithModel"],
|
||||
describeVideoFile:
|
||||
vi.fn() as unknown as PluginRuntime["mediaUnderstanding"]["describeVideoFile"],
|
||||
transcribeAudioFile:
|
||||
vi.fn() as unknown as PluginRuntime["mediaUnderstanding"]["transcribeAudioFile"],
|
||||
},
|
||||
imageGeneration: {
|
||||
generate: vi.fn() as unknown as PluginRuntime["imageGeneration"]["generate"],
|
||||
listProviders: vi.fn() as unknown as PluginRuntime["imageGeneration"]["listProviders"],
|
||||
},
|
||||
musicGeneration: {
|
||||
generate: vi.fn() as unknown as PluginRuntime["musicGeneration"]["generate"],
|
||||
listProviders: vi.fn() as unknown as PluginRuntime["musicGeneration"]["listProviders"],
|
||||
},
|
||||
videoGeneration: {
|
||||
generate: vi.fn() as unknown as PluginRuntime["videoGeneration"]["generate"],
|
||||
listProviders: vi.fn() as unknown as PluginRuntime["videoGeneration"]["listProviders"],
|
||||
},
|
||||
webSearch: {
|
||||
listProviders: vi.fn() as unknown as PluginRuntime["webSearch"]["listProviders"],
|
||||
search: vi.fn() as unknown as PluginRuntime["webSearch"]["search"],
|
||||
},
|
||||
stt: {
|
||||
transcribeAudioFile: vi.fn() as unknown as PluginRuntime["stt"]["transcribeAudioFile"],
|
||||
},
|
||||
channel: {
|
||||
text: {
|
||||
chunkByNewline: vi.fn((text: string) => (text ? [text] : [])),
|
||||
chunkMarkdownText: vi.fn((text: string) => [text]),
|
||||
chunkMarkdownTextWithMode: vi.fn((text: string) => (text ? [text] : [])),
|
||||
chunkText: vi.fn((text: string) => (text ? [text] : [])),
|
||||
chunkTextWithMode: vi.fn((text: string) => (text ? [text] : [])),
|
||||
resolveChunkMode: vi.fn(
|
||||
() => "length",
|
||||
) as unknown as PluginRuntime["channel"]["text"]["resolveChunkMode"],
|
||||
resolveTextChunkLimit: vi.fn(() => 4000),
|
||||
hasControlCommand: vi.fn(() => false),
|
||||
resolveMarkdownTableMode: vi.fn(
|
||||
() => "code",
|
||||
) as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"],
|
||||
convertMarkdownTables: vi.fn((text: string) => text),
|
||||
},
|
||||
reply: {
|
||||
dispatchReplyWithBufferedBlockDispatcher: vi.fn(
|
||||
async () => undefined,
|
||||
) as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
|
||||
createReplyDispatcherWithTyping:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["createReplyDispatcherWithTyping"],
|
||||
resolveEffectiveMessagesConfig:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveEffectiveMessagesConfig"],
|
||||
resolveHumanDelayConfig:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveHumanDelayConfig"],
|
||||
dispatchReplyFromConfig:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"],
|
||||
withReplyDispatcher: vi.fn(async ({ dispatcher, run, onSettled }) => {
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
dispatcher.markComplete();
|
||||
try {
|
||||
await dispatcher.waitForIdle();
|
||||
} finally {
|
||||
await onSettled?.();
|
||||
}
|
||||
}
|
||||
}) as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
|
||||
finalizeInboundContext: vi.fn(
|
||||
(ctx: Record<string, unknown>) => ctx,
|
||||
) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
||||
formatAgentEnvelope: vi.fn(
|
||||
(opts: { body: string }) => opts.body,
|
||||
) as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"],
|
||||
formatInboundEnvelope: vi.fn(
|
||||
(opts: { body: string }) => opts.body,
|
||||
) as unknown as PluginRuntime["channel"]["reply"]["formatInboundEnvelope"],
|
||||
resolveEnvelopeFormatOptions: vi.fn(() => ({
|
||||
template: "channel+name+time",
|
||||
})) as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
|
||||
},
|
||||
routing: {
|
||||
buildAgentSessionKey: vi.fn(
|
||||
({
|
||||
agentId,
|
||||
channel,
|
||||
peer,
|
||||
}: {
|
||||
agentId: string;
|
||||
channel: string;
|
||||
peer?: { kind?: string; id?: string };
|
||||
}) => `agent:${agentId}:${channel}:${peer?.kind ?? "direct"}:${peer?.id ?? "peer"}`,
|
||||
) as unknown as PluginRuntime["channel"]["routing"]["buildAgentSessionKey"],
|
||||
resolveAgentRoute: vi.fn(() => ({
|
||||
agentId: "main",
|
||||
accountId: "default",
|
||||
sessionKey: "agent:main:test:dm:peer",
|
||||
})) as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
|
||||
},
|
||||
pairing: {
|
||||
buildPairingReply: vi.fn(
|
||||
() => "Pairing code: TESTCODE",
|
||||
) as unknown as PluginRuntime["channel"]["pairing"]["buildPairingReply"],
|
||||
readAllowFromStore: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
[],
|
||||
) as unknown as PluginRuntime["channel"]["pairing"]["readAllowFromStore"],
|
||||
upsertPairingRequest: vi.fn().mockResolvedValue({
|
||||
code: "TESTCODE",
|
||||
created: true,
|
||||
}) as unknown as PluginRuntime["channel"]["pairing"]["upsertPairingRequest"],
|
||||
},
|
||||
media: {
|
||||
fetchRemoteMedia:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
|
||||
saveMediaBuffer: vi.fn().mockResolvedValue({
|
||||
path: "/tmp/test-media.jpg",
|
||||
contentType: "image/jpeg",
|
||||
}) as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
|
||||
},
|
||||
session: {
|
||||
resolveStorePath: vi.fn(
|
||||
() => "/tmp/sessions.json",
|
||||
) as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
|
||||
readSessionUpdatedAt: vi.fn(
|
||||
() => undefined,
|
||||
) as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
|
||||
recordSessionMetaFromInbound:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"],
|
||||
recordInboundSession:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
|
||||
updateLastRoute:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"],
|
||||
},
|
||||
mentions: {
|
||||
buildMentionRegexes: vi.fn(() => [
|
||||
/\bbert\b/i,
|
||||
]) as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"],
|
||||
matchesMentionPatterns: vi.fn((text: string, regexes: RegExp[]) =>
|
||||
regexes.some((regex) => regex.test(text)),
|
||||
) as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"],
|
||||
matchesMentionWithExplicit: vi.fn(
|
||||
(params: { text: string; mentionRegexes: RegExp[]; explicitWasMentioned?: boolean }) =>
|
||||
params.explicitWasMentioned === true
|
||||
? true
|
||||
: params.mentionRegexes.some((regex) => regex.test(params.text)),
|
||||
) as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionWithExplicit"],
|
||||
implicitMentionKindWhen,
|
||||
resolveInboundMentionDecision,
|
||||
},
|
||||
reactions: {
|
||||
createAckReactionHandle,
|
||||
shouldAckReaction,
|
||||
removeAckReactionAfterReply,
|
||||
removeAckReactionHandleAfterReply,
|
||||
},
|
||||
groups: {
|
||||
resolveGroupPolicy: vi.fn(
|
||||
() => "open",
|
||||
) as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
|
||||
resolveRequireMention: vi.fn(
|
||||
() => false,
|
||||
) as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"],
|
||||
},
|
||||
debounce: {
|
||||
createInboundDebouncer: vi.fn(
|
||||
(params: { onFlush: (items: unknown[]) => Promise<void> }) => ({
|
||||
enqueue: async (item: unknown) => {
|
||||
await params.onFlush([item]);
|
||||
},
|
||||
flushKey: vi.fn(),
|
||||
}),
|
||||
) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"],
|
||||
resolveInboundDebounceMs: vi.fn((params: unknown) => {
|
||||
// Match the production contract so channel plugins that delegate to
|
||||
// `core.channel.debounce.resolveInboundDebounceMs({ cfg, channel })`
|
||||
// see the same per-channel/global/default precedence in tests as
|
||||
// they would at runtime. Prior to this, the mock returned 0
|
||||
// unconditionally, which meant any channel that delegated (vs.
|
||||
// reading config directly) effectively disabled its debounce
|
||||
// window in tests — a footgun that silently hid coverage for
|
||||
// per-channel overrides.
|
||||
const p = params as
|
||||
| {
|
||||
cfg?: {
|
||||
messages?: {
|
||||
inbound?: {
|
||||
debounceMs?: unknown;
|
||||
byChannel?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
};
|
||||
channel?: string;
|
||||
overrideMs?: unknown;
|
||||
}
|
||||
| undefined;
|
||||
const override = typeof p?.overrideMs === "number" ? p.overrideMs : undefined;
|
||||
if (typeof override === "number") {
|
||||
return override;
|
||||
}
|
||||
const inbound = p?.cfg?.messages?.inbound;
|
||||
const perChannel =
|
||||
p?.channel && inbound?.byChannel ? inbound.byChannel[p.channel] : undefined;
|
||||
if (typeof perChannel === "number") {
|
||||
return perChannel;
|
||||
}
|
||||
if (typeof inbound?.debounceMs === "number") {
|
||||
return inbound.debounceMs;
|
||||
}
|
||||
return 0;
|
||||
}) as unknown as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"],
|
||||
},
|
||||
commands: {
|
||||
resolveCommandAuthorizedFromAuthorizers: vi.fn(
|
||||
() => false,
|
||||
) as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"],
|
||||
isControlCommandMessage:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["isControlCommandMessage"],
|
||||
shouldComputeCommandAuthorized:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"],
|
||||
shouldHandleTextCommands:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldHandleTextCommands"],
|
||||
},
|
||||
outbound: {
|
||||
loadAdapter: vi.fn() as unknown as PluginRuntime["channel"]["outbound"]["loadAdapter"],
|
||||
},
|
||||
threadBindings: {
|
||||
setIdleTimeoutBySessionKey:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["threadBindings"]["setIdleTimeoutBySessionKey"],
|
||||
setMaxAgeBySessionKey:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["threadBindings"]["setMaxAgeBySessionKey"],
|
||||
},
|
||||
runtimeContexts: {
|
||||
register: vi.fn(({ abortSignal }: { abortSignal?: AbortSignal }) => {
|
||||
const lease = { dispose: vi.fn() };
|
||||
abortSignal?.addEventListener("abort", lease.dispose, { once: true });
|
||||
return lease;
|
||||
}) as unknown as PluginRuntime["channel"]["runtimeContexts"]["register"],
|
||||
get: vi.fn() as unknown as PluginRuntime["channel"]["runtimeContexts"]["get"],
|
||||
watch: vi.fn(() =>
|
||||
vi.fn(),
|
||||
) as unknown as PluginRuntime["channel"]["runtimeContexts"]["watch"],
|
||||
},
|
||||
activity: {} as PluginRuntime["channel"]["activity"],
|
||||
},
|
||||
events: {
|
||||
onAgentEvent: vi.fn(() => () => {}) as unknown as PluginRuntime["events"]["onAgentEvent"],
|
||||
onSessionTranscriptUpdate: vi.fn(
|
||||
() => () => {},
|
||||
) as unknown as PluginRuntime["events"]["onSessionTranscriptUpdate"],
|
||||
},
|
||||
logging: {
|
||||
shouldLogVerbose: vi.fn(() => false),
|
||||
getChildLogger: vi.fn(() => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
})),
|
||||
},
|
||||
state: {
|
||||
resolveStateDir: vi.fn(() => "/tmp/openclaw"),
|
||||
},
|
||||
tasks: {
|
||||
runs: {
|
||||
bindSession: vi.fn(),
|
||||
fromToolContext: vi.fn(),
|
||||
} as PluginRuntime["tasks"]["runs"],
|
||||
flows: {
|
||||
bindSession: vi.fn(),
|
||||
fromToolContext: vi.fn(),
|
||||
} as PluginRuntime["tasks"]["flows"],
|
||||
flow: taskFlow,
|
||||
},
|
||||
taskFlow,
|
||||
modelAuth: {
|
||||
getApiKeyForModel: vi.fn() as unknown as PluginRuntime["modelAuth"]["getApiKeyForModel"],
|
||||
getRuntimeAuthForModel:
|
||||
vi.fn() as unknown as PluginRuntime["modelAuth"]["getRuntimeAuthForModel"],
|
||||
resolveApiKeyForProvider:
|
||||
vi.fn() as unknown as PluginRuntime["modelAuth"]["resolveApiKeyForProvider"],
|
||||
},
|
||||
subagent: {
|
||||
run: vi.fn(),
|
||||
waitForRun: vi.fn(),
|
||||
getSessionMessages: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
},
|
||||
nodes: {
|
||||
list: vi.fn(async () => ({ nodes: [] })),
|
||||
invoke: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
return mergeDeep(base, overrides);
|
||||
}
|
||||
@@ -1,386 +0,0 @@
|
||||
import {
|
||||
clearRuntimeAuthProfileStoreSnapshots,
|
||||
type AuthProfileStore,
|
||||
} from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { createNonExitingRuntime } from "openclaw/plugin-sdk/runtime";
|
||||
import type {
|
||||
WizardMultiSelectParams,
|
||||
WizardPrompter,
|
||||
WizardProgress,
|
||||
WizardSelectParams,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerProviders, requireProvider } from "./contracts-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 EnsureAuthProfileStore =
|
||||
typeof import("openclaw/plugin-sdk/provider-auth").ensureAuthProfileStore;
|
||||
type ListProfilesForProvider =
|
||||
typeof import("openclaw/plugin-sdk/provider-auth").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>());
|
||||
|
||||
export type ProviderAuthContractPluginLoader = () => Promise<{
|
||||
default: Parameters<typeof registerProviders>[0];
|
||||
}>;
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-auth-login", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/provider-auth-login")>(
|
||||
"openclaw/plugin-sdk/provider-auth-login",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
loginOpenAICodexOAuth: loginOpenAICodexOAuthMock,
|
||||
githubCopilotLoginCommand: githubCopilotLoginCommandMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-auth", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/provider-auth")>(
|
||||
"openclaw/plugin-sdk/provider-auth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
ensureAuthProfileStore: ensureAuthProfileStoreMock,
|
||||
listProfilesForProvider: listProfilesForProviderMock,
|
||||
};
|
||||
});
|
||||
|
||||
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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 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.5": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultModel: "openai-codex/gpt-5.5",
|
||||
notes: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function installSharedAuthProfileStoreHooks(state: { authStore: AuthProfileStore }) {
|
||||
beforeEach(() => {
|
||||
state.authStore = { version: 1, profiles: {} };
|
||||
ensureAuthProfileStoreMock.mockReset();
|
||||
ensureAuthProfileStoreMock.mockImplementation(() => state.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();
|
||||
});
|
||||
}
|
||||
|
||||
export function describeOpenAICodexProviderAuthContract(load: ProviderAuthContractPluginLoader) {
|
||||
const state = {
|
||||
authStore: { version: 1, profiles: {} } as AuthProfileStore,
|
||||
};
|
||||
|
||||
describe("openai-codex provider auth contract", () => {
|
||||
installSharedAuthProfileStoreHooks(state);
|
||||
|
||||
async function expectStableFallbackProfile(params: { access: string; profileId: string }) {
|
||||
const { default: openAIPlugin } = await load();
|
||||
const provider = requireProvider(await registerProviders(openAIPlugin), "openai-codex");
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function getProvider() {
|
||||
const { default: openAIPlugin } = await load();
|
||||
return requireProvider(await registerProviders(openAIPlugin), "openai-codex");
|
||||
}
|
||||
|
||||
it("keeps OAuth auth results provider-owned", async () => {
|
||||
const provider = await getProvider();
|
||||
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 OAuth email from the JWT profile claim", async () => {
|
||||
const provider = await getProvider();
|
||||
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 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 expectStableFallbackProfile({
|
||||
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 expectStableFallbackProfile({
|
||||
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 expectStableFallbackProfile({
|
||||
access,
|
||||
profileId: `openai-codex:id-${expectedStableId}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the default profile when JWT parsing yields no identity", async () => {
|
||||
const provider = await getProvider();
|
||||
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 OAuth failures non-fatal at the provider layer", async () => {
|
||||
const provider = await getProvider();
|
||||
loginOpenAICodexOAuthMock.mockRejectedValueOnce(new Error("oauth failed"));
|
||||
|
||||
await expect(provider.auth[0]?.run(buildAuthContext() as never)).resolves.toEqual({
|
||||
profiles: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeGithubCopilotProviderAuthContract(load: ProviderAuthContractPluginLoader) {
|
||||
const state = {
|
||||
authStore: { version: 1, profiles: {} } as AuthProfileStore,
|
||||
};
|
||||
|
||||
describe("github-copilot provider auth contract", () => {
|
||||
installSharedAuthProfileStoreHooks(state);
|
||||
|
||||
async function getProvider() {
|
||||
const { default: githubCopilotPlugin } = await load();
|
||||
return requireProvider(await registerProviders(githubCopilotPlugin), "github-copilot");
|
||||
}
|
||||
|
||||
it("keeps device auth results provider-owned", async () => {
|
||||
const provider = await getProvider();
|
||||
state.authStore.profiles["github-copilot:github"] = {
|
||||
type: "token",
|
||||
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/claude-opus-4.7",
|
||||
});
|
||||
} finally {
|
||||
if (previousIsTTYDescriptor) {
|
||||
Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor);
|
||||
} else if (!hadOwnIsTTY) {
|
||||
delete (stdin as { isTTY?: boolean }).isTTY;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps auth gated on interactive TTYs", async () => {
|
||||
const provider = await getProvider();
|
||||
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,31 +0,0 @@
|
||||
export {
|
||||
expectAugmentedCodexCatalog,
|
||||
expectedAugmentedOpenaiCodexCatalogEntriesWithGpt55,
|
||||
expectCodexBuiltInSuppression,
|
||||
expectCodexMissingAuthHint,
|
||||
} from "openclaw/plugin-sdk/testing";
|
||||
export type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
export {
|
||||
loadBundledPluginPublicSurface,
|
||||
loadBundledPluginPublicSurfaceSync,
|
||||
} from "./public-surface-loader.js";
|
||||
|
||||
type ProviderRuntimeCatalogModule = Pick<
|
||||
typeof import("openclaw/plugin-sdk/provider-catalog-runtime"),
|
||||
| "augmentModelCatalogWithProviderPlugins"
|
||||
| "resetProviderRuntimeHookCacheForTest"
|
||||
| "resolveProviderBuiltInModelSuppression"
|
||||
>;
|
||||
|
||||
export async function importProviderRuntimeCatalogModule(): Promise<ProviderRuntimeCatalogModule> {
|
||||
const {
|
||||
augmentModelCatalogWithProviderPlugins,
|
||||
resetProviderRuntimeHookCacheForTest,
|
||||
resolveProviderBuiltInModelSuppression,
|
||||
} = await import("openclaw/plugin-sdk/provider-catalog-runtime");
|
||||
return {
|
||||
augmentModelCatalogWithProviderPlugins,
|
||||
resetProviderRuntimeHookCacheForTest,
|
||||
resolveProviderBuiltInModelSuppression,
|
||||
};
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import type { WebFetchProviderPlugin } from "openclaw/plugin-sdk/provider-web-fetch-contract";
|
||||
import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/testing";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
type Lazy<T> = T | (() => T);
|
||||
|
||||
type WebProviderCredentialContract = Pick<
|
||||
WebSearchProviderPlugin,
|
||||
| "createTool"
|
||||
| "docsUrl"
|
||||
| "envVars"
|
||||
| "getCredentialValue"
|
||||
| "hint"
|
||||
| "id"
|
||||
| "label"
|
||||
| "placeholder"
|
||||
| "setCredentialValue"
|
||||
| "signupUrl"
|
||||
>;
|
||||
|
||||
function resolveLazy<T>(value: Lazy<T>): T {
|
||||
return typeof value === "function" ? (value as () => T)() : value;
|
||||
}
|
||||
|
||||
function expectWebProviderCredentialContract(
|
||||
provider: WebProviderCredentialContract,
|
||||
credentialValue: unknown,
|
||||
) {
|
||||
expect(provider.id).toMatch(/^[a-z0-9][a-z0-9-]*$/);
|
||||
expect(provider.label.trim()).not.toBe("");
|
||||
expect(provider.hint.trim()).not.toBe("");
|
||||
expect(provider.placeholder.trim()).not.toBe("");
|
||||
expect(provider.signupUrl.startsWith("https://")).toBe(true);
|
||||
if (provider.docsUrl) {
|
||||
expect(provider.docsUrl.startsWith("http")).toBe(true);
|
||||
}
|
||||
|
||||
expect(provider.envVars).toEqual([...new Set(provider.envVars)]);
|
||||
expect(provider.envVars.every((entry) => entry.trim().length > 0)).toBe(true);
|
||||
|
||||
const configTarget: Record<string, unknown> = {};
|
||||
provider.setCredentialValue(configTarget, credentialValue);
|
||||
expect(provider.getCredentialValue(configTarget)).toEqual(credentialValue);
|
||||
|
||||
expect(typeof provider.createTool).toBe("function");
|
||||
return configTarget;
|
||||
}
|
||||
|
||||
export function installProviderPluginContractSuite(params: { provider: Lazy<ProviderPlugin> }) {
|
||||
it("satisfies the base provider plugin contract", () => {
|
||||
const provider = resolveLazy(params.provider);
|
||||
const authIds = provider.auth.map((method) => method.id);
|
||||
const wizardChoiceIds = new Set<string>();
|
||||
|
||||
expect(provider.id).toMatch(/^[a-z0-9][a-z0-9-]*$/);
|
||||
expect(provider.label.trim()).not.toBe("");
|
||||
|
||||
if (provider.docsPath) {
|
||||
expect(provider.docsPath.startsWith("/")).toBe(true);
|
||||
}
|
||||
if (provider.aliases) {
|
||||
expect(provider.aliases).toEqual([...new Set(provider.aliases)]);
|
||||
}
|
||||
if (provider.envVars) {
|
||||
expect(provider.envVars).toEqual([...new Set(provider.envVars)]);
|
||||
expect(provider.envVars.every((entry) => entry.trim().length > 0)).toBe(true);
|
||||
}
|
||||
|
||||
expect(Array.isArray(provider.auth)).toBe(true);
|
||||
expect(authIds).toEqual([...new Set(authIds)]);
|
||||
for (const method of provider.auth) {
|
||||
expect(method.id.trim()).not.toBe("");
|
||||
expect(method.label.trim()).not.toBe("");
|
||||
if (method.hint !== undefined) {
|
||||
expect(method.hint.trim()).not.toBe("");
|
||||
}
|
||||
if (method.wizard) {
|
||||
if (method.wizard.choiceId) {
|
||||
expect(method.wizard.choiceId.trim()).not.toBe("");
|
||||
expect(wizardChoiceIds.has(method.wizard.choiceId)).toBe(false);
|
||||
wizardChoiceIds.add(method.wizard.choiceId);
|
||||
}
|
||||
if (method.wizard.methodId) {
|
||||
expect(authIds).toContain(method.wizard.methodId);
|
||||
}
|
||||
if (method.wizard.modelAllowlist?.allowedKeys) {
|
||||
expect(method.wizard.modelAllowlist.allowedKeys).toEqual([
|
||||
...new Set(method.wizard.modelAllowlist.allowedKeys),
|
||||
]);
|
||||
}
|
||||
if (method.wizard.modelAllowlist?.initialSelections) {
|
||||
expect(method.wizard.modelAllowlist.initialSelections).toEqual([
|
||||
...new Set(method.wizard.modelAllowlist.initialSelections),
|
||||
]);
|
||||
}
|
||||
}
|
||||
expect(typeof method.run).toBe("function");
|
||||
}
|
||||
|
||||
if (provider.wizard?.setup || provider.wizard?.modelPicker) {
|
||||
expect(provider.auth.length).toBeGreaterThan(0);
|
||||
}
|
||||
if (provider.wizard?.setup) {
|
||||
if (provider.wizard.setup.choiceId) {
|
||||
expect(provider.wizard.setup.choiceId.trim()).not.toBe("");
|
||||
expect(wizardChoiceIds.has(provider.wizard.setup.choiceId)).toBe(false);
|
||||
}
|
||||
if (provider.wizard.setup.methodId) {
|
||||
expect(authIds).toContain(provider.wizard.setup.methodId);
|
||||
}
|
||||
if (provider.wizard.setup.modelAllowlist?.allowedKeys) {
|
||||
expect(provider.wizard.setup.modelAllowlist.allowedKeys).toEqual([
|
||||
...new Set(provider.wizard.setup.modelAllowlist.allowedKeys),
|
||||
]);
|
||||
}
|
||||
if (provider.wizard.setup.modelAllowlist?.initialSelections) {
|
||||
expect(provider.wizard.setup.modelAllowlist.initialSelections).toEqual([
|
||||
...new Set(provider.wizard.setup.modelAllowlist.initialSelections),
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (provider.wizard?.modelPicker?.methodId) {
|
||||
expect(authIds).toContain(provider.wizard.modelPicker.methodId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function installWebSearchProviderContractSuite(params: {
|
||||
provider: Lazy<WebSearchProviderPlugin>;
|
||||
credentialValue: Lazy<unknown>;
|
||||
}) {
|
||||
it("satisfies the base web search provider contract", () => {
|
||||
const provider = resolveLazy(params.provider);
|
||||
const credentialValue = resolveLazy(params.credentialValue);
|
||||
|
||||
const searchConfigTarget = expectWebProviderCredentialContract(provider, credentialValue);
|
||||
expect(provider.getCredentialValue(searchConfigTarget)).toEqual(credentialValue);
|
||||
if (provider.runSetup) {
|
||||
expect(typeof provider.runSetup).toBe("function");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function installWebFetchProviderContractSuite(params: {
|
||||
provider: Lazy<WebFetchProviderPlugin>;
|
||||
credentialValue: Lazy<unknown>;
|
||||
pluginId?: string;
|
||||
}) {
|
||||
it("satisfies the base web fetch provider contract", () => {
|
||||
const provider = resolveLazy(params.provider);
|
||||
const credentialValue = resolveLazy(params.credentialValue);
|
||||
|
||||
expectWebProviderCredentialContract(provider, credentialValue);
|
||||
expect(provider.credentialPath.trim()).not.toBe("");
|
||||
if (provider.inactiveSecretPaths) {
|
||||
expect(provider.inactiveSecretPaths).toEqual([...new Set(provider.inactiveSecretPaths)]);
|
||||
expect(provider.inactiveSecretPaths).toContain(provider.credentialPath);
|
||||
}
|
||||
|
||||
const fetchConfigTarget: Record<string, unknown> = {};
|
||||
provider.setCredentialValue(fetchConfigTarget, credentialValue);
|
||||
expect(provider.getCredentialValue(fetchConfigTarget)).toEqual(credentialValue);
|
||||
|
||||
if (provider.setConfiguredCredentialValue && provider.getConfiguredCredentialValue) {
|
||||
const configTarget = {} as OpenClawConfig;
|
||||
provider.setConfiguredCredentialValue(configTarget, credentialValue);
|
||||
expect(provider.getConfiguredCredentialValue(configTarget)).toEqual(credentialValue);
|
||||
}
|
||||
|
||||
if (provider.applySelectionConfig && params.pluginId) {
|
||||
const applied = provider.applySelectionConfig({} as OpenClawConfig);
|
||||
expect(applied.plugins?.entries?.[params.pluginId]?.enabled).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
providerContractLoadError,
|
||||
resolveBundledExplicitProviderContractsFromPublicArtifacts,
|
||||
resolveProviderContractProvidersForPluginIds,
|
||||
} from "openclaw/plugin-sdk/testing";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { installProviderPluginContractSuite } from "./provider-contract-suites.js";
|
||||
|
||||
type ProviderContractEntry = {
|
||||
pluginId: string;
|
||||
provider: ProviderPlugin;
|
||||
};
|
||||
|
||||
function providerMatchesManifestId(provider: ProviderPlugin, providerId: string): boolean {
|
||||
return (
|
||||
provider.id === providerId ||
|
||||
(provider.aliases ?? []).includes(providerId) ||
|
||||
(provider.hookAliases ?? []).includes(providerId)
|
||||
);
|
||||
}
|
||||
function resolveProviderContractProvidersFromPublicArtifact(
|
||||
pluginId: string,
|
||||
): ProviderContractEntry[] | null {
|
||||
return resolveBundledExplicitProviderContractsFromPublicArtifacts({ onlyPluginIds: [pluginId] });
|
||||
}
|
||||
|
||||
export function describeProviderContracts(pluginId: string) {
|
||||
const resolveProviderEntries = (): ProviderContractEntry[] => {
|
||||
const publicArtifactProviders = resolveProviderContractProvidersFromPublicArtifact(pluginId);
|
||||
if (publicArtifactProviders) {
|
||||
return publicArtifactProviders;
|
||||
}
|
||||
return resolveProviderContractProvidersForPluginIds([pluginId]).map((provider) => ({
|
||||
pluginId,
|
||||
provider,
|
||||
}));
|
||||
};
|
||||
const resolveProviderIds = (): string[] =>
|
||||
resolveProviderEntries().map((entry) => entry.provider.id);
|
||||
|
||||
describe(`${pluginId} provider contract registry load`, () => {
|
||||
it("loads bundled providers without import-time registry failure", () => {
|
||||
const providers = resolveProviderEntries();
|
||||
expect(providerContractLoadError).toBeUndefined();
|
||||
expect(providers.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
for (const providerId of resolveProviderIds()) {
|
||||
describe(`${pluginId}:${providerId} provider contract`, () => {
|
||||
// Resolve provider entries lazily so the non-isolated extension runner
|
||||
// does not race provider contract collection against other file imports.
|
||||
installProviderPluginContractSuite({
|
||||
provider: () => {
|
||||
const entry = resolveProviderEntries().find((entry) =>
|
||||
providerMatchesManifestId(entry.provider, providerId),
|
||||
);
|
||||
if (!entry) {
|
||||
throw new Error(`provider contract entry missing for ${pluginId}:${providerId}`);
|
||||
}
|
||||
return entry.provider;
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,646 +0,0 @@
|
||||
import type { AuthProfileStore, OpenClawConfig } from "openclaw/plugin-sdk/provider-auth";
|
||||
import {
|
||||
registerProviderPlugins as registerProviders,
|
||||
requireRegisteredProvider as requireProvider,
|
||||
runProviderCatalog,
|
||||
} from "openclaw/plugin-sdk/testing";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const resolveCopilotApiTokenMock = 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());
|
||||
|
||||
export type ProviderDiscoveryContractPluginLoader = () => Promise<{
|
||||
default: Parameters<typeof registerProviders>[0];
|
||||
}>;
|
||||
|
||||
type ProviderHandle = Awaited<ReturnType<typeof registerProviders>>[number];
|
||||
|
||||
type DiscoveryState = {
|
||||
runProviderCatalog: typeof runProviderCatalog;
|
||||
githubCopilotProvider?: ProviderHandle;
|
||||
vllmProvider?: ProviderHandle;
|
||||
sglangProvider?: ProviderHandle;
|
||||
minimaxProvider?: ProviderHandle;
|
||||
minimaxPortalProvider?: ProviderHandle;
|
||||
modelStudioProvider?: ProviderHandle;
|
||||
cloudflareAiGatewayProvider?: ProviderHandle;
|
||||
};
|
||||
|
||||
type BundledProviderUnderTest =
|
||||
| "github-copilot"
|
||||
| "vllm"
|
||||
| "sglang"
|
||||
| "minimax"
|
||||
| "modelstudio"
|
||||
| "cloudflare-ai-gateway";
|
||||
|
||||
type DiscoveryContractOptions = {
|
||||
providerIds: readonly BundledProviderUnderTest[];
|
||||
loadGithubCopilot?: ProviderDiscoveryContractPluginLoader;
|
||||
loadVllm?: ProviderDiscoveryContractPluginLoader;
|
||||
loadSglang?: ProviderDiscoveryContractPluginLoader;
|
||||
loadMinimax?: ProviderDiscoveryContractPluginLoader;
|
||||
loadModelStudio?: ProviderDiscoveryContractPluginLoader;
|
||||
loadCloudflareAiGateway?: ProviderDiscoveryContractPluginLoader;
|
||||
githubCopilotRegisterRuntimeModuleId?: string;
|
||||
vllmApiModuleId?: string;
|
||||
sglangApiModuleId?: string;
|
||||
};
|
||||
|
||||
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(
|
||||
state: DiscoveryState,
|
||||
params: {
|
||||
provider: ProviderHandle;
|
||||
config?: OpenClawConfig;
|
||||
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 state.runProviderCatalog({
|
||||
provider: params.provider,
|
||||
config: params.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",
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function installDiscoveryHooks(state: DiscoveryState, options: DiscoveryContractOptions) {
|
||||
beforeAll(async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("openclaw/plugin-sdk/agent-runtime", () => {
|
||||
return {
|
||||
ensureAuthProfileStore: ensureAuthProfileStoreMock,
|
||||
listProfilesForProvider: listProfilesForProviderMock,
|
||||
};
|
||||
});
|
||||
vi.doMock("openclaw/plugin-sdk/provider-auth", () => {
|
||||
return {
|
||||
MINIMAX_OAUTH_MARKER: "minimax-oauth",
|
||||
applyAuthProfileConfig: (config: OpenClawConfig) => config,
|
||||
buildApiKeyCredential: (
|
||||
provider: string,
|
||||
key: unknown,
|
||||
metadata?: Record<string, unknown>,
|
||||
) => ({
|
||||
type: "api_key",
|
||||
provider,
|
||||
...(typeof key === "string" ? { key } : {}),
|
||||
...(metadata ? { metadata } : {}),
|
||||
}),
|
||||
buildOauthProviderAuthResult: vi.fn(),
|
||||
coerceSecretRef: (value: unknown) =>
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null,
|
||||
ensureApiKeyFromOptionEnvOrPrompt: vi.fn(),
|
||||
ensureAuthProfileStore: ensureAuthProfileStoreMock,
|
||||
listProfilesForProvider: listProfilesForProviderMock,
|
||||
normalizeApiKeyInput: (value: unknown) => (typeof value === "string" ? value.trim() : ""),
|
||||
normalizeOptionalSecretInput: (value: unknown) =>
|
||||
typeof value === "string" && value.trim() ? value.trim() : undefined,
|
||||
resolveNonEnvSecretRefApiKeyMarker: (source: unknown) =>
|
||||
typeof source === "string" ? source : "",
|
||||
upsertAuthProfile: vi.fn(),
|
||||
validateApiKeyInput: () => undefined,
|
||||
};
|
||||
});
|
||||
if (options.githubCopilotRegisterRuntimeModuleId) {
|
||||
vi.doMock(options.githubCopilotRegisterRuntimeModuleId, async () => {
|
||||
const actual = await vi.importActual<object>(options.githubCopilotRegisterRuntimeModuleId!);
|
||||
return {
|
||||
...actual,
|
||||
resolveCopilotApiToken: resolveCopilotApiTokenMock,
|
||||
};
|
||||
});
|
||||
}
|
||||
if (options.vllmApiModuleId) {
|
||||
vi.doMock(options.vllmApiModuleId, async () => {
|
||||
return {
|
||||
VLLM_DEFAULT_API_KEY_ENV_VAR: "VLLM_API_KEY",
|
||||
VLLM_DEFAULT_BASE_URL: "http://127.0.0.1:8000/v1",
|
||||
VLLM_MODEL_PLACEHOLDER: "meta-llama/Meta-Llama-3-8B-Instruct",
|
||||
VLLM_PROVIDER_LABEL: "vLLM",
|
||||
buildVllmProvider: (...args: unknown[]) => buildVllmProviderMock(...args),
|
||||
};
|
||||
});
|
||||
}
|
||||
if (options.sglangApiModuleId) {
|
||||
vi.doMock(options.sglangApiModuleId, async () => {
|
||||
return {
|
||||
SGLANG_DEFAULT_API_KEY_ENV_VAR: "SGLANG_API_KEY",
|
||||
SGLANG_DEFAULT_BASE_URL: "http://127.0.0.1:30000/v1",
|
||||
SGLANG_MODEL_PLACEHOLDER: "Qwen/Qwen3-8B",
|
||||
SGLANG_PROVIDER_LABEL: "SGLang",
|
||||
buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args),
|
||||
};
|
||||
});
|
||||
}
|
||||
state.runProviderCatalog = runProviderCatalog;
|
||||
|
||||
if (options.providerIds.includes("github-copilot")) {
|
||||
const { default: githubCopilotPlugin } = await options.loadGithubCopilot!();
|
||||
state.githubCopilotProvider = requireProvider(
|
||||
await registerProviders(githubCopilotPlugin),
|
||||
"github-copilot",
|
||||
);
|
||||
}
|
||||
|
||||
if (options.providerIds.includes("vllm")) {
|
||||
const { default: vllmPlugin } = await options.loadVllm!();
|
||||
state.vllmProvider = requireProvider(await registerProviders(vllmPlugin), "vllm");
|
||||
}
|
||||
|
||||
if (options.providerIds.includes("sglang")) {
|
||||
const { default: sglangPlugin } = await options.loadSglang!();
|
||||
state.sglangProvider = requireProvider(await registerProviders(sglangPlugin), "sglang");
|
||||
}
|
||||
|
||||
if (options.providerIds.includes("minimax")) {
|
||||
const { default: minimaxPlugin } = await options.loadMinimax!();
|
||||
const registeredProviders = await registerProviders(minimaxPlugin);
|
||||
state.minimaxProvider = requireProvider(registeredProviders, "minimax");
|
||||
state.minimaxPortalProvider = requireProvider(registeredProviders, "minimax-portal");
|
||||
}
|
||||
|
||||
if (options.providerIds.includes("modelstudio")) {
|
||||
const { default: qwenPlugin } = await options.loadModelStudio!();
|
||||
state.modelStudioProvider = requireProvider(await registerProviders(qwenPlugin), "qwen");
|
||||
}
|
||||
|
||||
if (options.providerIds.includes("cloudflare-ai-gateway")) {
|
||||
const { default: cloudflareAiGatewayPlugin } = await options.loadCloudflareAiGateway!();
|
||||
state.cloudflareAiGatewayProvider = requireProvider(
|
||||
await registerProviders(cloudflareAiGatewayPlugin),
|
||||
"cloudflare-ai-gateway",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setRuntimeAuthStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
resolveCopilotApiTokenMock.mockReset();
|
||||
buildVllmProviderMock.mockReset();
|
||||
buildSglangProviderMock.mockReset();
|
||||
ensureAuthProfileStoreMock.mockReset();
|
||||
listProfilesForProviderMock.mockReset();
|
||||
setRuntimeAuthStore();
|
||||
});
|
||||
}
|
||||
|
||||
export function describeGithubCopilotProviderDiscoveryContract(params: {
|
||||
load: ProviderDiscoveryContractPluginLoader;
|
||||
registerRuntimeModuleId: string;
|
||||
}) {
|
||||
const state = {} as DiscoveryState;
|
||||
|
||||
describe("github-copilot provider discovery contract", () => {
|
||||
installDiscoveryHooks(state, {
|
||||
providerIds: ["github-copilot"],
|
||||
loadGithubCopilot: params.load,
|
||||
githubCopilotRegisterRuntimeModuleId: params.registerRuntimeModuleId,
|
||||
});
|
||||
|
||||
it("keeps catalog disabled without env tokens or profiles", async () => {
|
||||
await expect(
|
||||
runCatalog(state, { provider: state.githubCopilotProvider! }),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps profile-only catalog fallback provider-owned", async () => {
|
||||
setGithubCopilotProfileSnapshot();
|
||||
|
||||
await expect(
|
||||
runCatalog(state, {
|
||||
provider: state.githubCopilotProvider!,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
provider: {
|
||||
baseUrl: "https://api.individual.githubcopilot.com",
|
||||
models: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps 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(state, {
|
||||
provider: state.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",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeVllmProviderDiscoveryContract(params: {
|
||||
load: ProviderDiscoveryContractPluginLoader;
|
||||
apiModuleId: string;
|
||||
}) {
|
||||
const state = {} as DiscoveryState;
|
||||
|
||||
describe("vllm provider discovery contract", () => {
|
||||
installDiscoveryHooks(state, {
|
||||
providerIds: ["vllm"],
|
||||
loadVllm: params.load,
|
||||
vllmApiModuleId: params.apiModuleId,
|
||||
});
|
||||
|
||||
it("keeps 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(
|
||||
runCatalog(state, {
|
||||
provider: state.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",
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeSglangProviderDiscoveryContract(params: {
|
||||
load: ProviderDiscoveryContractPluginLoader;
|
||||
apiModuleId: string;
|
||||
}) {
|
||||
const state = {} as DiscoveryState;
|
||||
|
||||
describe("sglang provider discovery contract", () => {
|
||||
installDiscoveryHooks(state, {
|
||||
providerIds: ["sglang"],
|
||||
loadSglang: params.load,
|
||||
sglangApiModuleId: params.apiModuleId,
|
||||
});
|
||||
|
||||
it("keeps 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(
|
||||
runCatalog(state, {
|
||||
provider: state.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",
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeMinimaxProviderDiscoveryContract(
|
||||
load: ProviderDiscoveryContractPluginLoader,
|
||||
) {
|
||||
const state = {} as DiscoveryState;
|
||||
|
||||
describe("minimax provider discovery contract", () => {
|
||||
installDiscoveryHooks(state, { providerIds: ["minimax"], loadMinimax: load });
|
||||
|
||||
it("keeps API catalog provider-owned", async () => {
|
||||
await expect(
|
||||
state.runProviderCatalog({
|
||||
provider: state.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 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(
|
||||
runCatalog(state, {
|
||||
provider: state.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 portal explicit base URL override provider-owned", async () => {
|
||||
await expect(
|
||||
state.runProviderCatalog({
|
||||
provider: state.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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeModelStudioProviderDiscoveryContract(
|
||||
load: ProviderDiscoveryContractPluginLoader,
|
||||
) {
|
||||
const state = {} as DiscoveryState;
|
||||
|
||||
describe("modelstudio provider discovery contract", () => {
|
||||
installDiscoveryHooks(state, { providerIds: ["modelstudio"], loadModelStudio: load });
|
||||
|
||||
it("keeps catalog provider-owned", async () => {
|
||||
await expect(
|
||||
state.runProviderCatalog({
|
||||
provider: state.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: "qwen3-max-2026-01-23" }),
|
||||
expect.objectContaining({ id: "MiniMax-M2.5" }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeCloudflareAiGatewayProviderDiscoveryContract(
|
||||
load: ProviderDiscoveryContractPluginLoader,
|
||||
) {
|
||||
const state = {} as DiscoveryState;
|
||||
|
||||
describe("cloudflare-ai-gateway provider discovery contract", () => {
|
||||
installDiscoveryHooks(state, {
|
||||
providerIds: ["cloudflare-ai-gateway"],
|
||||
loadCloudflareAiGateway: load,
|
||||
});
|
||||
|
||||
it("keeps catalog disabled without stored metadata", async () => {
|
||||
await expect(
|
||||
runCatalog(state, {
|
||||
provider: state.cloudflareAiGatewayProvider!,
|
||||
config: {},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({ apiKey: undefined }),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: undefined,
|
||||
discoveryApiKey: undefined,
|
||||
mode: "none",
|
||||
source: "none",
|
||||
}),
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps 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(
|
||||
runCatalog(state, {
|
||||
provider: state.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-6" })],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import {
|
||||
resolveAgentModelFallbackValues,
|
||||
resolveAgentModelPrimaryValue,
|
||||
} from "openclaw/plugin-sdk/provider-onboard";
|
||||
import type { ModelApi } from "openclaw/plugin-sdk/provider-onboard";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/testing";
|
||||
import { expect } from "vitest";
|
||||
import {
|
||||
createConfigWithFallbacks,
|
||||
createLegacyProviderConfig,
|
||||
EXPECTED_FALLBACKS,
|
||||
} from "./onboard-config.js";
|
||||
|
||||
export function expectProviderOnboardAllowlistAlias(params: {
|
||||
applyProviderConfig: (config: OpenClawConfig) => OpenClawConfig;
|
||||
modelRef: string;
|
||||
alias: string;
|
||||
}) {
|
||||
const withDefault = params.applyProviderConfig({});
|
||||
expect(Object.keys(withDefault.agents?.defaults?.models ?? {})).toContain(params.modelRef);
|
||||
|
||||
const withAlias = params.applyProviderConfig({
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
[params.modelRef]: { alias: params.alias },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(withAlias.agents?.defaults?.models?.[params.modelRef]?.alias).toBe(params.alias);
|
||||
}
|
||||
|
||||
export function expectProviderOnboardPrimaryAndFallbacks(params: {
|
||||
applyConfig: (config: OpenClawConfig) => OpenClawConfig;
|
||||
modelRef: string;
|
||||
}) {
|
||||
expectProviderOnboardPrimaryModel(params);
|
||||
|
||||
const cfgWithFallbacks = params.applyConfig(createConfigWithFallbacks());
|
||||
expect(resolveAgentModelFallbackValues(cfgWithFallbacks.agents?.defaults?.model)).toEqual([
|
||||
...EXPECTED_FALLBACKS,
|
||||
]);
|
||||
}
|
||||
|
||||
export function expectProviderOnboardPrimaryModel(params: {
|
||||
applyConfig: (config: OpenClawConfig) => OpenClawConfig;
|
||||
modelRef: string;
|
||||
}) {
|
||||
const cfg = params.applyConfig({});
|
||||
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(params.modelRef);
|
||||
}
|
||||
|
||||
export function expectProviderOnboardPreservesPrimary(params: {
|
||||
applyProviderConfig: (config: OpenClawConfig) => OpenClawConfig;
|
||||
primaryModelRef: string;
|
||||
}) {
|
||||
const cfg = params.applyProviderConfig({
|
||||
agents: { defaults: { model: { primary: params.primaryModelRef } } },
|
||||
});
|
||||
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(params.primaryModelRef);
|
||||
}
|
||||
|
||||
export function expectProviderOnboardMergedLegacyConfig(params: {
|
||||
applyProviderConfig: (config: OpenClawConfig) => OpenClawConfig;
|
||||
providerId: string;
|
||||
providerApi: ModelApi;
|
||||
baseUrl: string;
|
||||
legacyApi: Parameters<typeof createLegacyProviderConfig>[0]["api"];
|
||||
legacyModelId?: string;
|
||||
legacyModelName?: string;
|
||||
legacyBaseUrl?: string;
|
||||
legacyApiKey?: string;
|
||||
}) {
|
||||
const cfg = params.applyProviderConfig(
|
||||
createLegacyProviderConfig({
|
||||
providerId: params.providerId,
|
||||
api: params.legacyApi,
|
||||
modelId: params.legacyModelId,
|
||||
modelName: params.legacyModelName,
|
||||
baseUrl: params.legacyBaseUrl,
|
||||
apiKey: params.legacyApiKey,
|
||||
}),
|
||||
);
|
||||
|
||||
const provider = cfg.models?.providers?.[params.providerId];
|
||||
expect(provider?.baseUrl).toBe(params.baseUrl);
|
||||
expect(provider?.api).toBe(params.providerApi);
|
||||
expect(provider?.apiKey).toBe((params.legacyApiKey ?? "old-key").trim());
|
||||
return provider;
|
||||
}
|
||||
@@ -1,858 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
createProviderUsageFetch,
|
||||
makeResponse,
|
||||
registerProviderPlugin,
|
||||
requireRegisteredProvider,
|
||||
} from "openclaw/plugin-sdk/testing";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
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" },
|
||||
{ id: "google", envApiKey: "GOOGLE_API_KEY", oauthTokenEnv: "GOOGLE_OAUTH_TOKEN" },
|
||||
{ id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" },
|
||||
]),
|
||||
);
|
||||
|
||||
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: ProviderRuntimeContractPluginLoader;
|
||||
};
|
||||
|
||||
export type ProviderRuntimeContractPluginLoader = () => Promise<{
|
||||
default: Parameters<typeof registerProviderPlugin>[0]["plugin"];
|
||||
}>;
|
||||
|
||||
function installRuntimeHooks(fixtures: readonly ProviderRuntimeContractFixture[]) {
|
||||
const providers = new Map<string, ProviderPlugin>();
|
||||
let loadPromise: Promise<void> | null = null;
|
||||
|
||||
function requireProviderContractProvider(providerId: string): ProviderPlugin {
|
||||
const provider = providers.get(providerId);
|
||||
if (!provider) {
|
||||
throw new Error(`provider runtime contract fixture missing for ${providerId}`);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
async function ensureProvidersLoaded() {
|
||||
if (!loadPromise) {
|
||||
loadPromise = (async () => {
|
||||
providers.clear();
|
||||
const registeredFixtures = await Promise.all(
|
||||
fixtures.map(async (fixture) => {
|
||||
const plugin = await fixture.load();
|
||||
return {
|
||||
fixture,
|
||||
providers: (
|
||||
await registerProviderPlugin({
|
||||
plugin: plugin.default,
|
||||
id: fixture.pluginId,
|
||||
name: fixture.name,
|
||||
})
|
||||
).providers,
|
||||
};
|
||||
}),
|
||||
);
|
||||
for (const { fixture, providers: registeredProviders } of registeredFixtures) {
|
||||
for (const providerId of fixture.providerIds) {
|
||||
providers.set(
|
||||
providerId,
|
||||
requireRegisteredProvider(registeredProviders, providerId, "provider"),
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
await loadPromise;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await ensureProvidersLoaded();
|
||||
}, CONTRACT_SETUP_TIMEOUT_MS);
|
||||
|
||||
beforeEach(() => {
|
||||
refreshOpenAICodexTokenMock.mockReset();
|
||||
getOAuthProvidersMock.mockClear();
|
||||
}, CONTRACT_SETUP_TIMEOUT_MS);
|
||||
|
||||
return requireProviderContractProvider;
|
||||
}
|
||||
|
||||
export function describeAnthropicProviderRuntimeContract(
|
||||
load: ProviderRuntimeContractPluginLoader,
|
||||
) {
|
||||
describe("anthropic provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => {
|
||||
const requireProviderContractProvider = installRuntimeHooks([
|
||||
{ providerIds: ["anthropic"], pluginId: "anthropic", name: "Anthropic", load },
|
||||
]);
|
||||
|
||||
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,
|
||||
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",
|
||||
});
|
||||
});
|
||||
|
||||
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") },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeGithubCopilotProviderRuntimeContract(
|
||||
load: ProviderRuntimeContractPluginLoader,
|
||||
) {
|
||||
describe(
|
||||
"github-copilot provider runtime contract",
|
||||
{ timeout: CONTRACT_SETUP_TIMEOUT_MS },
|
||||
() => {
|
||||
const requireProviderContractProvider = installRuntimeHooks([
|
||||
{
|
||||
providerIds: ["github-copilot"],
|
||||
pluginId: "github-copilot",
|
||||
name: "GitHub Copilot",
|
||||
load,
|
||||
},
|
||||
]);
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function describeGoogleProviderRuntimeContract(load: ProviderRuntimeContractPluginLoader) {
|
||||
describe("google provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => {
|
||||
const requireProviderContractProvider = installRuntimeHooks([
|
||||
{ providerIds: ["google", "google-gemini-cli"], pluginId: "google", name: "Google", load },
|
||||
]);
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeOpenAIProviderRuntimeContract(load: ProviderRuntimeContractPluginLoader) {
|
||||
describe("openai provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => {
|
||||
const requireProviderContractProvider = installRuntimeHooks([
|
||||
{ providerIds: ["openai", "openai-codex"], pluginId: "openai", name: "OpenAI", load },
|
||||
]);
|
||||
|
||||
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("leaves openai gpt-5.5 forward-compat resolution to Pi", () => {
|
||||
const provider = requireProviderContractProvider("openai");
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.5",
|
||||
modelRegistry: {
|
||||
find: (_provider: string, id: string) =>
|
||||
id === "gpt-5.4"
|
||||
? createModel({
|
||||
id,
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
input: ["text", "image"],
|
||||
})
|
||||
: null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(model).toBeUndefined();
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
|
||||
it("owns refresh fallback for accountId extraction failures", 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("keeps Pi cost metadata but applies Codex context metadata for gpt-5.5 models", () => {
|
||||
const provider = requireProviderContractProvider("openai-codex");
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "openai-codex",
|
||||
modelId: "gpt-5.5",
|
||||
modelRegistry: {
|
||||
find: (_provider: string, id: string) =>
|
||||
id === "gpt-5.5"
|
||||
? createModel({
|
||||
id,
|
||||
api: "openai-codex-responses",
|
||||
provider: "openai-codex",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
input: ["text", "image"],
|
||||
cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 0 },
|
||||
contextWindow: 272_000,
|
||||
maxTokens: 128_000,
|
||||
})
|
||||
: null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "gpt-5.5",
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
contextWindow: 400_000,
|
||||
contextTokens: 272_000,
|
||||
maxTokens: 128_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("owns forward-compat codex mini models", () => {
|
||||
const provider = requireProviderContractProvider("openai-codex");
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "openai-codex",
|
||||
modelId: "gpt-5.4-mini",
|
||||
modelRegistry: {
|
||||
find: (_provider: string, id: string) =>
|
||||
id === "gpt-5.1-codex-mini"
|
||||
? 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-mini",
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
contextWindow: 272_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",
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeOpenRouterProviderRuntimeContract(
|
||||
load: ProviderRuntimeContractPluginLoader,
|
||||
) {
|
||||
describe("openrouter provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => {
|
||||
const requireProviderContractProvider = installRuntimeHooks([
|
||||
{ providerIds: ["openrouter"], pluginId: "openrouter", name: "OpenRouter", load },
|
||||
]);
|
||||
|
||||
it("owns dynamic OpenRouter model defaults", () => {
|
||||
const provider = requireProviderContractProvider("openrouter");
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "openrouter",
|
||||
modelId: "x-ai/grok-4-1-fast",
|
||||
modelRegistry: {
|
||||
find: () => null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "x-ai/grok-4-1-fast",
|
||||
provider: "openrouter",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
maxTokens: 8192,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeVeniceProviderRuntimeContract(load: ProviderRuntimeContractPluginLoader) {
|
||||
describe("venice provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => {
|
||||
const requireProviderContractProvider = installRuntimeHooks([
|
||||
{ providerIds: ["venice"], pluginId: "venice", name: "Venice", load },
|
||||
]);
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeZAIProviderRuntimeContract(load: ProviderRuntimeContractPluginLoader) {
|
||||
describe("zai provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => {
|
||||
const requireProviderContractProvider = installRuntimeHooks([
|
||||
{ providerIds: ["zai"], pluginId: "zai", name: "Z.AI", load },
|
||||
]);
|
||||
|
||||
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,246 +0,0 @@
|
||||
import type { ProviderAuthMethod } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
buildProviderPluginMethodChoice,
|
||||
resolveProviderModelPickerEntries,
|
||||
resolveProviderPluginChoice,
|
||||
resolveProviderWizardOptions,
|
||||
setProviderWizardProvidersResolverForTest,
|
||||
} from "openclaw/plugin-sdk/testing";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const resolvePluginProvidersMock = vi.fn();
|
||||
let restoreProviderResolver: (() => void) | undefined;
|
||||
|
||||
function createAuthMethod(
|
||||
params: Pick<ProviderAuthMethod, "id" | "label"> &
|
||||
Partial<Pick<ProviderAuthMethod, "hint" | "wizard">>,
|
||||
): ProviderAuthMethod {
|
||||
return {
|
||||
id: params.id,
|
||||
label: params.label,
|
||||
...(params.hint ? { hint: params.hint } : {}),
|
||||
...(params.wizard ? { wizard: params.wizard } : {}),
|
||||
kind: "api_key",
|
||||
run: async () => ({ profiles: [] }),
|
||||
};
|
||||
}
|
||||
|
||||
const TEST_PROVIDERS: ProviderPlugin[] = [
|
||||
{
|
||||
id: "alpha",
|
||||
label: "Alpha",
|
||||
auth: [
|
||||
createAuthMethod({
|
||||
id: "api-key",
|
||||
label: "API key",
|
||||
wizard: {
|
||||
choiceLabel: "Alpha key",
|
||||
choiceHint: "Use an API key",
|
||||
groupId: "alpha",
|
||||
groupLabel: "Alpha",
|
||||
onboardingScopes: ["text-inference"],
|
||||
},
|
||||
}),
|
||||
createAuthMethod({
|
||||
id: "oauth",
|
||||
label: "OAuth",
|
||||
wizard: {
|
||||
choiceId: "alpha-oauth",
|
||||
choiceLabel: "Alpha OAuth",
|
||||
groupId: "alpha",
|
||||
groupLabel: "Alpha",
|
||||
groupHint: "Recommended",
|
||||
},
|
||||
}),
|
||||
],
|
||||
wizard: {
|
||||
modelPicker: {
|
||||
label: "Alpha custom",
|
||||
hint: "Pick Alpha models",
|
||||
methodId: "oauth",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "beta",
|
||||
label: "Beta",
|
||||
auth: [createAuthMethod({ id: "token", label: "Token" })],
|
||||
wizard: {
|
||||
setup: {
|
||||
choiceLabel: "Beta setup",
|
||||
groupId: "beta",
|
||||
groupLabel: "Beta",
|
||||
},
|
||||
modelPicker: {
|
||||
label: "Beta custom",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gamma",
|
||||
label: "Gamma",
|
||||
auth: [
|
||||
createAuthMethod({ id: "default", label: "Default auth" }),
|
||||
createAuthMethod({ id: "alt", label: "Alt auth" }),
|
||||
],
|
||||
wizard: {
|
||||
setup: {
|
||||
methodId: "alt",
|
||||
choiceId: "gamma-alt",
|
||||
choiceLabel: "Gamma alt",
|
||||
groupId: "gamma",
|
||||
groupLabel: "Gamma",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const TEST_PROVIDER_IDS = TEST_PROVIDERS.map((provider) => provider.id).toSorted((left, right) =>
|
||||
left.localeCompare(right),
|
||||
);
|
||||
|
||||
function sortedValues(values: readonly string[]) {
|
||||
return [...values].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function expectUniqueValues(values: readonly string[]) {
|
||||
expect(values).toEqual([...new Set(values)]);
|
||||
}
|
||||
|
||||
function resolveExpectedWizardChoiceValues(providers: ProviderPlugin[]) {
|
||||
return sortedValues(
|
||||
providers.flatMap((provider) => {
|
||||
const methodSetups = provider.auth.filter((method) => method.wizard);
|
||||
if (methodSetups.length > 0) {
|
||||
return methodSetups.map(
|
||||
(method) =>
|
||||
method.wizard?.choiceId?.trim() ||
|
||||
buildProviderPluginMethodChoice(provider.id, method.id),
|
||||
);
|
||||
}
|
||||
|
||||
const setup = provider.wizard?.setup;
|
||||
if (!setup) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const explicitMethodId = setup.methodId?.trim();
|
||||
if (explicitMethodId && provider.auth.some((method) => method.id === explicitMethodId)) {
|
||||
return [
|
||||
setup.choiceId?.trim() || buildProviderPluginMethodChoice(provider.id, explicitMethodId),
|
||||
];
|
||||
}
|
||||
|
||||
if (provider.auth.length === 1) {
|
||||
return [setup.choiceId?.trim() || provider.id];
|
||||
}
|
||||
|
||||
return provider.auth.map((method) => buildProviderPluginMethodChoice(provider.id, method.id));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveExpectedModelPickerValues(providers: ProviderPlugin[]) {
|
||||
return sortedValues(
|
||||
providers.flatMap((provider) => {
|
||||
const modelPicker = provider.wizard?.modelPicker;
|
||||
if (!modelPicker) {
|
||||
return [];
|
||||
}
|
||||
const explicitMethodId = modelPicker.methodId?.trim();
|
||||
if (explicitMethodId) {
|
||||
return [buildProviderPluginMethodChoice(provider.id, explicitMethodId)];
|
||||
}
|
||||
if (provider.auth.length === 1) {
|
||||
return [provider.id];
|
||||
}
|
||||
return [buildProviderPluginMethodChoice(provider.id, provider.auth[0]?.id ?? "default")];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function expectAllChoicesResolve(
|
||||
values: readonly string[],
|
||||
resolver: (choice: string) => ReturnType<typeof resolveProviderPluginChoice>,
|
||||
) {
|
||||
expect(
|
||||
values.every((value) => Boolean(resolver(value))),
|
||||
values.join(", "),
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resolvePluginProvidersMock.mockReset();
|
||||
resolvePluginProvidersMock.mockReturnValue(TEST_PROVIDERS);
|
||||
restoreProviderResolver?.();
|
||||
restoreProviderResolver = setProviderWizardProvidersResolverForTest((params) =>
|
||||
resolvePluginProvidersMock(params),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreProviderResolver?.();
|
||||
restoreProviderResolver = undefined;
|
||||
});
|
||||
|
||||
export function describeProviderWizardSetupOptionsContract() {
|
||||
describe("provider wizard setup options contract", () => {
|
||||
it("exposes every wizard setup choice through the shared wizard layer", () => {
|
||||
const options = resolveProviderWizardOptions({
|
||||
config: {
|
||||
plugins: {
|
||||
enabled: true,
|
||||
allow: TEST_PROVIDER_IDS,
|
||||
slots: {
|
||||
memory: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(sortedValues(options.map((option) => option.value))).toEqual(
|
||||
resolveExpectedWizardChoiceValues(TEST_PROVIDERS),
|
||||
);
|
||||
expectUniqueValues(options.map((option) => option.value));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeProviderWizardChoiceResolutionContract() {
|
||||
describe("provider wizard choice resolution contract", () => {
|
||||
it("round-trips every shared wizard choice back to its provider and auth method", () => {
|
||||
const options = resolveProviderWizardOptions({ config: {}, env: process.env });
|
||||
|
||||
expectAllChoicesResolve(
|
||||
options.map((option) => option.value),
|
||||
(choice) =>
|
||||
resolveProviderPluginChoice({
|
||||
providers: TEST_PROVIDERS,
|
||||
choice,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeProviderWizardModelPickerContract() {
|
||||
describe("provider wizard model picker contract", () => {
|
||||
it("exposes every model-picker entry through the shared wizard layer", () => {
|
||||
const entries = resolveProviderModelPickerEntries({ config: {}, env: process.env });
|
||||
|
||||
expect(sortedValues(entries.map((entry) => entry.value))).toEqual(
|
||||
resolveExpectedModelPickerValues(TEST_PROVIDERS),
|
||||
);
|
||||
expectAllChoicesResolve(
|
||||
entries.map((entry) => entry.value),
|
||||
(choice) =>
|
||||
resolveProviderPluginChoice({
|
||||
providers: TEST_PROVIDERS,
|
||||
choice,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { assertUniqueValues, BUNDLED_RUNTIME_SIDECAR_PATHS } from "openclaw/plugin-sdk/testing";
|
||||
|
||||
export function getPublicArtifactBasename(relativePath: string): string {
|
||||
return relativePath.split("/").at(-1) ?? relativePath;
|
||||
}
|
||||
|
||||
const EXTRA_GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES = assertUniqueValues(
|
||||
[
|
||||
"action-runtime.runtime.js",
|
||||
"action-runtime-api.js",
|
||||
"allow-from.js",
|
||||
"api.js",
|
||||
"auth-presence.js",
|
||||
"channel-config-api.js",
|
||||
"index.js",
|
||||
"login-qr-api.js",
|
||||
"onboard.js",
|
||||
"openai-codex-catalog.js",
|
||||
"provider-catalog.js",
|
||||
"session-key-api.js",
|
||||
"setup-api.js",
|
||||
"setup-entry.js",
|
||||
"timeouts.js",
|
||||
"x-search.js",
|
||||
] as const,
|
||||
"extra guarded extension public surface basename",
|
||||
);
|
||||
|
||||
export const BUNDLED_RUNTIME_SIDECAR_BASENAMES = assertUniqueValues(
|
||||
[...new Set(BUNDLED_RUNTIME_SIDECAR_PATHS.map(getPublicArtifactBasename))],
|
||||
"bundled runtime sidecar basename",
|
||||
);
|
||||
|
||||
export const GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES = assertUniqueValues(
|
||||
[...BUNDLED_RUNTIME_SIDECAR_BASENAMES, ...EXTRA_GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES],
|
||||
"guarded extension public surface basename",
|
||||
);
|
||||
@@ -1,81 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
function readJson<T>(filePath: string): T | undefined {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8")) as T;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeArtifactBasename(artifactBasename: string): string {
|
||||
return artifactBasename.replace(/^\.\/+/u, "").replace(/^\/+/u, "");
|
||||
}
|
||||
|
||||
function resolveSourceArtifactPath(packageDir: string, artifactBasename: string): string {
|
||||
const artifactPath = path.resolve(packageDir, normalizeArtifactBasename(artifactBasename));
|
||||
if (artifactPath.endsWith(".js")) {
|
||||
const sourcePath = `${artifactPath.slice(0, -".js".length)}.ts`;
|
||||
if (fs.existsSync(sourcePath)) {
|
||||
return sourcePath;
|
||||
}
|
||||
}
|
||||
return artifactPath;
|
||||
}
|
||||
|
||||
function resolveExtensionDirByManifestId(pluginId: string): string {
|
||||
const pluginDir = path.resolve(repoRoot, "extensions", pluginId);
|
||||
const manifest = readJson<{ id?: unknown }>(path.join(pluginDir, "openclaw.plugin.json"));
|
||||
if (manifest?.id === pluginId) {
|
||||
return pluginDir;
|
||||
}
|
||||
throw new Error(`Unknown bundled plugin id: ${pluginId}`);
|
||||
}
|
||||
|
||||
function resolveWorkspacePackageDir(packageName: string): string {
|
||||
const extensionsDir = path.resolve(repoRoot, "extensions");
|
||||
for (const entry of fs.readdirSync(extensionsDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const packageDir = path.join(extensionsDir, entry.name);
|
||||
const manifest = readJson<{ name?: unknown }>(path.join(packageDir, "package.json"));
|
||||
if (manifest?.name === packageName) {
|
||||
return packageDir;
|
||||
}
|
||||
}
|
||||
throw new Error(`Unknown workspace package: ${packageName}`);
|
||||
}
|
||||
|
||||
export async function loadBundledPluginPublicSurface<T extends object>(params: {
|
||||
pluginId: string;
|
||||
artifactBasename: string;
|
||||
}): Promise<T> {
|
||||
const artifactPath = resolveSourceArtifactPath(
|
||||
resolveExtensionDirByManifestId(params.pluginId),
|
||||
params.artifactBasename,
|
||||
);
|
||||
return (await import(pathToFileURL(artifactPath).href)) as T;
|
||||
}
|
||||
|
||||
export function loadBundledPluginPublicSurfaceSync<T extends object>(_params: {
|
||||
pluginId: string;
|
||||
artifactBasename: string;
|
||||
}): T {
|
||||
throw new Error("Synchronous bundled plugin public-surface loading is not available here");
|
||||
}
|
||||
|
||||
export function resolveWorkspacePackagePublicModuleUrl(params: {
|
||||
packageName: string;
|
||||
artifactBasename: string;
|
||||
}): string {
|
||||
const artifactPath = resolveSourceArtifactPath(
|
||||
resolveWorkspacePackageDir(params.packageName),
|
||||
params.artifactBasename,
|
||||
);
|
||||
return pathToFileURL(artifactPath).href;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { expect } from "vitest";
|
||||
|
||||
type MockFn = (...args: never[]) => unknown;
|
||||
|
||||
type CfgThreadingAssertion<TCfg> = {
|
||||
loadConfig: MockFn;
|
||||
resolveAccount: MockFn;
|
||||
cfg: TCfg;
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
type SendRuntimeState = {
|
||||
loadConfig: MockFn;
|
||||
resolveMarkdownTableMode: MockFn;
|
||||
convertMarkdownTables: MockFn;
|
||||
record: MockFn;
|
||||
};
|
||||
|
||||
export function expectProvidedCfgSkipsRuntimeLoad<TCfg>({
|
||||
loadConfig,
|
||||
resolveAccount,
|
||||
cfg,
|
||||
accountId,
|
||||
}: CfgThreadingAssertion<TCfg>): void {
|
||||
expect(loadConfig).not.toHaveBeenCalled();
|
||||
expect(resolveAccount).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
|
||||
export function expectRuntimeCfgFallback<TCfg>({
|
||||
loadConfig,
|
||||
resolveAccount,
|
||||
cfg,
|
||||
accountId,
|
||||
}: CfgThreadingAssertion<TCfg>): void {
|
||||
expect(loadConfig).toHaveBeenCalledTimes(1);
|
||||
expect(resolveAccount).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
|
||||
export function createSendCfgThreadingRuntime({
|
||||
loadConfig,
|
||||
resolveMarkdownTableMode,
|
||||
convertMarkdownTables,
|
||||
record,
|
||||
}: SendRuntimeState) {
|
||||
return {
|
||||
config: {
|
||||
loadConfig,
|
||||
},
|
||||
channel: {
|
||||
text: {
|
||||
resolveMarkdownTableMode,
|
||||
convertMarkdownTables,
|
||||
},
|
||||
activity: {
|
||||
record,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { createRuntimeEnv } from "openclaw/plugin-sdk/testing";
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelGatewayContext,
|
||||
OpenClawConfig,
|
||||
RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk/testing";
|
||||
import { vi } from "vitest";
|
||||
|
||||
export function createStartAccountContext<TAccount extends { accountId: string }>(params: {
|
||||
account: TAccount;
|
||||
abortSignal?: AbortSignal;
|
||||
cfg?: OpenClawConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
statusPatchSink?: (next: ChannelAccountSnapshot) => void;
|
||||
}): ChannelGatewayContext<TAccount> {
|
||||
const snapshot: ChannelAccountSnapshot = {
|
||||
accountId: params.account.accountId,
|
||||
configured: true,
|
||||
enabled: true,
|
||||
running: false,
|
||||
};
|
||||
return {
|
||||
accountId: params.account.accountId,
|
||||
account: params.account,
|
||||
cfg: params.cfg ?? ({} as OpenClawConfig),
|
||||
runtime: params.runtime ?? createRuntimeEnv(),
|
||||
abortSignal: params.abortSignal ?? new AbortController().signal,
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
getStatus: () => snapshot,
|
||||
setStatus: (next) => {
|
||||
Object.assign(snapshot, next);
|
||||
params.statusPatchSink?.(snapshot);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import type { ChannelAccountSnapshot, ChannelGatewayContext } from "openclaw/plugin-sdk/testing";
|
||||
import { expect, vi } from "vitest";
|
||||
import { createStartAccountContext } from "./start-account-context.js";
|
||||
|
||||
export function startAccountAndTrackLifecycle<TAccount extends { accountId: string }>(params: {
|
||||
startAccount: (ctx: ChannelGatewayContext<TAccount>) => Promise<unknown>;
|
||||
account: TAccount;
|
||||
}) {
|
||||
const patches: ChannelAccountSnapshot[] = [];
|
||||
const abort = new AbortController();
|
||||
const task = params.startAccount(
|
||||
createStartAccountContext({
|
||||
account: params.account,
|
||||
abortSignal: abort.signal,
|
||||
statusPatchSink: (next) => patches.push({ ...next }),
|
||||
}),
|
||||
);
|
||||
let settled = false;
|
||||
void task.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
return {
|
||||
abort,
|
||||
patches,
|
||||
task,
|
||||
isSettled: () => settled,
|
||||
};
|
||||
}
|
||||
|
||||
export async function abortStartedAccount(params: {
|
||||
abort: AbortController;
|
||||
task: Promise<unknown>;
|
||||
}) {
|
||||
params.abort.abort();
|
||||
await params.task;
|
||||
}
|
||||
|
||||
export function waitForStartedMocks(...mocks: Array<ReturnType<typeof vi.fn>>) {
|
||||
return async () => {
|
||||
await vi.waitFor(() => {
|
||||
for (const mock of mocks) {
|
||||
expect(mock).toHaveBeenCalledOnce();
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function expectLifecyclePatch(
|
||||
patches: ChannelAccountSnapshot[],
|
||||
expected: Partial<ChannelAccountSnapshot>,
|
||||
) {
|
||||
expect(patches).toContainEqual(expect.objectContaining(expected));
|
||||
}
|
||||
|
||||
export async function expectPendingUntilAbort(params: {
|
||||
waitForStarted: () => Promise<void>;
|
||||
isSettled: () => boolean;
|
||||
abort: AbortController;
|
||||
task: Promise<unknown>;
|
||||
assertBeforeAbort?: () => void;
|
||||
assertAfterAbort?: () => void;
|
||||
}) {
|
||||
await params.waitForStarted();
|
||||
expect(params.isSettled()).toBe(false);
|
||||
params.assertBeforeAbort?.();
|
||||
await abortStartedAccount({ abort: params.abort, task: params.task });
|
||||
params.assertAfterAbort?.();
|
||||
}
|
||||
|
||||
export async function expectStopPendingUntilAbort(params: {
|
||||
waitForStarted: () => Promise<void>;
|
||||
isSettled: () => boolean;
|
||||
abort: AbortController;
|
||||
task: Promise<unknown>;
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
await expectPendingUntilAbort({
|
||||
waitForStarted: params.waitForStarted,
|
||||
isSettled: params.isSettled,
|
||||
abort: params.abort,
|
||||
task: params.task,
|
||||
assertBeforeAbort: () => {
|
||||
expect(params.stop).not.toHaveBeenCalled();
|
||||
},
|
||||
assertAfterAbort: () => {
|
||||
expect(params.stop).toHaveBeenCalledOnce();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { expect } from "vitest";
|
||||
|
||||
export function expectOpenDmPolicyConfigIssue<TAccount>(params: {
|
||||
collectIssues: (accounts: TAccount[]) => Array<{ kind?: string }>;
|
||||
account: TAccount;
|
||||
}) {
|
||||
const issues = params.collectIssues([params.account]);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]?.kind).toBe("config");
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
|
||||
export function createCapturedThinkingConfigStream() {
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const streamFn: StreamFn = (model, _context, options) => {
|
||||
const payload = { config: { thinkingConfig: { thinkingBudget: -1 } } } as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
options?.onPayload?.(payload as never, model as never);
|
||||
capturedPayload = payload;
|
||||
return {} as never;
|
||||
};
|
||||
return {
|
||||
streamFn,
|
||||
getCapturedPayload: () => capturedPayload,
|
||||
};
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Test helper preserves plugin-specific hook API type.
|
||||
export function registerHookHandlersForTest<TApi>(params: {
|
||||
config: Record<string, unknown>;
|
||||
register: (api: TApi) => void;
|
||||
}) {
|
||||
const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
|
||||
const api = {
|
||||
config: params.config,
|
||||
on: (hookName: string, handler: (event: unknown, ctx: unknown) => unknown) => {
|
||||
handlers.set(hookName, handler);
|
||||
},
|
||||
} as TApi;
|
||||
params.register(api);
|
||||
return handlers;
|
||||
}
|
||||
|
||||
export function getRequiredHookHandler(
|
||||
handlers: Map<string, (event: unknown, ctx: unknown) => unknown>,
|
||||
hookName: string,
|
||||
): (event: unknown, ctx: unknown) => unknown {
|
||||
const handler = handlers.get(hookName);
|
||||
if (!handler) {
|
||||
throw new Error(`expected ${hookName} hook handler`);
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,71 +0,0 @@
|
||||
import type { WebFetchProviderPlugin } from "openclaw/plugin-sdk/provider-web-fetch-contract";
|
||||
import {
|
||||
pluginRegistrationContractRegistry,
|
||||
resolveBundledExplicitWebFetchProvidersFromPublicArtifacts,
|
||||
resolveWebFetchProviderContractEntriesForPluginId,
|
||||
} from "openclaw/plugin-sdk/testing";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { installWebFetchProviderContractSuite } from "./provider-contract-suites.js";
|
||||
|
||||
function resolveWebFetchCredentialValue(provider: WebFetchProviderPlugin): unknown {
|
||||
if (provider.requiresCredential === false) {
|
||||
return `${provider.id}-no-key-needed`;
|
||||
}
|
||||
const envVar = provider.envVars.find((entry) => entry.trim().length > 0);
|
||||
if (!envVar) {
|
||||
return `${provider.id}-test`;
|
||||
}
|
||||
return envVar.toLowerCase().includes("api_key") ? `${provider.id}-test` : "sk-test";
|
||||
}
|
||||
|
||||
export function describeWebFetchProviderContracts(pluginId: string) {
|
||||
const providerIds =
|
||||
pluginRegistrationContractRegistry.find((entry) => entry.pluginId === pluginId)
|
||||
?.webFetchProviderIds ?? [];
|
||||
|
||||
const resolveProviders = () => {
|
||||
const publicArtifactProviders = resolveBundledExplicitWebFetchProvidersFromPublicArtifacts({
|
||||
onlyPluginIds: [pluginId],
|
||||
});
|
||||
if (publicArtifactProviders) {
|
||||
return publicArtifactProviders.map((provider) => ({
|
||||
pluginId: provider.pluginId,
|
||||
provider,
|
||||
credentialValue: resolveWebFetchCredentialValue(provider),
|
||||
}));
|
||||
}
|
||||
return resolveWebFetchProviderContractEntriesForPluginId(pluginId);
|
||||
};
|
||||
|
||||
describe(`${pluginId} web fetch provider contract registry load`, () => {
|
||||
it("loads bundled web fetch providers", () => {
|
||||
expect(resolveProviders().length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
for (const providerId of providerIds) {
|
||||
describe(`${pluginId}:${providerId} web fetch contract`, () => {
|
||||
installWebFetchProviderContractSuite({
|
||||
provider: () => {
|
||||
const entry = resolveProviders().find((provider) => provider.provider.id === providerId);
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`web fetch provider contract entry missing for ${pluginId}:${providerId}`,
|
||||
);
|
||||
}
|
||||
return entry.provider;
|
||||
},
|
||||
credentialValue: () => {
|
||||
const entry = resolveProviders().find((provider) => provider.provider.id === providerId);
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`web fetch provider contract entry missing for ${pluginId}:${providerId}`,
|
||||
);
|
||||
}
|
||||
return entry.credentialValue;
|
||||
},
|
||||
pluginId,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import {
|
||||
pluginRegistrationContractRegistry,
|
||||
resolveBundledExplicitWebSearchProvidersFromPublicArtifacts,
|
||||
resolveWebSearchProviderContractEntriesForPluginId,
|
||||
} from "openclaw/plugin-sdk/testing";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { installWebSearchProviderContractSuite } from "./provider-contract-suites.js";
|
||||
|
||||
type WebSearchContractEntry = ReturnType<
|
||||
typeof resolveWebSearchProviderContractEntriesForPluginId
|
||||
>[number];
|
||||
|
||||
function resolveWebSearchCredentialValue(provider: {
|
||||
id: string;
|
||||
requiresCredential?: boolean;
|
||||
envVars: readonly string[];
|
||||
}): unknown {
|
||||
if (provider.requiresCredential === false) {
|
||||
return `${provider.id}-no-key-needed`;
|
||||
}
|
||||
const envVar = provider.envVars.find((entry) => entry.trim().length > 0);
|
||||
if (!envVar) {
|
||||
return `${provider.id}-test`;
|
||||
}
|
||||
if (envVar === "OPENROUTER_API_KEY") {
|
||||
return "openrouter-test";
|
||||
}
|
||||
return envVar.toLowerCase().includes("api_key") ? `${provider.id}-test` : "sk-test";
|
||||
}
|
||||
|
||||
export function describeWebSearchProviderContracts(pluginId: string) {
|
||||
const providerIds =
|
||||
pluginRegistrationContractRegistry.find((entry) => entry.pluginId === pluginId)
|
||||
?.webSearchProviderIds ?? [];
|
||||
|
||||
const resolveProviders = (): WebSearchContractEntry[] => {
|
||||
const publicArtifactProviders = resolveBundledExplicitWebSearchProvidersFromPublicArtifacts({
|
||||
onlyPluginIds: [pluginId],
|
||||
});
|
||||
if (publicArtifactProviders) {
|
||||
return publicArtifactProviders.map((provider) => ({
|
||||
pluginId: provider.pluginId,
|
||||
provider,
|
||||
credentialValue: resolveWebSearchCredentialValue(provider),
|
||||
}));
|
||||
}
|
||||
return resolveWebSearchProviderContractEntriesForPluginId(pluginId);
|
||||
};
|
||||
|
||||
describe(`${pluginId} web search provider contract registry load`, () => {
|
||||
it("loads bundled web search providers", () => {
|
||||
expect(resolveProviders().length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
for (const providerId of providerIds) {
|
||||
describe(`${pluginId}:${providerId} web search contract`, () => {
|
||||
installWebSearchProviderContractSuite({
|
||||
provider: () => {
|
||||
const entry = resolveProviders().find((entry) => entry.provider.id === providerId);
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`web search provider contract entry missing for ${pluginId}:${providerId}`,
|
||||
);
|
||||
}
|
||||
return entry.provider;
|
||||
},
|
||||
credentialValue: () => {
|
||||
const entry = resolveProviders().find((entry) => entry.provider.id === providerId);
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`web search provider contract entry missing for ${pluginId}:${providerId}`,
|
||||
);
|
||||
}
|
||||
return entry.credentialValue;
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -270,7 +270,7 @@ describe("scripts/test-projects changed-target routing", () => {
|
||||
it("routes precise plugin contract helpers without broad-running every shard", () => {
|
||||
expect(
|
||||
resolveChangedTargetArgs(["--changed", "origin/main"], process.cwd(), () => [
|
||||
"test/helpers/plugins/tts-contract-suites.ts",
|
||||
"src/plugins/contracts/tts-contract-suites.ts",
|
||||
]),
|
||||
).toEqual([
|
||||
"src/plugins/contracts/core-extension-facade-boundary.test.ts",
|
||||
|
||||
Reference in New Issue
Block a user