mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
refactor: move extension markers into manifests
This commit is contained in:
@@ -155,6 +155,7 @@ Those belong in your plugin code and `package.json`.
|
||||
| `modelSupport` | No | `object` | Manifest-owned shorthand model-family metadata used to auto-load the plugin before runtime. |
|
||||
| `cliBackends` | No | `string[]` | CLI inference backend ids owned by this plugin. Used for startup auto-activation from explicit config refs. |
|
||||
| `syntheticAuthRefs` | No | `string[]` | Provider or CLI backend refs whose plugin-owned synthetic auth hook should be probed during cold model discovery before runtime loads. |
|
||||
| `nonSecretAuthMarkers` | No | `string[]` | Bundled-plugin-owned placeholder API key values that represent non-secret local, OAuth, or ambient credential state. |
|
||||
| `commandAliases` | No | `object[]` | Command names owned by this plugin that should produce plugin-aware config and CLI diagnostics before runtime loads. |
|
||||
| `providerAuthEnvVars` | No | `Record<string, string[]>` | Cheap provider-auth env metadata that OpenClaw can inspect without loading plugin code. |
|
||||
| `providerAuthAliases` | No | `Record<string, string>` | Provider ids that should reuse another provider id for auth lookup, for example a coding provider that shares the base provider API key and auth profiles. |
|
||||
@@ -605,6 +606,10 @@ See [Configuration reference](/gateway/configuration) for the full `plugins.*` s
|
||||
auth hooks that must be visible to cold model discovery before the runtime
|
||||
registry exists. Only list refs whose runtime provider or CLI backend actually
|
||||
implements `resolveSyntheticAuth`.
|
||||
- `nonSecretAuthMarkers` is the cheap metadata path for bundled plugin-owned
|
||||
placeholder API keys such as local, OAuth, or ambient credential markers.
|
||||
Core treats these as non-secrets for auth display and secret audits without
|
||||
hardcoding the owning provider.
|
||||
- `channelEnvVars` is the cheap metadata path for shell-env fallback, setup
|
||||
prompts, and similar channel surfaces that should not boot plugin runtime
|
||||
just to inspect env names.
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"enabledByDefault": true,
|
||||
"providers": ["anthropic-vertex"],
|
||||
"providerDiscoveryEntry": "./provider-discovery.ts",
|
||||
"nonSecretAuthMarkers": ["gcp-vertex-credentials"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"id": "lmstudio",
|
||||
"enabledByDefault": true,
|
||||
"providers": ["lmstudio"],
|
||||
"nonSecretAuthMarkers": ["lmstudio-local"],
|
||||
"providerAuthEnvVars": {
|
||||
"lmstudio": ["LM_API_TOKEN"]
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"legacyPluginIds": ["minimax-portal-auth"],
|
||||
"providers": ["minimax", "minimax-portal"],
|
||||
"autoEnableWhenConfiguredProviders": ["minimax", "minimax-portal"],
|
||||
"nonSecretAuthMarkers": ["minimax-oauth"],
|
||||
"providerAuthEnvVars": {
|
||||
"minimax": ["MINIMAX_API_KEY"],
|
||||
"minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"providers": ["ollama"],
|
||||
"providerDiscoveryEntry": "./provider-discovery.ts",
|
||||
"syntheticAuthRefs": ["ollama"],
|
||||
"nonSecretAuthMarkers": ["ollama-local"],
|
||||
"providerAuthEnvVars": {
|
||||
"ollama": ["OLLAMA_API_KEY"]
|
||||
},
|
||||
|
||||
@@ -4,6 +4,26 @@ import { describe, expect, it } from "vitest";
|
||||
import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
|
||||
import plugin from "./index.js";
|
||||
|
||||
function createProviderModel(overrides: {
|
||||
id: string;
|
||||
api?: string;
|
||||
baseUrl?: string;
|
||||
provider?: string;
|
||||
}) {
|
||||
return {
|
||||
id: overrides.id,
|
||||
name: overrides.id,
|
||||
api: overrides.api ?? "openai-completions",
|
||||
provider: overrides.provider ?? "xai",
|
||||
baseUrl: overrides.baseUrl ?? "https://api.x.ai/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 8_192,
|
||||
};
|
||||
}
|
||||
|
||||
describe("xai provider plugin", () => {
|
||||
it("owns replay policy for xAI OpenAI-compatible transports", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
@@ -112,4 +132,77 @@ describe("xai provider plugin", () => {
|
||||
} as never),
|
||||
).toBe(explicit);
|
||||
});
|
||||
|
||||
it("owns forward-compatible Grok model resolution", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
||||
expect(
|
||||
provider.resolveDynamicModel?.({
|
||||
provider: "xai",
|
||||
modelId: "grok-4-1-fast-reasoning",
|
||||
modelRegistry: { find: () => null } as never,
|
||||
providerConfig: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
},
|
||||
} as never),
|
||||
).toMatchObject({
|
||||
id: "grok-4-1-fast-reasoning",
|
||||
provider: "xai",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
reasoning: true,
|
||||
contextWindow: 2_000_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks modern Grok refs without accepting multi-agent ids", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
||||
expect(
|
||||
provider.isModernModelRef?.({
|
||||
provider: "xai",
|
||||
modelId: "grok-4-1-fast-reasoning",
|
||||
} as never),
|
||||
).toBe(true);
|
||||
expect(
|
||||
provider.isModernModelRef?.({
|
||||
provider: "xai",
|
||||
modelId: "grok-4.20-multi-agent-experimental-beta-0304",
|
||||
} as never),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("owns xai compat flags for direct and downstream routed models", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
||||
expect(
|
||||
provider.normalizeResolvedModel?.({
|
||||
provider: "xai",
|
||||
modelId: "grok-4-1-fast",
|
||||
model: createProviderModel({ id: "grok-4-1-fast" }),
|
||||
} as never),
|
||||
).toMatchObject({
|
||||
compat: {
|
||||
toolSchemaProfile: "xai",
|
||||
nativeWebSearchTool: true,
|
||||
toolCallArgumentsEncoding: "html-entities",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
provider.contributeResolvedModelCompat?.({
|
||||
provider: "openrouter",
|
||||
modelId: "x-ai/grok-4-1-fast",
|
||||
model: createProviderModel({
|
||||
id: "x-ai/grok-4-1-fast",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
}),
|
||||
} as never),
|
||||
).toMatchObject({
|
||||
toolSchemaProfile: "xai",
|
||||
nativeWebSearchTool: true,
|
||||
toolCallArgumentsEncoding: "html-entities",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ let GCP_VERTEX_CREDENTIALS_MARKER: typeof import("./model-auth-markers.js").GCP_
|
||||
let NON_ENV_SECRETREF_MARKER: typeof import("./model-auth-markers.js").NON_ENV_SECRETREF_MARKER;
|
||||
let isKnownEnvApiKeyMarker: typeof import("./model-auth-markers.js").isKnownEnvApiKeyMarker;
|
||||
let isNonSecretApiKeyMarker: typeof import("./model-auth-markers.js").isNonSecretApiKeyMarker;
|
||||
let listKnownNonSecretApiKeyMarkers: typeof import("./model-auth-markers.js").listKnownNonSecretApiKeyMarkers;
|
||||
let resolveOAuthApiKeyMarker: typeof import("./model-auth-markers.js").resolveOAuthApiKeyMarker;
|
||||
let manifestEnvSnapshot: ReturnType<typeof captureEnv> | undefined;
|
||||
|
||||
@@ -42,6 +43,7 @@ async function loadMarkerModules() {
|
||||
NON_ENV_SECRETREF_MARKER = markersModule.NON_ENV_SECRETREF_MARKER;
|
||||
isKnownEnvApiKeyMarker = markersModule.isKnownEnvApiKeyMarker;
|
||||
isNonSecretApiKeyMarker = markersModule.isNonSecretApiKeyMarker;
|
||||
listKnownNonSecretApiKeyMarkers = markersModule.listKnownNonSecretApiKeyMarkers;
|
||||
resolveOAuthApiKeyMarker = markersModule.resolveOAuthApiKeyMarker;
|
||||
}
|
||||
|
||||
@@ -66,9 +68,21 @@ describe("model auth markers", () => {
|
||||
expect(isNonSecretApiKeyMarker(NON_ENV_SECRETREF_MARKER)).toBe(true);
|
||||
expect(isNonSecretApiKeyMarker(resolveOAuthApiKeyMarker("chutes"))).toBe(true);
|
||||
expect(isNonSecretApiKeyMarker("ollama-local")).toBe(true);
|
||||
expect(isNonSecretApiKeyMarker("lmstudio-local")).toBe(true);
|
||||
expect(isNonSecretApiKeyMarker(GCP_VERTEX_CREDENTIALS_MARKER)).toBe(true);
|
||||
});
|
||||
|
||||
it("reads bundled plugin-owned non-secret markers from manifests", () => {
|
||||
expect(listKnownNonSecretApiKeyMarkers()).toEqual(
|
||||
expect.arrayContaining([
|
||||
"gcp-vertex-credentials",
|
||||
"lmstudio-local",
|
||||
"minimax-oauth",
|
||||
"ollama-local",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat removed provider markers as active auth markers", () => {
|
||||
expect(isNonSecretApiKeyMarker("qwen-oauth")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SecretRefSource } from "../config/types.secrets.js";
|
||||
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
|
||||
import { listKnownProviderEnvApiKeyNames } from "./model-auth-env-vars.js";
|
||||
|
||||
export const MINIMAX_OAUTH_MARKER = "minimax-oauth";
|
||||
@@ -14,6 +15,10 @@ const AWS_SDK_ENV_MARKERS = new Set([
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_PROFILE",
|
||||
]);
|
||||
const CORE_NON_SECRET_API_KEY_MARKERS = [
|
||||
CUSTOM_LOCAL_AUTH_MARKER,
|
||||
NON_ENV_SECRETREF_MARKER,
|
||||
] as const;
|
||||
|
||||
// Legacy marker names kept for backward compatibility with existing models.json files.
|
||||
const LEGACY_ENV_API_KEY_MARKERS = [
|
||||
@@ -35,6 +40,13 @@ function listKnownEnvApiKeyMarkers(): Set<string> {
|
||||
]);
|
||||
}
|
||||
|
||||
export function listKnownNonSecretApiKeyMarkers(): string[] {
|
||||
const bundledMarkers = loadPluginManifestRegistry({ cache: true }).plugins.flatMap((plugin) =>
|
||||
plugin.origin === "bundled" ? (plugin.nonSecretAuthMarkers ?? []) : [],
|
||||
);
|
||||
return [...new Set([...CORE_NON_SECRET_API_KEY_MARKERS, ...bundledMarkers])];
|
||||
}
|
||||
|
||||
export function isAwsSdkAuthMarker(value: string): boolean {
|
||||
return AWS_SDK_ENV_MARKERS.has(value.trim());
|
||||
}
|
||||
@@ -80,12 +92,8 @@ export function isNonSecretApiKeyMarker(
|
||||
return false;
|
||||
}
|
||||
const isKnownMarker =
|
||||
trimmed === MINIMAX_OAUTH_MARKER ||
|
||||
isOAuthApiKeyMarker(trimmed) ||
|
||||
trimmed === OLLAMA_LOCAL_AUTH_MARKER ||
|
||||
trimmed === CUSTOM_LOCAL_AUTH_MARKER ||
|
||||
trimmed === GCP_VERTEX_CREDENTIALS_MARKER ||
|
||||
trimmed === NON_ENV_SECRETREF_MARKER ||
|
||||
listKnownNonSecretApiKeyMarkers().includes(trimmed) ||
|
||||
isAwsSdkAuthMarker(trimmed);
|
||||
if (isKnownMarker) {
|
||||
return true;
|
||||
|
||||
@@ -2,10 +2,10 @@ import { z } from "zod";
|
||||
import { isSafeScpRemoteHost } from "../infra/scp-host.js";
|
||||
import { isValidInboundPathRootPattern } from "../media/inbound-path-policy.js";
|
||||
import {
|
||||
normalizeTelegramCommandDescription,
|
||||
normalizeTelegramCommandName,
|
||||
resolveTelegramCustomCommands,
|
||||
} from "../plugin-sdk/telegram-command-config.js";
|
||||
normalizeCommandDescription,
|
||||
normalizeSlashCommandName,
|
||||
resolveCustomCommands,
|
||||
} from "../shared/custom-command-config.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import { ToolPolicySchema } from "./zod-schema.agent-runtime.js";
|
||||
import {
|
||||
@@ -105,6 +105,12 @@ const SlackCapabilitiesSchema = z.union([
|
||||
]);
|
||||
|
||||
const TelegramErrorPolicySchema = z.enum(["always", "once", "silent"]).optional();
|
||||
const TelegramCommandNamePattern = /^[a-z0-9_]{1,32}$/;
|
||||
const TelegramCustomCommandConfig = {
|
||||
label: "Telegram",
|
||||
pattern: TelegramCommandNamePattern,
|
||||
patternDescription: "use a-z, 0-9, underscore; max 32 chars",
|
||||
} as const;
|
||||
export const TelegramTopicSchema = z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
@@ -170,8 +176,8 @@ export const TelegramDirectSchema = z
|
||||
|
||||
const TelegramCustomCommandSchema = z
|
||||
.object({
|
||||
command: z.string().overwrite(normalizeTelegramCommandName),
|
||||
description: z.string().overwrite(normalizeTelegramCommandDescription),
|
||||
command: z.string().overwrite(normalizeSlashCommandName),
|
||||
description: z.string().overwrite(normalizeCommandDescription),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -182,10 +188,11 @@ const validateTelegramCustomCommands = (
|
||||
if (!value.customCommands || value.customCommands.length === 0) {
|
||||
return;
|
||||
}
|
||||
const { issues } = resolveTelegramCustomCommands({
|
||||
const { issues } = resolveCustomCommands({
|
||||
commands: value.customCommands,
|
||||
checkReserved: false,
|
||||
checkDuplicates: false,
|
||||
config: TelegramCustomCommandConfig,
|
||||
});
|
||||
for (const issue of issues) {
|
||||
ctx.addIssue({
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
|
||||
import {
|
||||
normalizeCommandDescription,
|
||||
normalizeSlashCommandName,
|
||||
resolveCustomCommands,
|
||||
} from "../shared/custom-command-config.js";
|
||||
|
||||
export type TelegramCustomCommandInput = {
|
||||
command?: string | null;
|
||||
@@ -11,18 +15,18 @@ export type TelegramCustomCommandIssue = {
|
||||
message: string;
|
||||
};
|
||||
const TELEGRAM_COMMAND_NAME_PATTERN_VALUE = /^[a-z0-9_]{1,32}$/;
|
||||
const TELEGRAM_CUSTOM_COMMAND_CONFIG = {
|
||||
label: "Telegram",
|
||||
pattern: TELEGRAM_COMMAND_NAME_PATTERN_VALUE,
|
||||
patternDescription: "use a-z, 0-9, underscore; max 32 chars",
|
||||
} as const;
|
||||
|
||||
function normalizeTelegramCommandNameImpl(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const withoutSlash = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed;
|
||||
return normalizeLowercaseStringOrEmpty(withoutSlash).replace(/-/g, "_");
|
||||
return normalizeSlashCommandName(value);
|
||||
}
|
||||
|
||||
function normalizeTelegramCommandDescriptionImpl(value: string): string {
|
||||
return value.trim();
|
||||
return normalizeCommandDescription(value);
|
||||
}
|
||||
|
||||
function resolveTelegramCustomCommandsImpl(params: {
|
||||
@@ -34,65 +38,10 @@ function resolveTelegramCustomCommandsImpl(params: {
|
||||
commands: Array<{ command: string; description: string }>;
|
||||
issues: TelegramCustomCommandIssue[];
|
||||
} {
|
||||
const entries = Array.isArray(params.commands) ? params.commands : [];
|
||||
const reserved = params.reservedCommands ?? new Set<string>();
|
||||
const checkReserved = params.checkReserved !== false;
|
||||
const checkDuplicates = params.checkDuplicates !== false;
|
||||
const seen = new Set<string>();
|
||||
const resolved: Array<{ command: string; description: string }> = [];
|
||||
const issues: TelegramCustomCommandIssue[] = [];
|
||||
|
||||
for (let index = 0; index < entries.length; index += 1) {
|
||||
const entry = entries[index];
|
||||
const normalized = normalizeTelegramCommandNameImpl(entry?.command ?? "");
|
||||
if (!normalized) {
|
||||
issues.push({
|
||||
index,
|
||||
field: "command",
|
||||
message: "Telegram custom command is missing a command name.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!TELEGRAM_COMMAND_NAME_PATTERN_VALUE.test(normalized)) {
|
||||
issues.push({
|
||||
index,
|
||||
field: "command",
|
||||
message: `Telegram custom command "/${normalized}" is invalid (use a-z, 0-9, underscore; max 32 chars).`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (checkReserved && reserved.has(normalized)) {
|
||||
issues.push({
|
||||
index,
|
||||
field: "command",
|
||||
message: `Telegram custom command "/${normalized}" conflicts with a native command.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (checkDuplicates && seen.has(normalized)) {
|
||||
issues.push({
|
||||
index,
|
||||
field: "command",
|
||||
message: `Telegram custom command "/${normalized}" is duplicated.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const description = normalizeTelegramCommandDescriptionImpl(entry?.description ?? "");
|
||||
if (!description) {
|
||||
issues.push({
|
||||
index,
|
||||
field: "description",
|
||||
message: `Telegram custom command "/${normalized}" is missing a description.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (checkDuplicates) {
|
||||
seen.add(normalized);
|
||||
}
|
||||
resolved.push({ command: normalized, description });
|
||||
}
|
||||
|
||||
return { commands: resolved, issues };
|
||||
return resolveCustomCommands({
|
||||
...params,
|
||||
config: TELEGRAM_CUSTOM_COMMAND_CONFIG,
|
||||
});
|
||||
}
|
||||
|
||||
export function getTelegramCommandNamePattern(): RegExp {
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
describeGithubCopilotProviderDiscoveryContract,
|
||||
describeMinimaxProviderDiscoveryContract,
|
||||
describeModelStudioProviderDiscoveryContract,
|
||||
describeOllamaProviderDiscoveryContract,
|
||||
describeSglangProviderDiscoveryContract,
|
||||
describeVllmProviderDiscoveryContract,
|
||||
} from "../../../test/helpers/plugins/provider-discovery-contract.js";
|
||||
@@ -12,6 +11,5 @@ describeCloudflareAiGatewayProviderDiscoveryContract();
|
||||
describeGithubCopilotProviderDiscoveryContract();
|
||||
describeMinimaxProviderDiscoveryContract();
|
||||
describeModelStudioProviderDiscoveryContract();
|
||||
describeOllamaProviderDiscoveryContract();
|
||||
describeSglangProviderDiscoveryContract();
|
||||
describeVllmProviderDiscoveryContract();
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
describeOpenAIProviderRuntimeContract,
|
||||
describeOpenRouterProviderRuntimeContract,
|
||||
describeVeniceProviderRuntimeContract,
|
||||
describeXAIProviderRuntimeContract,
|
||||
describeZAIProviderRuntimeContract,
|
||||
} from "../../../test/helpers/plugins/provider-runtime-contract.js";
|
||||
|
||||
@@ -15,5 +14,4 @@ describeGoogleProviderRuntimeContract();
|
||||
describeOpenAIProviderRuntimeContract();
|
||||
describeOpenRouterProviderRuntimeContract();
|
||||
describeVeniceProviderRuntimeContract();
|
||||
describeXAIProviderRuntimeContract();
|
||||
describeZAIProviderRuntimeContract();
|
||||
|
||||
@@ -383,6 +383,7 @@ describe("loadPluginManifestRegistry", () => {
|
||||
openai: ["OPENAI_API_KEY"],
|
||||
},
|
||||
syntheticAuthRefs: ["openai-cli"],
|
||||
nonSecretAuthMarkers: ["openai-cli"],
|
||||
providerAuthAliases: {
|
||||
"openai-codex": "openai",
|
||||
},
|
||||
@@ -409,6 +410,7 @@ describe("loadPluginManifestRegistry", () => {
|
||||
openai: ["OPENAI_API_KEY"],
|
||||
});
|
||||
expect(registry.plugins[0]?.syntheticAuthRefs).toEqual(["openai-cli"]);
|
||||
expect(registry.plugins[0]?.nonSecretAuthMarkers).toEqual(["openai-cli"]);
|
||||
expect(registry.plugins[0]?.providerAuthAliases).toEqual({
|
||||
"openai-codex": "openai",
|
||||
});
|
||||
|
||||
@@ -87,6 +87,7 @@ export type PluginManifestRecord = {
|
||||
modelSupport?: PluginManifestModelSupport;
|
||||
cliBackends: string[];
|
||||
syntheticAuthRefs?: string[];
|
||||
nonSecretAuthMarkers?: string[];
|
||||
commandAliases?: PluginManifestCommandAlias[];
|
||||
providerAuthEnvVars?: Record<string, string[]>;
|
||||
providerAuthAliases?: Record<string, string>;
|
||||
@@ -330,6 +331,7 @@ function buildRecord(params: {
|
||||
modelSupport: params.manifest.modelSupport,
|
||||
cliBackends: params.manifest.cliBackends ?? [],
|
||||
syntheticAuthRefs: params.manifest.syntheticAuthRefs ?? [],
|
||||
nonSecretAuthMarkers: params.manifest.nonSecretAuthMarkers ?? [],
|
||||
commandAliases: params.manifest.commandAliases,
|
||||
providerAuthEnvVars: params.manifest.providerAuthEnvVars,
|
||||
providerAuthAliases: params.manifest.providerAuthAliases,
|
||||
@@ -401,6 +403,7 @@ function buildBundleRecord(params: {
|
||||
providers: [],
|
||||
cliBackends: [],
|
||||
syntheticAuthRefs: [],
|
||||
nonSecretAuthMarkers: [],
|
||||
skills: params.manifest.skills ?? [],
|
||||
settingsFiles: params.manifest.settingsFiles ?? [],
|
||||
hooks: params.manifest.hooks ?? [],
|
||||
|
||||
@@ -168,6 +168,11 @@ export type PluginManifest = {
|
||||
* be probed during cold model discovery before the runtime registry exists.
|
||||
*/
|
||||
syntheticAuthRefs?: string[];
|
||||
/**
|
||||
* Bundled-plugin-owned placeholder API key values that represent non-secret
|
||||
* local, OAuth, or ambient credential state.
|
||||
*/
|
||||
nonSecretAuthMarkers?: string[];
|
||||
/**
|
||||
* Plugin-owned command aliases that should resolve to this plugin during
|
||||
* config diagnostics before runtime loads.
|
||||
@@ -707,6 +712,7 @@ export function loadPluginManifest(
|
||||
const modelSupport = normalizeManifestModelSupport(raw.modelSupport);
|
||||
const cliBackends = normalizeTrimmedStringList(raw.cliBackends);
|
||||
const syntheticAuthRefs = normalizeTrimmedStringList(raw.syntheticAuthRefs);
|
||||
const nonSecretAuthMarkers = normalizeTrimmedStringList(raw.nonSecretAuthMarkers);
|
||||
const commandAliases = normalizeManifestCommandAliases(raw.commandAliases);
|
||||
const providerAuthEnvVars = normalizeStringListRecord(raw.providerAuthEnvVars);
|
||||
const providerAuthAliases = normalizeStringRecord(raw.providerAuthAliases);
|
||||
@@ -742,6 +748,7 @@ export function loadPluginManifest(
|
||||
modelSupport,
|
||||
cliBackends,
|
||||
syntheticAuthRefs,
|
||||
nonSecretAuthMarkers,
|
||||
commandAliases,
|
||||
providerAuthEnvVars,
|
||||
providerAuthAliases,
|
||||
|
||||
107
src/shared/custom-command-config.ts
Normal file
107
src/shared/custom-command-config.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
|
||||
|
||||
export type CustomCommandInput = {
|
||||
command?: string | null;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type CustomCommandIssue = {
|
||||
index: number;
|
||||
field: "command" | "description";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type CustomCommandConfig = {
|
||||
label: string;
|
||||
pattern: RegExp;
|
||||
patternDescription: string;
|
||||
prefix?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_PREFIX = "/";
|
||||
|
||||
export function normalizeSlashCommandName(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const withoutSlash = trimmed.startsWith(DEFAULT_PREFIX) ? trimmed.slice(1) : trimmed;
|
||||
return normalizeLowercaseStringOrEmpty(withoutSlash).replace(/-/g, "_");
|
||||
}
|
||||
|
||||
export function normalizeCommandDescription(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function resolveCustomCommands(params: {
|
||||
commands?: CustomCommandInput[] | null;
|
||||
reservedCommands?: Set<string>;
|
||||
checkReserved?: boolean;
|
||||
checkDuplicates?: boolean;
|
||||
config: CustomCommandConfig;
|
||||
}): {
|
||||
commands: Array<{ command: string; description: string }>;
|
||||
issues: CustomCommandIssue[];
|
||||
} {
|
||||
const entries = Array.isArray(params.commands) ? params.commands : [];
|
||||
const reserved = params.reservedCommands ?? new Set<string>();
|
||||
const checkReserved = params.checkReserved !== false;
|
||||
const checkDuplicates = params.checkDuplicates !== false;
|
||||
const seen = new Set<string>();
|
||||
const resolved: Array<{ command: string; description: string }> = [];
|
||||
const issues: CustomCommandIssue[] = [];
|
||||
const label = params.config.label;
|
||||
const prefix = params.config.prefix ?? DEFAULT_PREFIX;
|
||||
|
||||
for (let index = 0; index < entries.length; index += 1) {
|
||||
const entry = entries[index];
|
||||
const normalized = normalizeSlashCommandName(entry?.command ?? "");
|
||||
if (!normalized) {
|
||||
issues.push({
|
||||
index,
|
||||
field: "command",
|
||||
message: `${label} custom command is missing a command name.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!params.config.pattern.test(normalized)) {
|
||||
issues.push({
|
||||
index,
|
||||
field: "command",
|
||||
message: `${label} custom command "${prefix}${normalized}" is invalid (${params.config.patternDescription}).`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (checkReserved && reserved.has(normalized)) {
|
||||
issues.push({
|
||||
index,
|
||||
field: "command",
|
||||
message: `${label} custom command "${prefix}${normalized}" conflicts with a native command.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (checkDuplicates && seen.has(normalized)) {
|
||||
issues.push({
|
||||
index,
|
||||
field: "command",
|
||||
message: `${label} custom command "${prefix}${normalized}" is duplicated.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const description = normalizeCommandDescription(entry?.description ?? "");
|
||||
if (!description) {
|
||||
issues.push({
|
||||
index,
|
||||
field: "description",
|
||||
message: `${label} custom command "${prefix}${normalized}" is missing a description.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (checkDuplicates) {
|
||||
seen.add(normalized);
|
||||
}
|
||||
resolved.push({ command: normalized, description });
|
||||
}
|
||||
|
||||
return { commands: resolved, issues };
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AuthProfileStore } from "../../../src/agents/auth-profiles/types.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { ModelDefinitionConfig } from "../../../src/config/types.models.js";
|
||||
import {
|
||||
resolveBundledPluginPublicModulePath,
|
||||
resolveRelativeBundledPluginPublicModuleId,
|
||||
@@ -9,7 +8,6 @@ import {
|
||||
import { registerProviders, requireProvider } from "./contracts-testkit.js";
|
||||
|
||||
const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn());
|
||||
const buildOllamaProviderMock = vi.hoisted(() => vi.fn());
|
||||
const buildVllmProviderMock = vi.hoisted(() => vi.fn());
|
||||
const buildSglangProviderMock = vi.hoisted(() => vi.fn());
|
||||
const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn());
|
||||
@@ -39,15 +37,6 @@ const bundledProviderModules = {
|
||||
pluginId: "qwen",
|
||||
artifactBasename: "index.js",
|
||||
}),
|
||||
ollamaApiModuleId: resolveBundledPluginPublicModulePath({
|
||||
pluginId: "ollama",
|
||||
artifactBasename: "api.js",
|
||||
}),
|
||||
ollamaIndexModuleUrl: resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "ollama",
|
||||
artifactBasename: "index.js",
|
||||
}),
|
||||
sglangApiModuleId: resolveBundledPluginPublicModulePath({
|
||||
pluginId: "sglang",
|
||||
artifactBasename: "api.js",
|
||||
@@ -73,7 +62,6 @@ type ProviderHandle = Awaited<ReturnType<typeof requireProvider>>;
|
||||
type DiscoveryState = {
|
||||
runProviderCatalog: typeof import("../../../src/plugins/provider-discovery.js").runProviderCatalog;
|
||||
githubCopilotProvider?: ProviderHandle;
|
||||
ollamaProvider?: ProviderHandle;
|
||||
vllmProvider?: ProviderHandle;
|
||||
sglangProvider?: ProviderHandle;
|
||||
minimaxProvider?: ProviderHandle;
|
||||
@@ -84,30 +72,12 @@ type DiscoveryState = {
|
||||
|
||||
type BundledProviderUnderTest =
|
||||
| "github-copilot"
|
||||
| "ollama"
|
||||
| "vllm"
|
||||
| "sglang"
|
||||
| "minimax"
|
||||
| "modelstudio"
|
||||
| "cloudflare-ai-gateway";
|
||||
|
||||
function createModelConfig(id: string, name = id): ModelDefinitionConfig {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 8_192,
|
||||
};
|
||||
}
|
||||
|
||||
function setRuntimeAuthStore(store?: AuthProfileStore) {
|
||||
const resolvedStore = store ?? {
|
||||
version: 1,
|
||||
@@ -226,15 +196,6 @@ function installDiscoveryHooks(
|
||||
resolveCopilotApiToken: resolveCopilotApiTokenMock,
|
||||
};
|
||||
});
|
||||
vi.doMock(bundledProviderModules.ollamaApiModuleId, async () => {
|
||||
return {
|
||||
OLLAMA_DEFAULT_BASE_URL: "http://127.0.0.1:11434",
|
||||
buildOllamaProvider: (...args: unknown[]) => buildOllamaProviderMock(...args),
|
||||
configureOllamaNonInteractive: vi.fn(),
|
||||
ensureOllamaModelPulled: vi.fn(),
|
||||
promptAndConfigureOllama: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.doMock(bundledProviderModules.vllmApiModuleId, async () => {
|
||||
return {
|
||||
VLLM_DEFAULT_API_KEY_ENV_VAR: "VLLM_API_KEY",
|
||||
@@ -266,13 +227,6 @@ function installDiscoveryHooks(
|
||||
);
|
||||
}
|
||||
|
||||
if (providerIds.includes("ollama")) {
|
||||
const { default: ollamaPlugin } = await importBundledProviderPlugin<{
|
||||
default: Parameters<typeof registerProviders>[0];
|
||||
}>(bundledProviderModules.ollamaIndexModuleUrl);
|
||||
state.ollamaProvider = requireProvider(await registerProviders(ollamaPlugin), "ollama");
|
||||
}
|
||||
|
||||
if (providerIds.includes("vllm")) {
|
||||
const { default: vllmPlugin } = await importBundledProviderPlugin<{
|
||||
default: Parameters<typeof registerProviders>[0];
|
||||
@@ -321,7 +275,6 @@ function installDiscoveryHooks(
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
resolveCopilotApiTokenMock.mockReset();
|
||||
buildOllamaProviderMock.mockReset();
|
||||
buildVllmProviderMock.mockReset();
|
||||
buildSglangProviderMock.mockReset();
|
||||
ensureAuthProfileStoreMock.mockReset();
|
||||
@@ -388,108 +341,6 @@ export function describeGithubCopilotProviderDiscoveryContract() {
|
||||
});
|
||||
}
|
||||
|
||||
export function describeOllamaProviderDiscoveryContract() {
|
||||
const state = {} as DiscoveryState;
|
||||
|
||||
describe("ollama provider discovery contract", () => {
|
||||
installDiscoveryHooks(state, ["ollama"]);
|
||||
|
||||
it("keeps explicit catalog normalization provider-owned", async () => {
|
||||
await expect(
|
||||
state.runProviderCatalog({
|
||||
provider: state.ollamaProvider!,
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://ollama-host:11434/v1/",
|
||||
models: [createModelConfig("llama3.2")],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({ apiKey: undefined }),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: undefined,
|
||||
discoveryApiKey: undefined,
|
||||
mode: "none",
|
||||
source: "none",
|
||||
}),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
provider: {
|
||||
baseUrl: "http://ollama-host:11434",
|
||||
api: "ollama",
|
||||
apiKey: "ollama-local",
|
||||
models: [createModelConfig("llama3.2")],
|
||||
},
|
||||
});
|
||||
expect(buildOllamaProviderMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps empty autodiscovery disabled without keys or explicit config", async () => {
|
||||
buildOllamaProviderMock.mockResolvedValueOnce({
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
runCatalog(state, {
|
||||
provider: state.ollamaProvider!,
|
||||
config: {},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({ apiKey: undefined }),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: undefined,
|
||||
discoveryApiKey: undefined,
|
||||
mode: "none",
|
||||
source: "none",
|
||||
}),
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
expect(buildOllamaProviderMock).toHaveBeenCalledWith(undefined, { quiet: true });
|
||||
});
|
||||
|
||||
it("keeps empty default-ish provider stubs on the quiet ambient path", async () => {
|
||||
buildOllamaProviderMock.mockResolvedValueOnce({
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
runCatalog(state, {
|
||||
provider: state.ollamaProvider!,
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({ apiKey: undefined }),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: undefined,
|
||||
discoveryApiKey: undefined,
|
||||
mode: "none",
|
||||
source: "none",
|
||||
}),
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
expect(buildOllamaProviderMock).toHaveBeenCalledWith("http://127.0.0.1:11434", {
|
||||
quiet: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeVllmProviderDiscoveryContract() {
|
||||
const state = {} as DiscoveryState;
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ProviderPlugin, ProviderRuntimeModel } from "../../../src/plugins/types.js";
|
||||
import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js";
|
||||
@@ -52,11 +51,6 @@ const providerRuntimeContractModules = {
|
||||
pluginId: "venice",
|
||||
artifactBasename: "index.js",
|
||||
}),
|
||||
xAIIndexModuleId: resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "xai",
|
||||
artifactBasename: "index.js",
|
||||
}),
|
||||
zaiIndexModuleId: resolveRelativeBundledPluginPublicModuleId({
|
||||
fromModuleUrl: import.meta.url,
|
||||
pluginId: "zai",
|
||||
@@ -156,15 +150,6 @@ const PROVIDER_RUNTIME_CONTRACT_FIXTURES: readonly ProviderRuntimeContractFixtur
|
||||
default: Parameters<typeof registerProviderPlugin>[0]["plugin"];
|
||||
}>(providerRuntimeContractModules.veniceIndexModuleId),
|
||||
},
|
||||
{
|
||||
providerIds: ["xai"],
|
||||
pluginId: "xai",
|
||||
name: "xAI",
|
||||
load: async () =>
|
||||
await importBundledProviderPlugin<{
|
||||
default: Parameters<typeof registerProviderPlugin>[0]["plugin"];
|
||||
}>(providerRuntimeContractModules.xAIIndexModuleId),
|
||||
},
|
||||
{
|
||||
providerIds: ["zai"],
|
||||
pluginId: "zai",
|
||||
@@ -717,159 +702,6 @@ export function describeOpenAIProviderRuntimeContract() {
|
||||
});
|
||||
}
|
||||
|
||||
export function describeXAIProviderRuntimeContract() {
|
||||
describe("xai provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => {
|
||||
installRuntimeHooks();
|
||||
|
||||
it("owns Grok forward-compat resolution for newer fast models", () => {
|
||||
const provider = requireProviderContractProvider("xai");
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "xai",
|
||||
modelId: "grok-4-1-fast-reasoning",
|
||||
modelRegistry: {
|
||||
find: () => null,
|
||||
} as never,
|
||||
providerConfig: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "grok-4-1-fast-reasoning",
|
||||
provider: "xai",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
reasoning: true,
|
||||
contextWindow: 2_000_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("owns modern-model matching without accepting multi-agent ids", () => {
|
||||
const provider = requireProviderContractProvider("xai");
|
||||
|
||||
expect(
|
||||
provider.isModernModelRef?.({
|
||||
provider: "xai",
|
||||
modelId: "grok-4-1-fast-reasoning",
|
||||
} as never),
|
||||
).toBe(true);
|
||||
expect(
|
||||
provider.isModernModelRef?.({
|
||||
provider: "xai",
|
||||
modelId: "grok-4.20-multi-agent-experimental-beta-0304",
|
||||
} as never),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("owns direct xai compat flags on resolved models", () => {
|
||||
const provider = requireProviderContractProvider("xai");
|
||||
|
||||
expect(
|
||||
provider.normalizeResolvedModel?.({
|
||||
provider: "xai",
|
||||
modelId: "grok-4-1-fast",
|
||||
model: createModel({
|
||||
id: "grok-4-1-fast",
|
||||
provider: "xai",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
}),
|
||||
} as never),
|
||||
).toMatchObject({
|
||||
compat: {
|
||||
toolSchemaProfile: "xai",
|
||||
nativeWebSearchTool: true,
|
||||
toolCallArgumentsEncoding: "html-entities",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("owns downstream xai compat contributions for x-ai routed models", () => {
|
||||
const provider = requireProviderContractProvider("xai");
|
||||
|
||||
expect(
|
||||
provider.contributeResolvedModelCompat?.({
|
||||
provider: "openrouter",
|
||||
modelId: "x-ai/grok-4-1-fast",
|
||||
model: createModel({
|
||||
id: "x-ai/grok-4-1-fast",
|
||||
provider: "openrouter",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
}),
|
||||
} as never),
|
||||
).toMatchObject({
|
||||
toolSchemaProfile: "xai",
|
||||
nativeWebSearchTool: true,
|
||||
toolCallArgumentsEncoding: "html-entities",
|
||||
});
|
||||
});
|
||||
|
||||
it("owns xai tool_stream defaults", () => {
|
||||
const provider = requireProviderContractProvider("xai");
|
||||
|
||||
expect(
|
||||
provider.prepareExtraParams?.({
|
||||
provider: "xai",
|
||||
modelId: "grok-4-1-fast-reasoning",
|
||||
extraParams: { temperature: 0.2 },
|
||||
}),
|
||||
).toEqual({
|
||||
temperature: 0.2,
|
||||
tool_stream: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
provider.prepareExtraParams?.({
|
||||
provider: "xai",
|
||||
modelId: "grok-4-1-fast-reasoning",
|
||||
extraParams: { tool_stream: false },
|
||||
}),
|
||||
).toEqual({
|
||||
tool_stream: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("owns xai fast-mode model rewriting through the plugin stream hook", () => {
|
||||
const provider = requireProviderContractProvider("xai");
|
||||
let capturedModelId = "";
|
||||
const baseStreamFn: StreamFn = (model) => {
|
||||
capturedModelId = model.id;
|
||||
return {
|
||||
push() {},
|
||||
async result() {
|
||||
return undefined;
|
||||
},
|
||||
async *[Symbol.asyncIterator]() {
|
||||
// Minimal async stream surface for xAI decode wrappers.
|
||||
},
|
||||
} as unknown as ReturnType<StreamFn>;
|
||||
};
|
||||
|
||||
const streamFn = provider.wrapStreamFn?.({
|
||||
provider: "xai",
|
||||
modelId: "grok-4",
|
||||
extraParams: { fastMode: true },
|
||||
streamFn: baseStreamFn,
|
||||
});
|
||||
|
||||
expect(streamFn).toBeTypeOf("function");
|
||||
void streamFn?.(
|
||||
createModel({
|
||||
id: "grok-4",
|
||||
provider: "xai",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
}) as never,
|
||||
{ messages: [] } as never,
|
||||
{},
|
||||
);
|
||||
expect(capturedModelId).toBe("grok-4-fast");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function describeOpenRouterProviderRuntimeContract() {
|
||||
describe("openrouter provider runtime contract", { timeout: CONTRACT_SETUP_TIMEOUT_MS }, () => {
|
||||
installRuntimeHooks();
|
||||
|
||||
Reference in New Issue
Block a user