diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 15baedc0b5c..d969ef03398 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -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` | Cheap provider-auth env metadata that OpenClaw can inspect without loading plugin code. | | `providerAuthAliases` | No | `Record` | 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. diff --git a/extensions/anthropic-vertex/openclaw.plugin.json b/extensions/anthropic-vertex/openclaw.plugin.json index 8c417d22806..54844144623 100644 --- a/extensions/anthropic-vertex/openclaw.plugin.json +++ b/extensions/anthropic-vertex/openclaw.plugin.json @@ -3,6 +3,7 @@ "enabledByDefault": true, "providers": ["anthropic-vertex"], "providerDiscoveryEntry": "./provider-discovery.ts", + "nonSecretAuthMarkers": ["gcp-vertex-credentials"], "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/lmstudio/openclaw.plugin.json b/extensions/lmstudio/openclaw.plugin.json index 0559047b9a9..bfb896f30dc 100644 --- a/extensions/lmstudio/openclaw.plugin.json +++ b/extensions/lmstudio/openclaw.plugin.json @@ -2,6 +2,7 @@ "id": "lmstudio", "enabledByDefault": true, "providers": ["lmstudio"], + "nonSecretAuthMarkers": ["lmstudio-local"], "providerAuthEnvVars": { "lmstudio": ["LM_API_TOKEN"] }, diff --git a/extensions/minimax/openclaw.plugin.json b/extensions/minimax/openclaw.plugin.json index be9419390b6..211a25ac72c 100644 --- a/extensions/minimax/openclaw.plugin.json +++ b/extensions/minimax/openclaw.plugin.json @@ -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"] diff --git a/extensions/ollama/openclaw.plugin.json b/extensions/ollama/openclaw.plugin.json index 1217e0707c2..f2f3d86390c 100644 --- a/extensions/ollama/openclaw.plugin.json +++ b/extensions/ollama/openclaw.plugin.json @@ -4,6 +4,7 @@ "providers": ["ollama"], "providerDiscoveryEntry": "./provider-discovery.ts", "syntheticAuthRefs": ["ollama"], + "nonSecretAuthMarkers": ["ollama-local"], "providerAuthEnvVars": { "ollama": ["OLLAMA_API_KEY"] }, diff --git a/extensions/xai/index.test.ts b/extensions/xai/index.test.ts index 6c2f0f12e67..1dea1d530f0 100644 --- a/extensions/xai/index.test.ts +++ b/extensions/xai/index.test.ts @@ -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", + }); + }); }); diff --git a/src/agents/model-auth-markers.test.ts b/src/agents/model-auth-markers.test.ts index ac19e085b8a..da769abaeb3 100644 --- a/src/agents/model-auth-markers.test.ts +++ b/src/agents/model-auth-markers.test.ts @@ -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 | 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); }); diff --git a/src/agents/model-auth-markers.ts b/src/agents/model-auth-markers.ts index e6d8543ddc3..d057c4db80e 100644 --- a/src/agents/model-auth-markers.ts +++ b/src/agents/model-auth-markers.ts @@ -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 { ]); } +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; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 7b1f31d4680..45212a65f4c 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -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({ diff --git a/src/plugin-sdk/telegram-command-config.ts b/src/plugin-sdk/telegram-command-config.ts index a9ca35c4a9e..2512160c293 100644 --- a/src/plugin-sdk/telegram-command-config.ts +++ b/src/plugin-sdk/telegram-command-config.ts @@ -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(); - const checkReserved = params.checkReserved !== false; - const checkDuplicates = params.checkDuplicates !== false; - const seen = new Set(); - 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 { diff --git a/src/plugins/contracts/provider-discovery.contract.test.ts b/src/plugins/contracts/provider-discovery.contract.test.ts index adf5f830158..e067634e551 100644 --- a/src/plugins/contracts/provider-discovery.contract.test.ts +++ b/src/plugins/contracts/provider-discovery.contract.test.ts @@ -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(); diff --git a/src/plugins/contracts/provider-runtime.contract.test.ts b/src/plugins/contracts/provider-runtime.contract.test.ts index 3998a688d87..0fe8423855f 100644 --- a/src/plugins/contracts/provider-runtime.contract.test.ts +++ b/src/plugins/contracts/provider-runtime.contract.test.ts @@ -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(); diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 71b371ccb98..7eebb65aba7 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -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", }); diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index e7a2cd7716a..a5fbe48a4a9 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -87,6 +87,7 @@ export type PluginManifestRecord = { modelSupport?: PluginManifestModelSupport; cliBackends: string[]; syntheticAuthRefs?: string[]; + nonSecretAuthMarkers?: string[]; commandAliases?: PluginManifestCommandAlias[]; providerAuthEnvVars?: Record; providerAuthAliases?: Record; @@ -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 ?? [], diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index c5b79a1b4c2..21d04e29015 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -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, diff --git a/src/shared/custom-command-config.ts b/src/shared/custom-command-config.ts new file mode 100644 index 00000000000..995444e5ebd --- /dev/null +++ b/src/shared/custom-command-config.ts @@ -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; + 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(); + const checkReserved = params.checkReserved !== false; + const checkDuplicates = params.checkDuplicates !== false; + const seen = new Set(); + 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 }; +} diff --git a/test/helpers/plugins/provider-discovery-contract.ts b/test/helpers/plugins/provider-discovery-contract.ts index c581129203c..ca435335e21 100644 --- a/test/helpers/plugins/provider-discovery-contract.ts +++ b/test/helpers/plugins/provider-discovery-contract.ts @@ -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>; 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[0]; - }>(bundledProviderModules.ollamaIndexModuleUrl); - state.ollamaProvider = requireProvider(await registerProviders(ollamaPlugin), "ollama"); - } - if (providerIds.includes("vllm")) { const { default: vllmPlugin } = await importBundledProviderPlugin<{ default: Parameters[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; diff --git a/test/helpers/plugins/provider-runtime-contract.ts b/test/helpers/plugins/provider-runtime-contract.ts index 6f8c23db4e4..72a30ad6b28 100644 --- a/test/helpers/plugins/provider-runtime-contract.ts +++ b/test/helpers/plugins/provider-runtime-contract.ts @@ -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[0]["plugin"]; }>(providerRuntimeContractModules.veniceIndexModuleId), }, - { - providerIds: ["xai"], - pluginId: "xai", - name: "xAI", - load: async () => - await importBundledProviderPlugin<{ - default: Parameters[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; - }; - - 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();