diff --git a/.agents/skills/parallels-discord-roundtrip/SKILL.md b/.agents/skills/parallels-discord-roundtrip/SKILL.md index 8fda0da1a23..cbfffc21446 100644 --- a/.agents/skills/parallels-discord-roundtrip/SKILL.md +++ b/.agents/skills/parallels-discord-roundtrip/SKILL.md @@ -42,10 +42,13 @@ pnpm test:parallels:macos \ ## Notes - Snapshot target: closest to `macOS 26.3.1 fresh`. +- Snapshot resolver now prefers matching `*-poweroff*` clones when the base hint also matches. That lets the harness reuse disk-only recovery snapshots without passing a longer hint. +- If Windows/Linux snapshot restore logs show `PET_QUESTION_SNAPSHOT_STATE_INCOMPATIBLE_CPU`, drop the suspended state once, create a `*-poweroff*` replacement snapshot, and rerun. The smoke scripts now auto-start restored power-off snapshots. - Harness configures Discord inside the guest; no checked-in token/config. - Use the `openclaw` wrapper for guest `message send/read`; `node openclaw.mjs message ...` does not expose the lazy message subcommands the same way. - Write `channels.discord.guilds` in one JSON object (`--strict-json`), not dotted `config set channels.discord.guilds....` paths; numeric snowflakes get treated like array indexes. - Avoid `prlctl enter` / expect for long Discord setup scripts; it line-wraps/corrupts long commands. Use `prlctl exec --current-user /bin/sh -lc ...` for the Discord config phase. +- Full 3-OS sweeps: the shared build lock is safe in parallel, but snapshot restore is still a Parallels bottleneck. Prefer serialized Windows/Linux restore-heavy reruns if the host is already under load. - Harness cleanup deletes the temporary Discord smoke messages at exit. - Per-phase logs: `/tmp/openclaw-parallels-smoke.*` - Machine summary: pass `--json` diff --git a/CHANGELOG.md b/CHANGELOG.md index d948e2b59ee..24335d41a91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ Docs: https://docs.openclaw.ai - Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28. - Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#46663) Fixes #40146. Thanks @Takhoffman. - Zalo/plugin runtime: export `resolveClientIp` from `openclaw/plugin-sdk/zalo` so installed builds no longer crash on startup when the webhook monitor loads from the packaged extension instead of the monorepo source tree. (#46549) Thanks @No898. +- Docker/live tests: mount external CLI auth homes into writable container copies, derive Codex OAuth expiry from JWT `exp`, refresh synced CLI creds instead of trusting stale cached expiry, and make gateway live probes wait on transcript output so `pnpm test:docker:all` stays green in Linux. - Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`. (#46722) Thanks @Takhoffman. - Control UI/logging: make browser-safe logger imports avoid eager temp-dir resolution so the bundled Control UI no longer crashes to a blank screen when logging reaches `tmp-openclaw-dir`. (#48469) Fixes #48062. Thanks @7inspire. - Plugins/scoped ids: preserve scoped plugin ids during install and config keying, and keep bundled plugins ahead of discovered duplicate ids by default so `@scope/name` plugins no longer collide with unscoped installs. (#47413) Thanks @vincentkoc. diff --git a/docs/help/testing.md b/docs/help/testing.md index 09388dd769e..ab63db23670 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -362,7 +362,7 @@ If you want to rely on env keys (e.g. exported in your `~/.profile`), run local ## Docker runners (optional “works in Linux” checks) -These run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted). They also bind-mount CLI auth homes like `~/.codex`, `~/.claude`, `~/.qwen`, and `~/.minimax` when present so external-CLI OAuth stays available in-container: +These run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted). They also bind-mount CLI auth homes like `~/.codex`, `~/.claude`, `~/.qwen`, and `~/.minimax` when present, then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store: - Direct models: `pnpm test:docker:live-models` (script: `scripts/test-live-models-docker.sh`) - Gateway + dev agent: `pnpm test:docker:live-gateway` (script: `scripts/test-live-gateway-models-docker.sh`) @@ -373,6 +373,9 @@ These run `pnpm test:live` inside the repo Docker image, mounting your local con The live-model Docker runners also bind-mount the current checkout read-only and stage it into a temporary workdir inside the container. This keeps the runtime image slim while still running Vitest against your exact local source/config. +`test:docker:live-models` still runs `pnpm test:live`, so pass through +`OPENCLAW_LIVE_GATEWAY_*` as well when you need to narrow or exclude gateway +live coverage from that Docker lane. Manual ACP plain-language thread smoke (not CI): @@ -384,8 +387,9 @@ Useful env vars: - `OPENCLAW_CONFIG_DIR=...` (default: `~/.openclaw`) mounted to `/home/node/.openclaw` - `OPENCLAW_WORKSPACE_DIR=...` (default: `~/.openclaw/workspace`) mounted to `/home/node/.openclaw/workspace` - `OPENCLAW_PROFILE_FILE=...` (default: `~/.profile`) mounted to `/home/node/.profile` and sourced before running tests -- External CLI auth dirs under `$HOME` (`.codex`, `.claude`, `.qwen`, `.minimax`) are mounted read-only to the matching `/home/node/...` paths when present +- External CLI auth dirs under `$HOME` (`.codex`, `.claude`, `.qwen`, `.minimax`) are mounted read-only under `/host-auth/...`, then copied into `/home/node/...` before tests start - `OPENCLAW_LIVE_GATEWAY_MODELS=...` / `OPENCLAW_LIVE_MODELS=...` to narrow the run +- `OPENCLAW_LIVE_GATEWAY_PROVIDERS=...` / `OPENCLAW_LIVE_PROVIDERS=...` to filter providers in-container - `OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS=1` to ensure creds come from the profile store (not env) ## Docs sanity diff --git a/docs/nodes/media-understanding.md b/docs/nodes/media-understanding.md index dae748633bd..ab3701387be 100644 --- a/docs/nodes/media-understanding.md +++ b/docs/nodes/media-understanding.md @@ -10,6 +10,10 @@ title: "Media Understanding" OpenClaw can **summarize inbound media** (image/audio/video) before the reply pipeline runs. It auto‑detects when local tools or provider keys are available, and can be disabled or customized. If understanding is off, models still receive the original files/URLs as usual. +Vendor-specific media behavior is registered by vendor plugins, while OpenClaw +core owns the shared `tools.media` config, fallback order, and reply-pipeline +integration. + ## Goals - Optional: pre‑digest inbound media into short text for faster routing + better command parsing. @@ -184,7 +188,10 @@ If you set `capabilities`, the entry only runs for those media types. For shared lists, OpenClaw can infer defaults: - `openai`, `anthropic`, `minimax`: **image** +- `moonshot`: **image + video** - `google` (Gemini API): **image + audio + video** +- `mistral`: **audio** +- `zai`: **image** - `groq`: **audio** - `deepgram`: **audio** @@ -193,11 +200,11 @@ If you omit `capabilities`, the entry is eligible for the list it appears in. ## Provider support matrix (OpenClaw integrations) -| Capability | Provider integration | Notes | -| ---------- | ------------------------------------------------ | --------------------------------------------------------- | -| Image | OpenAI / Anthropic / Google / others via `pi-ai` | Any image-capable model in the registry works. | -| Audio | OpenAI, Groq, Deepgram, Google, Mistral | Provider transcription (Whisper/Deepgram/Gemini/Voxtral). | -| Video | Google (Gemini API) | Provider video understanding. | +| Capability | Provider integration | Notes | +| ---------- | -------------------------------------------------- | ----------------------------------------------------------------------- | +| Image | OpenAI, Anthropic, Google, MiniMax, Moonshot, Z.AI | Vendor plugins register image support against core media understanding. | +| Audio | OpenAI, Groq, Deepgram, Google, Mistral | Provider transcription (Whisper/Deepgram/Gemini/Voxtral). | +| Video | Google, Moonshot | Provider video understanding via vendor plugins. | ## Model selection guidance diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 3e53c5e205e..c1dc9398f5c 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -113,9 +113,11 @@ That means: Examples: - the bundled `openai` plugin owns OpenAI model-provider behavior and OpenAI - speech behavior + speech + media-understanding behavior - the bundled `elevenlabs` plugin owns ElevenLabs speech behavior - the bundled `microsoft` plugin owns Microsoft speech behavior +- the bundled `google`, `minimax`, `mistral`, `moonshot`, and `zai` plugins own + their media-understanding backends - the `voice-call` plugin is a feature plugin: it owns call transport, tools, CLI, routes, and runtime, but it consumes core TTS/STT capability instead of inventing a second speech stack @@ -167,6 +169,24 @@ For example, TTS follows this shape: That same pattern should be preferred for future capabilities. +### Capability example: video understanding + +OpenClaw already treats image/audio/video understanding as one shared +capability. The same ownership model applies there: + +1. core defines the media-understanding contract +2. vendor plugins register `describeImage`, `transcribeAudio`, and + `describeVideo` as applicable +3. channels and feature plugins consume the shared core behavior instead of + wiring directly to vendor code + +That avoids baking one provider's video assumptions into core. The plugin owns +the vendor surface; core owns the capability contract and fallback behavior. + +If OpenClaw adds a new domain later, such as video generation, use the same +sequence again: define the core capability first, then let vendor plugins +register implementations against it. + ## Compatible bundles OpenClaw also recognizes two compatible external bundle layouts: @@ -331,7 +351,8 @@ There are two layers of enforcement: 2. **contract tests** Bundled plugins are captured in contract registries during test runs so OpenClaw can assert ownership explicitly. Today this is used for model - providers, web search providers, and bundled registration ownership. + providers, speech providers, web search providers, and bundled registration + ownership. The practical effect is that OpenClaw knows, up front, which plugin owns which surface. That lets core and channels compose seamlessly because ownership is @@ -649,19 +670,32 @@ to think of as short-lived performance caches, not persistence. ## Runtime helpers -Plugins can access selected core helpers via `api.runtime`. For telephony TTS: +Plugins can access selected core helpers via `api.runtime`. For TTS: ```ts +const clip = await api.runtime.tts.textToSpeech({ + text: "Hello from OpenClaw", + cfg: api.config, +}); + const result = await api.runtime.tts.textToSpeechTelephony({ text: "Hello from OpenClaw", cfg: api.config, }); + +const voices = await api.runtime.tts.listVoices({ + provider: "elevenlabs", + cfg: api.config, +}); ``` Notes: +- `textToSpeech` returns the normal core TTS output payload for file/voice-note surfaces. - Uses core `messages.tts` configuration and provider selection. - Returns PCM audio buffer + sample rate. Plugins must resample/encode for providers. +- `listVoices` is optional per provider. Use it for vendor-owned voice pickers or setup flows. +- Voice listings can include richer metadata such as locale, gender, and personality tags for provider-aware pickers. - OpenAI and ElevenLabs support telephony today. Microsoft does not. Plugins can also register speech providers via `api.registerSpeechProvider(...)`. @@ -691,10 +725,48 @@ Notes: text, speech, image, and future media providers as OpenClaw adds those capability contracts. -For STT/transcription, plugins can call: +For image/audio/video understanding, plugins register one typed +media-understanding provider instead of a generic key/value bag: ```ts -const { text } = await api.runtime.stt.transcribeAudioFile({ +api.registerMediaUnderstandingProvider({ + id: "google", + capabilities: ["image", "audio", "video"], + describeImage: async (req) => ({ text: "..." }), + transcribeAudio: async (req) => ({ text: "..." }), + describeVideo: async (req) => ({ text: "..." }), +}); +``` + +Notes: + +- Keep orchestration, fallback, config, and channel wiring in core. +- Keep vendor behavior in the provider plugin. +- Additive expansion should stay typed: new optional methods, new optional + result fields, new optional capabilities. +- If OpenClaw adds a new capability such as video generation later, define the + core capability contract first, then let vendor plugins register against it. + +For media-understanding runtime helpers, plugins can call: + +```ts +const image = await api.runtime.mediaUnderstanding.describeImageFile({ + filePath: "/tmp/inbound-photo.jpg", + cfg: api.config, + agentDir: "/tmp/agent", +}); + +const video = await api.runtime.mediaUnderstanding.describeVideoFile({ + filePath: "/tmp/inbound-video.mp4", + cfg: api.config, +}); +``` + +For audio transcription, plugins can use either the media-understanding runtime +or the older STT alias: + +```ts +const { text } = await api.runtime.mediaUnderstanding.transcribeAudioFile({ filePath: "/tmp/inbound-audio.ogg", cfg: api.config, // Optional when MIME cannot be inferred reliably: @@ -704,8 +776,11 @@ const { text } = await api.runtime.stt.transcribeAudioFile({ Notes: +- `api.runtime.mediaUnderstanding.*` is the preferred shared surface for + image/audio/video understanding. - Uses core media-understanding audio configuration (`tools.media.audio`) and provider fallback order. - Returns `{ text: undefined }` when no transcription output is produced (for example skipped/unsupported input). +- `api.runtime.stt.transcribeAudioFile(...)` remains as a compatibility alias. ## Gateway HTTP routes @@ -1268,6 +1343,7 @@ Plugins export either: - `registerChannel` - `registerProvider` - `registerSpeechProvider` +- `registerMediaUnderstandingProvider` - `registerWebSearchProvider` - `registerHttpRoute` - `registerCommand` diff --git a/extensions/anthropic/index.ts b/extensions/anthropic/index.ts index a2491dfbd87..cf63e876354 100644 --- a/extensions/anthropic/index.ts +++ b/extensions/anthropic/index.ts @@ -26,6 +26,7 @@ import { fetchClaudeUsage } from "../../src/infra/provider-usage.fetch.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; import type { ProviderAuthResult } from "../../src/plugins/types.js"; import { normalizeSecretInput } from "../../src/utils/normalize-secret-input.js"; +import { anthropicMediaUnderstandingProvider } from "./media-understanding-provider.js"; const PROVIDER_ID = "anthropic"; const DEFAULT_ANTHROPIC_MODEL = "anthropic/claude-sonnet-4-6"; @@ -394,6 +395,7 @@ const anthropicPlugin = { profileId: ctx.profileId, }), }); + api.registerMediaUnderstandingProvider(anthropicMediaUnderstandingProvider); }, }; diff --git a/extensions/anthropic/media-understanding-provider.ts b/extensions/anthropic/media-understanding-provider.ts new file mode 100644 index 00000000000..fbd12374e50 --- /dev/null +++ b/extensions/anthropic/media-understanding-provider.ts @@ -0,0 +1,8 @@ +import { describeImageWithModel } from "../../src/media-understanding/providers/image.js"; +import type { MediaUnderstandingProvider } from "../../src/media-understanding/types.js"; + +export const anthropicMediaUnderstandingProvider: MediaUnderstandingProvider = { + id: "anthropic", + capabilities: ["image"], + describeImage: describeImageWithModel, +}; diff --git a/extensions/bluebubbles/src/setup-core.ts b/extensions/bluebubbles/src/setup-core.ts index 83a079dbaab..408cd255cf3 100644 --- a/extensions/bluebubbles/src/setup-core.ts +++ b/extensions/bluebubbles/src/setup-core.ts @@ -1,13 +1,12 @@ import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, patchScopedAccountConfig, + prepareScopedSetupConfig, } from "../../../src/channels/plugins/setup-helpers.js"; import { setTopLevelChannelDmPolicyWithAllowFrom } from "../../../src/channels/plugins/setup-wizard-helpers.js"; import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { DmPolicy } from "../../../src/config/types.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; import { applyBlueBubblesConnectionConfig } from "./config-apply.js"; const channel = "bluebubbles" as const; @@ -38,7 +37,7 @@ export function setBlueBubblesAllowFrom( export const blueBubblesSetupAdapter: ChannelSetupAdapter = { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ + prepareScopedSetupConfig({ cfg, channelKey: channel, accountId, @@ -57,19 +56,13 @@ export const blueBubblesSetupAdapter: ChannelSetupAdapter = { return null; }, applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ + const next = prepareScopedSetupConfig({ cfg, channelKey: channel, accountId, name: input.name, + migrateBaseName: true, }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; return applyBlueBubblesConnectionConfig({ cfg: next, accountId, diff --git a/extensions/bluebubbles/src/setup-surface.ts b/extensions/bluebubbles/src/setup-surface.ts index 1a138b8e73d..f6922ed4861 100644 --- a/extensions/bluebubbles/src/setup-surface.ts +++ b/extensions/bluebubbles/src/setup-surface.ts @@ -1,14 +1,14 @@ import { + DEFAULT_ACCOUNT_ID, + formatDocsLink, mergeAllowFromEntries, resolveSetupAccountId, -} from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js"; -import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { DmPolicy } from "../../../src/config/types.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; -import type { WizardPrompter } from "../../../src/wizard/prompts.js"; + type ChannelSetupDmPolicy, + type ChannelSetupWizard, + type DmPolicy, + type OpenClawConfig, + type WizardPrompter, +} from "openclaw/plugin-sdk/setup"; import { listBlueBubblesAccountIds, resolveBlueBubblesAccount, diff --git a/extensions/byteplus/index.ts b/extensions/byteplus/index.ts index d91fb87f1aa..7c6cf2f08fe 100644 --- a/extensions/byteplus/index.ts +++ b/extensions/byteplus/index.ts @@ -1,6 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { ensureModelAllowlistEntry } from "../../src/commands/model-allowlist.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { buildPairedProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildBytePlusCodingProvider, buildBytePlusProvider } from "./provider-catalog.js"; const PROVIDER_ID = "byteplus"; @@ -45,18 +46,15 @@ const byteplusPlugin = { ], catalog: { order: "paired", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - return { - providers: { - byteplus: { ...buildBytePlusProvider(), apiKey }, - "byteplus-plan": { ...buildBytePlusCodingProvider(), apiKey }, - }, - }; - }, + run: (ctx) => + buildPairedProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProviders: () => ({ + byteplus: buildBytePlusProvider(), + "byteplus-plan": buildBytePlusCodingProvider(), + }), + }), }, }); }, diff --git a/extensions/cloudflare-ai-gateway/index.ts b/extensions/cloudflare-ai-gateway/index.ts index ddc0bd7405a..aa584af8208 100644 --- a/extensions/cloudflare-ai-gateway/index.ts +++ b/extensions/cloudflare-ai-gateway/index.ts @@ -11,15 +11,16 @@ import { validateApiKeyInput, } from "../../src/commands/auth-choice.api-key.js"; import { ensureApiKeyFromOptionEnvOrPrompt } from "../../src/commands/auth-choice.apply-helpers.js"; -import { buildApiKeyCredential } from "../../src/commands/onboard-auth.credentials.js"; -import { - applyCloudflareAiGatewayConfig, - applyAuthProfileConfig, - CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, -} from "../../src/commands/onboard-auth.js"; +import { buildApiKeyCredential } from "../../src/commands/auth-credentials.js"; +import { applyAuthProfileConfig } from "../../src/commands/onboard-auth.js"; import type { SecretInput } from "../../src/config/types.secrets.js"; import { coerceSecretRef } from "../../src/config/types.secrets.js"; import { normalizeOptionalSecretInput } from "../../src/utils/normalize-secret-input.js"; +import { + applyCloudflareAiGatewayConfig, + buildCloudflareAiGatewayConfigPatch, + CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, +} from "./onboard.js"; const PROVIDER_ID = "cloudflare-ai-gateway"; const PROVIDER_ENV_VAR = "CLOUDFLARE_AI_GATEWAY_API_KEY"; @@ -53,30 +54,6 @@ function resolveMetadataFromCredential( }; } -function buildCloudflareConfigPatch(params: { accountId: string; gatewayId: string }) { - const baseUrl = resolveCloudflareAiGatewayBaseUrl(params); - return { - models: { - providers: { - [PROVIDER_ID]: { - baseUrl, - api: "anthropic-messages" as const, - models: [buildCloudflareAiGatewayModelDefinition()], - }, - }, - }, - agents: { - defaults: { - models: { - [CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF]: { - alias: "Cloudflare AI Gateway", - }, - }, - }, - }, - }; -} - async function resolveCloudflareGatewayMetadataInteractive(ctx: { accountId?: string; gatewayId?: string; @@ -180,7 +157,7 @@ const cloudflareAiGatewayPlugin = { ), }, ], - configPatch: buildCloudflareConfigPatch(metadata), + configPatch: buildCloudflareAiGatewayConfigPatch(metadata), defaultModel: CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, }; }, diff --git a/extensions/cloudflare-ai-gateway/onboard.ts b/extensions/cloudflare-ai-gateway/onboard.ts new file mode 100644 index 00000000000..267c2f806f1 --- /dev/null +++ b/extensions/cloudflare-ai-gateway/onboard.ts @@ -0,0 +1,93 @@ +import { + buildCloudflareAiGatewayModelDefinition, + CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, + resolveCloudflareAiGatewayBaseUrl, +} from "../../src/agents/cloudflare-ai-gateway.js"; +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithDefaultModel, +} from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; + +export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF }; + +export function buildCloudflareAiGatewayConfigPatch(params: { + accountId: string; + gatewayId: string; +}) { + const baseUrl = resolveCloudflareAiGatewayBaseUrl(params); + return { + models: { + providers: { + "cloudflare-ai-gateway": { + baseUrl, + api: "anthropic-messages" as const, + models: [buildCloudflareAiGatewayModelDefinition()], + }, + }, + }, + agents: { + defaults: { + models: { + [CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF]: { + alias: "Cloudflare AI Gateway", + }, + }, + }, + }, + }; +} + +export function applyCloudflareAiGatewayProviderConfig( + cfg: OpenClawConfig, + params?: { accountId?: string; gatewayId?: string }, +): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF] = { + ...models[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF], + alias: models[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF]?.alias ?? "Cloudflare AI Gateway", + }; + + const existingProvider = cfg.models?.providers?.["cloudflare-ai-gateway"] as + | { baseUrl?: unknown } + | undefined; + const baseUrl = + params?.accountId && params?.gatewayId + ? resolveCloudflareAiGatewayBaseUrl({ + accountId: params.accountId, + gatewayId: params.gatewayId, + }) + : typeof existingProvider?.baseUrl === "string" + ? existingProvider.baseUrl + : undefined; + if (!baseUrl) { + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + }, + }, + }; + } + + return applyProviderConfigWithDefaultModel(cfg, { + agentModels: models, + providerId: "cloudflare-ai-gateway", + api: "anthropic-messages", + baseUrl, + defaultModel: buildCloudflareAiGatewayModelDefinition(), + }); +} + +export function applyCloudflareAiGatewayConfig( + cfg: OpenClawConfig, + params?: { accountId?: string; gatewayId?: string }, +): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyCloudflareAiGatewayProviderConfig(cfg, params), + CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, + ); +} diff --git a/extensions/discord/src/account-inspect.ts b/extensions/discord/src/account-inspect.ts index bddea792c14..a998c5ba874 100644 --- a/extensions/discord/src/account-inspect.ts +++ b/extensions/discord/src/account-inspect.ts @@ -1,13 +1,13 @@ +import { + hasConfiguredSecretInput, + normalizeSecretInputString, +} from "../../../src/config/types.secrets.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId, type OpenClawConfig, type DiscordAccountConfig, -} from "openclaw/plugin-sdk/discord"; -import { - hasConfiguredSecretInput, - normalizeSecretInputString, -} from "../../../src/config/types.secrets.js"; +} from "../../../src/plugin-sdk-internal/discord.js"; import { mergeDiscordAccountConfig, resolveDefaultDiscordAccountId, diff --git a/extensions/discord/src/accounts.ts b/extensions/discord/src/accounts.ts index 6e9d58c97de..39903077aaf 100644 --- a/extensions/discord/src/accounts.ts +++ b/extensions/discord/src/accounts.ts @@ -1,14 +1,14 @@ -import type { - OpenClawConfig, - DiscordAccountConfig, - DiscordActionConfig, -} from "openclaw/plugin-sdk/discord"; import { createAccountActionGate, createAccountListHelpers, normalizeAccountId, resolveAccountEntry, } from "../../../src/plugin-sdk-internal/accounts.js"; +import type { + OpenClawConfig, + DiscordAccountConfig, + DiscordActionConfig, +} from "../../../src/plugin-sdk-internal/discord.js"; import { resolveDiscordToken } from "./token.js"; export type ResolvedDiscordAccount = { diff --git a/extensions/discord/src/actions/handle-action.ts b/extensions/discord/src/actions/handle-action.ts index 4beb7d76de4..c938d675955 100644 --- a/extensions/discord/src/actions/handle-action.ts +++ b/extensions/discord/src/actions/handle-action.ts @@ -1,4 +1,5 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; import { readNumberParam, readStringArrayParam, @@ -9,7 +10,6 @@ import { handleDiscordAction } from "../../../../src/agents/tools/discord-action import { resolveReactionMessageId } from "../../../../src/channels/plugins/actions/reaction-message-id.js"; import type { ChannelMessageActionContext } from "../../../../src/channels/plugins/types.js"; import { normalizeInteractiveReply } from "../../../../src/interactive/payload.js"; -import { readBooleanParam } from "../../../../src/plugin-sdk/boolean-param.js"; import { buildDiscordInteractiveComponents } from "../shared-interactive.js"; import { resolveDiscordChannelId } from "../targets.js"; import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js"; diff --git a/extensions/discord/src/channel.setup.ts b/extensions/discord/src/channel.setup.ts index ee157e3c9bb..5c7bfe6e659 100644 --- a/extensions/discord/src/channel.setup.ts +++ b/extensions/discord/src/channel.setup.ts @@ -1,77 +1,8 @@ -import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; -import { - createScopedAccountConfigAccessors, - formatAllowFromLowercase, -} from "openclaw/plugin-sdk/compat"; -import { - buildChannelConfigSchema, - DiscordConfigSchema, - getChatChannelMeta, - type ChannelPlugin, -} from "openclaw/plugin-sdk/discord"; -import { inspectDiscordAccount } from "./account-inspect.js"; -import { - listDiscordAccountIds, - resolveDefaultDiscordAccountId, - resolveDiscordAccount, - type ResolvedDiscordAccount, -} from "./accounts.js"; -import { createDiscordSetupWizardProxy, discordSetupAdapter } from "./setup-core.js"; +import type { ChannelPlugin } from "openclaw/plugin-sdk/discord"; +import type { ResolvedDiscordAccount } from "./accounts.js"; +import { discordSetupAdapter } from "./setup-core.js"; +import { createDiscordPluginBase } from "./shared.js"; -async function loadDiscordChannelRuntime() { - return await import("./channel.runtime.js"); -} - -const discordConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }), - resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom, - formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), - resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo, -}); - -const discordConfigBase = createScopedChannelConfigBase({ - sectionKey: "discord", - listAccountIds: listDiscordAccountIds, - resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }), - inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }), - defaultAccountId: resolveDefaultDiscordAccountId, - clearBaseFields: ["token", "name"], -}); - -const discordSetupWizard = createDiscordSetupWizardProxy(async () => ({ - discordSetupWizard: (await loadDiscordChannelRuntime()).discordSetupWizard, -})); - -export const discordSetupPlugin: ChannelPlugin = { - id: "discord", - meta: { - ...getChatChannelMeta("discord"), - }, - setupWizard: discordSetupWizard, - capabilities: { - chatTypes: ["direct", "channel", "thread"], - polls: true, - reactions: true, - threads: true, - media: true, - nativeCommands: true, - }, - streaming: { - blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, - }, - reload: { configPrefixes: ["channels.discord"] }, - configSchema: buildChannelConfigSchema(DiscordConfigSchema), - config: { - ...discordConfigBase, - isConfigured: (account) => Boolean(account.token?.trim()), - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: Boolean(account.token?.trim()), - tokenSource: account.tokenSource, - }), - ...discordConfigAccessors, - }, +export const discordSetupPlugin: ChannelPlugin = createDiscordPluginBase({ setup: discordSetupAdapter, -}; +}); diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 966a5a1cbcd..b598f004cf7 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -1,12 +1,9 @@ import { Separator, TextDisplay } from "@buape/carbon"; -import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; import { buildAccountScopedAllowlistConfigEditor, buildAccountScopedDmSecurityPolicy, collectOpenProviderGroupPolicyWarnings, collectOpenGroupPolicyConfiguredRouteWarnings, - createScopedAccountConfigAccessors, - formatAllowFromLowercase, } from "openclaw/plugin-sdk/compat"; import { buildAgentSessionKey, @@ -15,11 +12,8 @@ import { } from "openclaw/plugin-sdk/core"; import { buildComputedAccountStatusSnapshot, - buildChannelConfigSchema, buildTokenChannelStatusSummary, DEFAULT_ACCOUNT_ID, - DiscordConfigSchema, - getChatChannelMeta, listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig, PAIRING_APPROVED_MESSAGE, @@ -32,12 +26,11 @@ import { type OpenClawConfig, } from "openclaw/plugin-sdk/discord"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; +import { normalizeOutboundThreadId } from "../../../src/infra/outbound/thread-id.js"; import { normalizeMessageChannel } from "../../../src/utils/message-channel.js"; -import { inspectDiscordAccount } from "./account-inspect.js"; import { listDiscordAccountIds, resolveDiscordAccount, - resolveDefaultDiscordAccountId, type ResolvedDiscordAccount, } from "./accounts.js"; import { collectDiscordAuditChannelIds } from "./audit.js"; @@ -54,7 +47,8 @@ import type { DiscordProbe } from "./probe.js"; import { resolveDiscordUserAllowlist } from "./resolve-users.js"; import { getDiscordRuntime } from "./runtime.js"; import { fetchChannelPermissionsDiscord } from "./send.js"; -import { createDiscordSetupWizardProxy, discordSetupAdapter } from "./setup-core.js"; +import { discordSetupAdapter } from "./setup-core.js"; +import { createDiscordPluginBase, discordConfigAccessors } from "./shared.js"; import { collectDiscordStatusIssues } from "./status-issues.js"; import { parseDiscordTarget } from "./targets.js"; import { DiscordUiContainer } from "./ui.js"; @@ -63,13 +57,8 @@ type DiscordSendFn = ReturnType< typeof getDiscordRuntime >["channel"]["discord"]["sendMessageDiscord"]; -const meta = getChatChannelMeta("discord"); const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const; -async function loadDiscordChannelRuntime() { - return await import("./channel.runtime.js"); -} - function formatDiscordIntents(intents?: { messageContent?: string; guildMembers?: string; @@ -208,20 +197,6 @@ function parseDiscordExplicitTarget(raw: string) { } } -function normalizeOutboundThreadId(value?: string | number | null): string | undefined { - if (value == null) { - return undefined; - } - if (typeof value === "number") { - if (!Number.isFinite(value)) { - return undefined; - } - return String(Math.trunc(value)); - } - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; -} - function buildDiscordBaseSessionKey(params: { cfg: OpenClawConfig; agentId: string; @@ -304,32 +279,10 @@ function resolveDiscordOutboundSessionRoute(params: { }; } -const discordConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }), - resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom, - formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), - resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo, -}); - -const discordConfigBase = createScopedChannelConfigBase({ - sectionKey: "discord", - listAccountIds: listDiscordAccountIds, - resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }), - inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }), - defaultAccountId: resolveDefaultDiscordAccountId, - clearBaseFields: ["token", "name"], -}); - -const discordSetupWizard = createDiscordSetupWizardProxy(async () => ({ - discordSetupWizard: (await loadDiscordChannelRuntime()).discordSetupWizard, -})); - export const discordPlugin: ChannelPlugin = { - id: "discord", - meta: { - ...meta, - }, - setupWizard: discordSetupWizard, + ...createDiscordPluginBase({ + setup: discordSetupAdapter, + }), pairing: { idLabel: "discordUserId", normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""), @@ -340,31 +293,6 @@ export const discordPlugin: ChannelPlugin = { ); }, }, - capabilities: { - chatTypes: ["direct", "channel", "thread"], - polls: true, - reactions: true, - threads: true, - media: true, - nativeCommands: true, - }, - streaming: { - blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, - }, - reload: { configPrefixes: ["channels.discord"] }, - configSchema: buildChannelConfigSchema(DiscordConfigSchema), - config: { - ...discordConfigBase, - isConfigured: (account) => Boolean(account.token?.trim()), - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: Boolean(account.token?.trim()), - tokenSource: account.tokenSource, - }), - ...discordConfigAccessors, - }, allowlist: { supportsScope: ({ scope }) => scope === "dm", readConfig: ({ cfg, accountId }) => diff --git a/extensions/discord/src/monitor/allow-list.ts b/extensions/discord/src/monitor/allow-list.ts index 6391ad5c3a5..a6208eaf63a 100644 --- a/extensions/discord/src/monitor/allow-list.ts +++ b/extensions/discord/src/monitor/allow-list.ts @@ -1,4 +1,5 @@ import type { Guild, User } from "@buape/carbon"; +import { evaluateGroupRouteAccessForPolicy } from "openclaw/plugin-sdk/group-access"; import type { AllowlistMatch } from "../../../../src/channels/allowlist-match.js"; import { buildChannelKeyCandidates, @@ -6,7 +7,6 @@ import { resolveChannelMatchConfig, type ChannelMatchSource, } from "../../../../src/channels/channel-config.js"; -import { evaluateGroupRouteAccessForPolicy } from "../../../../src/plugin-sdk/group-access.js"; import { formatDiscordUserTag } from "./format.js"; export type DiscordAllowList = { diff --git a/extensions/discord/src/monitor/inbound-worker.ts b/extensions/discord/src/monitor/inbound-worker.ts index 214eb6a8020..cbc8e246704 100644 --- a/extensions/discord/src/monitor/inbound-worker.ts +++ b/extensions/discord/src/monitor/inbound-worker.ts @@ -1,7 +1,7 @@ +import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; import { createRunStateMachine } from "../../../../src/channels/run-state-machine.js"; import { danger } from "../../../../src/globals.js"; import { formatDurationSeconds } from "../../../../src/infra/format-time/format-duration.ts"; -import { KeyedAsyncQueue } from "../../../../src/plugin-sdk/keyed-async-queue.js"; import { materializeDiscordInboundJob, type DiscordInboundJob } from "./inbound-job.js"; import type { RuntimeEnv } from "./message-handler.preflight.types.js"; import { processDiscordMessage } from "./message-handler.process.js"; diff --git a/extensions/discord/src/monitor/model-picker-preferences.ts b/extensions/discord/src/monitor/model-picker-preferences.ts index e75ce013403..8657ed66436 100644 --- a/extensions/discord/src/monitor/model-picker-preferences.ts +++ b/extensions/discord/src/monitor/model-picker-preferences.ts @@ -1,14 +1,11 @@ import os from "node:os"; import path from "node:path"; +import { normalizeAccountId as normalizeSharedAccountId } from "openclaw/plugin-sdk/account-id"; +import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store"; import { normalizeProviderId } from "../../../../src/agents/model-selection.js"; import { resolveStateDir } from "../../../../src/config/paths.js"; import { withFileLock } from "../../../../src/infra/file-lock.js"; import { resolveRequiredHomeDir } from "../../../../src/infra/home-dir.js"; -import { - readJsonFileWithFallback, - writeJsonFileAtomically, -} from "../../../../src/plugin-sdk/json-store.js"; -import { normalizeAccountId as normalizeSharedAccountId } from "../../../../src/routing/account-id.js"; const MODEL_PICKER_PREFERENCES_LOCK_OPTIONS = { retries: { diff --git a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts index 97401cec0d8..dc81bc72e00 100644 --- a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts +++ b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts @@ -212,6 +212,58 @@ describe("Discord native plugin command dispatch", () => { ); }); + it("round-trips Discord native aliases through the real plugin registry", async () => { + const cfg = createConfig(); + const commandSpec: NativeCommandSpec = { + name: "pairdiscord", + description: "Pair", + acceptsArgs: true, + }; + const command = createDiscordNativeCommand({ + command: commandSpec, + cfg, + discordConfig: cfg.channels?.discord ?? {}, + accountId: "default", + sessionPrefix: "discord:slash", + ephemeralDefault: true, + threadBindings: createNoopThreadBindingManager("default"), + }); + const interaction = createInteraction(); + + expect( + registerPluginCommand("demo-plugin", { + name: "pair", + nativeNames: { + telegram: "pair_device", + discord: "pairdiscord", + }, + description: "Pair device", + acceptsArgs: true, + requireAuth: false, + handler: async ({ args }) => ({ text: `paired:${args ?? ""}` }), + }), + ).toEqual({ ok: true }); + + const dispatchSpy = vi + .spyOn(dispatcherModule, "dispatchReplyWithDispatcher") + .mockResolvedValue({} as never); + + await (command as { run: (interaction: unknown) => Promise }).run( + Object.assign(interaction, { + options: { + getString: () => "now", + getBoolean: () => null, + getFocused: () => "", + }, + }) as unknown, + ); + + expect(dispatchSpy).not.toHaveBeenCalled(); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: "paired:now" }), + ); + }); + it("blocks unauthorized Discord senders before requireAuth:false plugin commands execute", async () => { const cfg = { commands: { diff --git a/extensions/discord/src/plugin-shared.ts b/extensions/discord/src/plugin-shared.ts new file mode 100644 index 00000000000..9b5aec43b9e --- /dev/null +++ b/extensions/discord/src/plugin-shared.ts @@ -0,0 +1,39 @@ +import { + createScopedAccountConfigAccessors, + createScopedChannelConfigBase, + formatAllowFromLowercase, +} from "../../../src/plugin-sdk-internal/channel-config.js"; +import { type OpenClawConfig } from "../../../src/plugin-sdk-internal/discord.js"; +import { inspectDiscordAccount } from "./account-inspect.js"; +import { + listDiscordAccountIds, + resolveDefaultDiscordAccountId, + resolveDiscordAccount, + type ResolvedDiscordAccount, +} from "./accounts.js"; +import { createDiscordSetupWizardProxy } from "./setup-core.js"; + +async function loadDiscordChannelRuntime() { + return await import("./channel.runtime.js"); +} + +export const discordConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => + resolveDiscordAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom, + formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), + resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo, +}); + +export const discordConfigBase = createScopedChannelConfigBase({ + sectionKey: "discord", + listAccountIds: listDiscordAccountIds, + resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultDiscordAccountId, + clearBaseFields: ["token", "name"], +}); + +export const discordSetupWizard = createDiscordSetupWizardProxy(async () => ({ + discordSetupWizard: (await loadDiscordChannelRuntime()).discordSetupWizard, +})); diff --git a/extensions/discord/src/runtime.ts b/extensions/discord/src/runtime.ts index 2dc10a295fd..b73ec43a065 100644 --- a/extensions/discord/src/runtime.ts +++ b/extensions/discord/src/runtime.ts @@ -1,5 +1,7 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; -import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import { + createPluginRuntimeStore, + type PluginRuntime, +} from "../../../src/plugin-sdk-internal/core.js"; const { setRuntime: setDiscordRuntime, getRuntime: getDiscordRuntime } = createPluginRuntimeStore("Discord runtime not initialized"); diff --git a/extensions/discord/src/setup-core.ts b/extensions/discord/src/setup-core.ts index 6b644fe87c6..fe2b559a975 100644 --- a/extensions/discord/src/setup-core.ts +++ b/extensions/discord/src/setup-core.ts @@ -1,10 +1,7 @@ +import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; import type { DiscordGuildEntry } from "../../../src/config/types.discord.js"; import { - applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, - formatDocsLink, - migrateBaseNameToDefaultAccount, - normalizeAccountId, noteChannelLookupFailure, noteChannelLookupSummary, parseMentionOrPrefixedId, @@ -18,6 +15,7 @@ import { type ChannelSetupDmPolicy, type ChannelSetupWizard, } from "../../../src/plugin-sdk-internal/setup.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; import { inspectDiscordAccount } from "./account-inspect.js"; import { listDiscordAccountIds, resolveDiscordAccount } from "./accounts.js"; @@ -72,15 +70,8 @@ export function parseDiscordAllowFromId(value: string): string | null { }); } -export const discordSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), +export const discordSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ + channelKey: channel, validateInput: ({ accountId, input }) => { if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { return "DISCORD_BOT_TOKEN can only be used for the default account."; @@ -90,57 +81,46 @@ export const discordSetupAdapter: ChannelSetupAdapter = { } return null; }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - discord: { - ...next.channels?.discord, - enabled: true, - ...(input.useEnv ? {} : input.token ? { token: input.token } : {}), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - discord: { - ...next.channels?.discord, - enabled: true, - accounts: { - ...next.channels?.discord?.accounts, - [accountId]: { - ...next.channels?.discord?.accounts?.[accountId], - enabled: true, - ...(input.token ? { token: input.token } : {}), - }, - }, - }, - }, - }; - }, + buildPatch: (input) => (input.useEnv ? {} : input.token ? { token: input.token } : {}), +}); + +type DiscordAllowFromResolverParams = { + cfg: OpenClawConfig; + accountId: string; + credentialValues: { token?: string }; + entries: string[]; }; -export function createDiscordSetupWizardProxy( - loadWizard: () => Promise<{ discordSetupWizard: ChannelSetupWizard }>, -) { +type DiscordGroupAllowlistResolverParams = DiscordAllowFromResolverParams & { + prompter: { note: (message: string, title?: string) => Promise }; +}; + +type DiscordGroupAllowlistResolution = Array<{ + input: string; + resolved: boolean; +}>; + +type DiscordSetupWizardHandlers = { + promptAllowFrom: (params: { + cfg: OpenClawConfig; + prompter: import("../../../src/plugin-sdk-internal/setup.js").WizardPrompter; + accountId?: string; + }) => Promise; + resolveAllowFromEntries: (params: DiscordAllowFromResolverParams) => Promise< + Array<{ + input: string; + resolved: boolean; + id: string | null; + }> + >; + resolveGroupAllowlist: ( + params: DiscordGroupAllowlistResolverParams, + ) => Promise; +}; + +export function createDiscordSetupWizardBase( + handlers: DiscordSetupWizardHandlers, +): ChannelSetupWizard { const discordDmPolicy: ChannelSetupDmPolicy = { label: "Discord", channel, @@ -154,13 +134,7 @@ export function createDiscordSetupWizardProxy( channel, dmPolicy: policy, }), - promptAllowFrom: async ({ cfg, prompter, accountId }) => { - const wizard = (await loadWizard()).discordSetupWizard; - if (!wizard.dmPolicy?.promptAllowFrom) { - return cfg; - } - return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId }); - }, + promptAllowFrom: handlers.promptAllowFrom, }; return { @@ -238,44 +212,22 @@ export function createDiscordSetupWizardProxy( accountId, patch: { groupPolicy: policy }, }), - resolveAllowlist: async ({ - cfg, - accountId, - credentialValues, - entries, - prompter, - }: { - cfg: OpenClawConfig; - accountId: string; - credentialValues: { token?: string }; - entries: string[]; - prompter: { note: (message: string, title?: string) => Promise }; - }) => { - const wizard = (await loadWizard()).discordSetupWizard; - if (!wizard.groupAccess?.resolveAllowlist) { - return entries.map((input) => ({ input, resolved: false })); - } + resolveAllowlist: async (params: DiscordGroupAllowlistResolverParams) => { try { - return await wizard.groupAccess.resolveAllowlist({ - cfg, - accountId, - credentialValues, - entries, - prompter, - }); + return await handlers.resolveGroupAllowlist(params); } catch (error) { await noteChannelLookupFailure({ - prompter, + prompter: params.prompter, label: "Discord channels", error, }); await noteChannelLookupSummary({ - prompter, + prompter: params.prompter, label: "Discord channels", resolvedSections: [], - unresolved: entries, + unresolved: params.entries, }); - return entries.map((input) => ({ input, resolved: false })); + return params.entries.map((input) => ({ input, resolved: false })); } }, applyAllowlist: ({ @@ -305,28 +257,7 @@ export function createDiscordSetupWizardProxy( invalidWithoutCredentialNote: "Bot token missing; use numeric user ids (or mention form) only.", parseId: parseDiscordAllowFromId, - resolveEntries: async ({ - cfg, - accountId, - credentialValues, - entries, - }: { - cfg: OpenClawConfig; - accountId: string; - credentialValues: { token?: string }; - entries: string[]; - }) => { - const wizard = (await loadWizard()).discordSetupWizard; - if (!wizard.allowFrom) { - return entries.map((input) => ({ input, resolved: false, id: null })); - } - return await wizard.allowFrom.resolveEntries({ - cfg, - accountId, - credentialValues, - entries, - }); - }, + resolveEntries: handlers.resolveAllowFromEntries, apply: async ({ cfg, accountId, @@ -347,3 +278,42 @@ export function createDiscordSetupWizardProxy( disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; } + +export function createDiscordSetupWizardProxy( + loadWizard: () => Promise<{ discordSetupWizard: ChannelSetupWizard }>, +) { + return createDiscordSetupWizardBase({ + promptAllowFrom: async ({ cfg, prompter, accountId }) => { + const wizard = (await loadWizard()).discordSetupWizard; + if (!wizard.dmPolicy?.promptAllowFrom) { + return cfg; + } + return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId }); + }, + resolveAllowFromEntries: async ({ cfg, accountId, credentialValues, entries }) => { + const wizard = (await loadWizard()).discordSetupWizard; + if (!wizard.allowFrom) { + return entries.map((input) => ({ input, resolved: false, id: null })); + } + return await wizard.allowFrom.resolveEntries({ + cfg, + accountId, + credentialValues, + entries, + }); + }, + resolveGroupAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { + const wizard = (await loadWizard()).discordSetupWizard; + if (!wizard.groupAccess?.resolveAllowlist) { + return entries.map((input) => ({ input, resolved: false })); + } + return (await wizard.groupAccess.resolveAllowlist({ + cfg, + accountId, + credentialValues, + entries, + prompter, + })) as DiscordGroupAllowlistResolution; + }, + }); +} diff --git a/extensions/discord/src/setup-surface.ts b/extensions/discord/src/setup-surface.ts index 2a59cbb1ed0..5f785db6f01 100644 --- a/extensions/discord/src/setup-surface.ts +++ b/extensions/discord/src/setup-surface.ts @@ -1,27 +1,14 @@ import { - DEFAULT_ACCOUNT_ID, - formatDocsLink, noteChannelLookupFailure, noteChannelLookupSummary, type OpenClawConfig, - parseMentionOrPrefixedId, - patchChannelConfigForAccount, promptLegacyChannelAllowFrom, resolveSetupAccountId, - setLegacyChannelDmPolicyWithAllowFrom, - setSetupChannelEnabled, type WizardPrompter, } from "../../../src/plugin-sdk-internal/setup.js"; -import { - type ChannelSetupDmPolicy, - type ChannelSetupWizard, -} from "../../../src/plugin-sdk-internal/setup.js"; -import { inspectDiscordAccount } from "./account-inspect.js"; -import { - listDiscordAccountIds, - resolveDefaultDiscordAccountId, - resolveDiscordAccount, -} from "./accounts.js"; +import { type ChannelSetupWizard } from "../../../src/plugin-sdk-internal/setup.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { resolveDefaultDiscordAccountId, resolveDiscordAccount } from "./accounts.js"; import { normalizeDiscordSlug } from "./monitor/allow-list.js"; import { resolveDiscordChannelAllowlist, @@ -29,6 +16,7 @@ import { } from "./resolve-channels.js"; import { resolveDiscordUserAllowlist } from "./resolve-users.js"; import { + createDiscordSetupWizardBase, discordSetupAdapter, DISCORD_TOKEN_HELP_LINES, parseDiscordAllowFromId, @@ -94,186 +82,62 @@ async function promptDiscordAllowFrom(params: { }); } -const discordDmPolicy: ChannelSetupDmPolicy = { - label: "Discord", - channel, - policyKey: "channels.discord.dmPolicy", - allowFromKey: "channels.discord.allowFrom", - getCurrent: (cfg) => - cfg.channels?.discord?.dmPolicy ?? cfg.channels?.discord?.dm?.policy ?? "pairing", - setPolicy: (cfg, policy) => - setLegacyChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy: policy, - }), +export const discordSetupWizard: ChannelSetupWizard = createDiscordSetupWizardBase({ promptAllowFrom: promptDiscordAllowFrom, -}; - -export const discordSetupWizard: ChannelSetupWizard = { - channel, - status: { - configuredLabel: "configured", - unconfiguredLabel: "needs token", - configuredHint: "configured", - unconfiguredHint: "needs token", - configuredScore: 2, - unconfiguredScore: 1, - resolveConfigured: ({ cfg }) => - listDiscordAccountIds(cfg).some( - (accountId) => inspectDiscordAccount({ cfg, accountId }).configured, - ), - }, - credentials: [ - { - inputKey: "token", - providerHint: channel, - credentialLabel: "Discord bot token", - preferredEnvVar: "DISCORD_BOT_TOKEN", - helpTitle: "Discord bot token", - helpLines: DISCORD_TOKEN_HELP_LINES, - envPrompt: "DISCORD_BOT_TOKEN detected. Use env var?", - keepPrompt: "Discord token already configured. Keep it?", - inputPrompt: "Enter Discord bot token", - allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, - inspect: ({ cfg, accountId }) => { - const account = inspectDiscordAccount({ cfg, accountId }); - return { - accountConfigured: account.configured, - hasConfiguredValue: account.tokenStatus !== "missing", - resolvedValue: account.token?.trim() || undefined, - envValue: - accountId === DEFAULT_ACCOUNT_ID - ? process.env.DISCORD_BOT_TOKEN?.trim() || undefined - : undefined, - }; - }, - }, - ], - groupAccess: { - label: "Discord channels", - placeholder: "My Server/#general, guildId/channelId, #support", - currentPolicy: ({ cfg, accountId }) => - resolveDiscordAccount({ cfg, accountId }).config.groupPolicy ?? "allowlist", - currentEntries: ({ cfg, accountId }) => - Object.entries(resolveDiscordAccount({ cfg, accountId }).config.guilds ?? {}).flatMap( - ([guildKey, value]) => { - const channels = value?.channels ?? {}; - const channelKeys = Object.keys(channels); - if (channelKeys.length === 0) { - const input = /^\d+$/.test(guildKey) ? `guild:${guildKey}` : guildKey; - return [input]; - } - return channelKeys.map((channelKey) => `${guildKey}/${channelKey}`); - }, - ), - updatePrompt: ({ cfg, accountId }) => - Boolean(resolveDiscordAccount({ cfg, accountId }).config.guilds), - setPolicy: ({ cfg, accountId, policy }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { groupPolicy: policy }, - }), - resolveAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { - const token = + resolveAllowFromEntries: async ({ cfg, accountId, credentialValues, entries }) => + await resolveDiscordAllowFromEntries({ + token: resolveDiscordAccount({ cfg, accountId }).token || - (typeof credentialValues.token === "string" ? credentialValues.token : ""); - let resolved: DiscordChannelResolution[] = entries.map((input) => ({ - input, - resolved: false, - })); - if (!token || entries.length === 0) { - return resolved; - } - try { - resolved = await resolveDiscordChannelAllowlist({ - token, - entries, - }); - const resolvedChannels = resolved.filter((entry) => entry.resolved && entry.channelId); - const resolvedGuilds = resolved.filter( - (entry) => entry.resolved && entry.guildId && !entry.channelId, - ); - const unresolved = resolved.filter((entry) => !entry.resolved).map((entry) => entry.input); - await noteChannelLookupSummary({ - prompter, - label: "Discord channels", - resolvedSections: [ - { - title: "Resolved channels", - values: resolvedChannels - .map((entry) => entry.channelId) - .filter((value): value is string => Boolean(value)), - }, - { - title: "Resolved guilds", - values: resolvedGuilds - .map((entry) => entry.guildId) - .filter((value): value is string => Boolean(value)), - }, - ], - unresolved, - }); - } catch (error) { - await noteChannelLookupFailure({ - prompter, - label: "Discord channels", - error, - }); - } + (typeof credentialValues.token === "string" ? credentialValues.token : ""), + entries, + }), + resolveGroupAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { + const token = + resolveDiscordAccount({ cfg, accountId }).token || + (typeof credentialValues.token === "string" ? credentialValues.token : ""); + let resolved: DiscordChannelResolution[] = entries.map((input) => ({ + input, + resolved: false, + })); + if (!token || entries.length === 0) { return resolved; - }, - applyAllowlist: ({ cfg, accountId, resolved }) => { - const allowlistEntries: Array<{ guildKey: string; channelKey?: string }> = []; - for (const entry of resolved as DiscordChannelResolution[]) { - const guildKey = - entry.guildId ?? - (entry.guildName ? normalizeDiscordSlug(entry.guildName) : undefined) ?? - "*"; - const channelKey = - entry.channelId ?? - (entry.channelName ? normalizeDiscordSlug(entry.channelName) : undefined); - if (!channelKey && guildKey === "*") { - continue; - } - allowlistEntries.push({ guildKey, ...(channelKey ? { channelKey } : {}) }); - } - return setDiscordGuildChannelAllowlist(cfg, accountId, allowlistEntries); - }, - }, - allowFrom: { - credentialInputKey: "token", - helpTitle: "Discord allowlist", - helpLines: [ - "Allowlist Discord DMs by username (we resolve to user ids).", - "Examples:", - "- 123456789012345678", - "- @alice", - "- alice#1234", - "Multiple entries: comma-separated.", - `Docs: ${formatDocsLink("/discord", "discord")}`, - ], - message: "Discord allowFrom (usernames or ids)", - placeholder: "@alice, 123456789012345678", - invalidWithoutCredentialNote: "Bot token missing; use numeric user ids (or mention form) only.", - parseId: parseDiscordAllowFromId, - resolveEntries: async ({ cfg, accountId, credentialValues, entries }) => - await resolveDiscordAllowFromEntries({ - token: - resolveDiscordAccount({ cfg, accountId }).token || - (typeof credentialValues.token === "string" ? credentialValues.token : ""), + } + try { + resolved = await resolveDiscordChannelAllowlist({ + token, entries, - }), - apply: async ({ cfg, accountId, allowFrom }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { dmPolicy: "allowlist", allowFrom }, - }), + }); + const resolvedChannels = resolved.filter((entry) => entry.resolved && entry.channelId); + const resolvedGuilds = resolved.filter( + (entry) => entry.resolved && entry.guildId && !entry.channelId, + ); + const unresolved = resolved.filter((entry) => !entry.resolved).map((entry) => entry.input); + await noteChannelLookupSummary({ + prompter, + label: "Discord channels", + resolvedSections: [ + { + title: "Resolved channels", + values: resolvedChannels + .map((entry) => entry.channelId) + .filter((value): value is string => Boolean(value)), + }, + { + title: "Resolved guilds", + values: resolvedGuilds + .map((entry) => entry.guildId) + .filter((value): value is string => Boolean(value)), + }, + ], + unresolved, + }); + } catch (error) { + await noteChannelLookupFailure({ + prompter, + label: "Discord channels", + error, + }); + } + return resolved; }, - dmPolicy: discordDmPolicy, - disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), -}; +}); diff --git a/extensions/discord/src/shared.ts b/extensions/discord/src/shared.ts new file mode 100644 index 00000000000..6a691252052 --- /dev/null +++ b/extensions/discord/src/shared.ts @@ -0,0 +1,94 @@ +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; +import { + createScopedAccountConfigAccessors, + formatAllowFromLowercase, +} from "openclaw/plugin-sdk/compat"; +import { + buildChannelConfigSchema, + DiscordConfigSchema, + getChatChannelMeta, + type ChannelPlugin, +} from "openclaw/plugin-sdk/discord"; +import { inspectDiscordAccount } from "./account-inspect.js"; +import { + listDiscordAccountIds, + resolveDefaultDiscordAccountId, + resolveDiscordAccount, + type ResolvedDiscordAccount, +} from "./accounts.js"; +import { createDiscordSetupWizardProxy } from "./setup-core.js"; + +export const DISCORD_CHANNEL = "discord" as const; + +async function loadDiscordChannelRuntime() { + return await import("./channel.runtime.js"); +} + +export const discordSetupWizard = createDiscordSetupWizardProxy(async () => ({ + discordSetupWizard: (await loadDiscordChannelRuntime()).discordSetupWizard, +})); + +export const discordConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom, + formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), + resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo, +}); + +export const discordConfigBase = createScopedChannelConfigBase({ + sectionKey: DISCORD_CHANNEL, + listAccountIds: listDiscordAccountIds, + resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultDiscordAccountId, + clearBaseFields: ["token", "name"], +}); + +export function createDiscordPluginBase(params: { + setup: NonNullable["setup"]>; +}): Pick< + ChannelPlugin, + | "id" + | "meta" + | "setupWizard" + | "capabilities" + | "streaming" + | "reload" + | "configSchema" + | "config" + | "setup" +> { + return { + id: DISCORD_CHANNEL, + meta: { + ...getChatChannelMeta(DISCORD_CHANNEL), + }, + setupWizard: discordSetupWizard, + capabilities: { + chatTypes: ["direct", "channel", "thread"], + polls: true, + reactions: true, + threads: true, + media: true, + nativeCommands: true, + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.discord"] }, + configSchema: buildChannelConfigSchema(DiscordConfigSchema), + config: { + ...discordConfigBase, + isConfigured: (account) => Boolean(account.token?.trim()), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.token?.trim()), + tokenSource: account.tokenSource, + }), + ...discordConfigAccessors, + }, + setup: params.setup, + }; +} diff --git a/extensions/discord/src/subagent-hooks.ts b/extensions/discord/src/subagent-hooks.ts index c9ba7b97984..fa45eadd7c2 100644 --- a/extensions/discord/src/subagent-hooks.ts +++ b/extensions/discord/src/subagent-hooks.ts @@ -1,4 +1,4 @@ -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import type { OpenClawPluginApi } from "../../../src/plugin-sdk-internal/core.js"; import { resolveDiscordAccount } from "./accounts.js"; import { autoBindSpawnedDiscordSubagent, diff --git a/extensions/feishu/src/setup-core.ts b/extensions/feishu/src/setup-core.ts index ada8ef79933..a9c6639a2f7 100644 --- a/extensions/feishu/src/setup-core.ts +++ b/extensions/feishu/src/setup-core.ts @@ -1,6 +1,8 @@ -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { + DEFAULT_ACCOUNT_ID, + type ChannelSetupAdapter, + type OpenClawConfig, +} from "openclaw/plugin-sdk/setup"; import type { FeishuConfig } from "./types.js"; export function setFeishuNamedAccountEnabled( diff --git a/extensions/feishu/src/setup-surface.ts b/extensions/feishu/src/setup-surface.ts index 4f92b07a804..e990f308624 100644 --- a/extensions/feishu/src/setup-surface.ts +++ b/extensions/feishu/src/setup-surface.ts @@ -1,20 +1,20 @@ import { buildSingleChannelSecretPromptState, + DEFAULT_ACCOUNT_ID, + formatDocsLink, + hasConfiguredSecretInput, mergeAllowFromEntries, promptSingleChannelSecretInput, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, setTopLevelChannelGroupPolicy, splitSetupEntries, -} from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js"; -import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { DmPolicy } from "../../../src/config/types.js"; -import type { SecretInput } from "../../../src/config/types.secrets.js"; -import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; + type ChannelSetupDmPolicy, + type ChannelSetupWizard, + type DmPolicy, + type OpenClawConfig, + type SecretInput, +} from "openclaw/plugin-sdk/setup"; import { listFeishuAccountIds, resolveFeishuCredentials } from "./accounts.js"; import { probeFeishu } from "./probe.js"; import { feishuSetupAdapter } from "./setup-core.js"; diff --git a/extensions/google/gemini-cli-provider.ts b/extensions/google/gemini-cli-provider.ts index 926913f7390..e235a0dfebc 100644 --- a/extensions/google/gemini-cli-provider.ts +++ b/extensions/google/gemini-cli-provider.ts @@ -1,5 +1,5 @@ +import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/core"; import { fetchGeminiUsage } from "../../src/infra/provider-usage.fetch.js"; -import { buildOauthProviderAuthResult } from "../../src/plugin-sdk/provider-auth-result.js"; import type { OpenClawPluginApi, ProviderAuthContext, diff --git a/extensions/google/index.ts b/extensions/google/index.ts index 59d417e9349..6389dd25e48 100644 --- a/extensions/google/index.ts +++ b/extensions/google/index.ts @@ -11,6 +11,7 @@ import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; import type { OpenClawPluginApi } from "../../src/plugins/types.js"; import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js"; +import { googleMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js"; const googlePlugin = { @@ -51,6 +52,7 @@ const googlePlugin = { isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId), }); registerGoogleGeminiCliProvider(api); + api.registerMediaUnderstandingProvider(googleMediaUnderstandingProvider); api.registerWebSearchProvider( createPluginBackedWebSearchProvider({ id: "gemini", diff --git a/extensions/google/media-understanding-provider.ts b/extensions/google/media-understanding-provider.ts new file mode 100644 index 00000000000..559bd4c63b8 --- /dev/null +++ b/extensions/google/media-understanding-provider.ts @@ -0,0 +1,150 @@ +import { normalizeGoogleModelId } from "../../src/agents/model-id-normalization.js"; +import { parseGeminiAuth } from "../../src/infra/gemini-auth.js"; +import { describeImageWithModel } from "../../src/media-understanding/providers/image.js"; +import { + assertOkOrThrowHttpError, + normalizeBaseUrl, + postJsonRequest, +} from "../../src/media-understanding/providers/shared.js"; +import type { + AudioTranscriptionRequest, + AudioTranscriptionResult, + MediaUnderstandingProvider, + VideoDescriptionRequest, + VideoDescriptionResult, +} from "../../src/media-understanding/types.js"; + +export const DEFAULT_GOOGLE_AUDIO_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; +export const DEFAULT_GOOGLE_VIDEO_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; +const DEFAULT_GOOGLE_AUDIO_MODEL = "gemini-3-flash-preview"; +const DEFAULT_GOOGLE_VIDEO_MODEL = "gemini-3-flash-preview"; +const DEFAULT_GOOGLE_AUDIO_PROMPT = "Transcribe the audio."; +const DEFAULT_GOOGLE_VIDEO_PROMPT = "Describe the video."; + +async function generateGeminiInlineDataText(params: { + buffer: Buffer; + mime?: string; + apiKey: string; + baseUrl?: string; + headers?: Record; + model?: string; + prompt?: string; + timeoutMs: number; + fetchFn?: typeof fetch; + defaultBaseUrl: string; + defaultModel: string; + defaultPrompt: string; + defaultMime: string; + httpErrorLabel: string; + missingTextError: string; +}): Promise<{ text: string; model: string }> { + const fetchFn = params.fetchFn ?? fetch; + const baseUrl = normalizeBaseUrl(params.baseUrl, params.defaultBaseUrl); + const allowPrivate = Boolean(params.baseUrl?.trim()); + const model = (() => { + const trimmed = params.model?.trim(); + if (!trimmed) { + return params.defaultModel; + } + return normalizeGoogleModelId(trimmed); + })(); + const url = `${baseUrl}/models/${model}:generateContent`; + + const authHeaders = parseGeminiAuth(params.apiKey); + const headers = new Headers(params.headers); + for (const [key, value] of Object.entries(authHeaders.headers)) { + if (!headers.has(key)) { + headers.set(key, value); + } + } + + const prompt = (() => { + const trimmed = params.prompt?.trim(); + return trimmed || params.defaultPrompt; + })(); + + const body = { + contents: [ + { + role: "user", + parts: [ + { text: prompt }, + { + inline_data: { + mime_type: params.mime ?? params.defaultMime, + data: params.buffer.toString("base64"), + }, + }, + ], + }, + ], + }; + + const { response: res, release } = await postJsonRequest({ + url, + headers, + body, + timeoutMs: params.timeoutMs, + fetchFn, + allowPrivateNetwork: allowPrivate, + }); + + try { + await assertOkOrThrowHttpError(res, params.httpErrorLabel); + + const payload = (await res.json()) as { + candidates?: Array<{ + content?: { parts?: Array<{ text?: string }> }; + }>; + }; + const parts = payload.candidates?.[0]?.content?.parts ?? []; + const text = parts + .map((part) => part?.text?.trim()) + .filter(Boolean) + .join("\n"); + if (!text) { + throw new Error(params.missingTextError); + } + return { text, model }; + } finally { + await release(); + } +} + +export async function transcribeGeminiAudio( + params: AudioTranscriptionRequest, +): Promise { + const { text, model } = await generateGeminiInlineDataText({ + ...params, + defaultBaseUrl: DEFAULT_GOOGLE_AUDIO_BASE_URL, + defaultModel: DEFAULT_GOOGLE_AUDIO_MODEL, + defaultPrompt: DEFAULT_GOOGLE_AUDIO_PROMPT, + defaultMime: "audio/wav", + httpErrorLabel: "Audio transcription failed", + missingTextError: "Audio transcription response missing text", + }); + return { text, model }; +} + +export async function describeGeminiVideo( + params: VideoDescriptionRequest, +): Promise { + const { text, model } = await generateGeminiInlineDataText({ + ...params, + defaultBaseUrl: DEFAULT_GOOGLE_VIDEO_BASE_URL, + defaultModel: DEFAULT_GOOGLE_VIDEO_MODEL, + defaultPrompt: DEFAULT_GOOGLE_VIDEO_PROMPT, + defaultMime: "video/mp4", + httpErrorLabel: "Video description failed", + missingTextError: "Video description response missing text", + }); + return { text, model }; +} + +export const googleMediaUnderstandingProvider: MediaUnderstandingProvider = { + id: "google", + capabilities: ["image", "audio", "video"], + describeImage: describeImageWithModel, + transcribeAudio: transcribeGeminiAudio, + describeVideo: describeGeminiVideo, +}; diff --git a/extensions/googlechat/src/setup-core.ts b/extensions/googlechat/src/setup-core.ts index d4d2de49e06..09980bad5cd 100644 --- a/extensions/googlechat/src/setup-core.ts +++ b/extensions/googlechat/src/setup-core.ts @@ -1,22 +1,10 @@ -import { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; const channel = "googlechat" as const; -export const googlechatSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), +export const googlechatSetupAdapter = createPatchedAccountSetupAdapter({ + channelKey: channel, validateInput: ({ accountId, input }) => { if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { return "GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account."; @@ -26,20 +14,7 @@ export const googlechatSetupAdapter: ChannelSetupAdapter = { } return null; }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; + buildPatch: (input) => { const patch = input.useEnv ? {} : input.tokenFile @@ -51,17 +26,12 @@ export const googlechatSetupAdapter: ChannelSetupAdapter = { const audience = input.audience?.trim(); const webhookPath = input.webhookPath?.trim(); const webhookUrl = input.webhookUrl?.trim(); - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: channel, - accountId, - patch: { - ...patch, - ...(audienceType ? { audienceType } : {}), - ...(audience ? { audience } : {}), - ...(webhookPath ? { webhookPath } : {}), - ...(webhookUrl ? { webhookUrl } : {}), - }, - }); + return { + ...patch, + ...(audienceType ? { audienceType } : {}), + ...(audience ? { audience } : {}), + ...(webhookPath ? { webhookPath } : {}), + ...(webhookUrl ? { webhookUrl } : {}), + }; }, -}; +}); diff --git a/extensions/googlechat/src/setup-surface.ts b/extensions/googlechat/src/setup-surface.ts index 5561989543f..0af6e3d4f54 100644 --- a/extensions/googlechat/src/setup-surface.ts +++ b/extensions/googlechat/src/setup-surface.ts @@ -1,19 +1,17 @@ -import { - applySetupAccountConfigPatch, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; import { addWildcardAllowFrom, + applySetupAccountConfigPatch, + DEFAULT_ACCOUNT_ID, + formatDocsLink, mergeAllowFromEntries, + migrateBaseNameToDefaultAccount, setTopLevelChannelDmPolicyWithAllowFrom, splitSetupEntries, -} from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js"; -import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { DmPolicy } from "../../../src/config/types.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; + type ChannelSetupDmPolicy, + type ChannelSetupWizard, + type DmPolicy, + type OpenClawConfig, +} from "openclaw/plugin-sdk/setup"; import { listGoogleChatAccountIds, resolveDefaultGoogleChatAccountId, diff --git a/extensions/huggingface/index.ts b/extensions/huggingface/index.ts index 63598ce0236..433223bf268 100644 --- a/extensions/huggingface/index.ts +++ b/extensions/huggingface/index.ts @@ -1,9 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { - applyHuggingfaceConfig, - HUGGINGFACE_DEFAULT_MODEL_REF, -} from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { applyHuggingfaceConfig, HUGGINGFACE_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildHuggingfaceProvider } from "./provider-catalog.js"; const PROVIDER_ID = "huggingface"; diff --git a/extensions/huggingface/onboard.ts b/extensions/huggingface/onboard.ts new file mode 100644 index 00000000000..22493f87f0b --- /dev/null +++ b/extensions/huggingface/onboard.ts @@ -0,0 +1,35 @@ +import { + buildHuggingfaceModelDefinition, + HUGGINGFACE_BASE_URL, + HUGGINGFACE_MODEL_CATALOG, +} from "../../src/agents/huggingface-models.js"; +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithModelCatalog, +} from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; + +export const HUGGINGFACE_DEFAULT_MODEL_REF = "huggingface/deepseek-ai/DeepSeek-R1"; + +export function applyHuggingfaceProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[HUGGINGFACE_DEFAULT_MODEL_REF] = { + ...models[HUGGINGFACE_DEFAULT_MODEL_REF], + alias: models[HUGGINGFACE_DEFAULT_MODEL_REF]?.alias ?? "Hugging Face", + }; + + return applyProviderConfigWithModelCatalog(cfg, { + agentModels: models, + providerId: "huggingface", + api: "openai-completions", + baseUrl: HUGGINGFACE_BASE_URL, + catalogModels: HUGGINGFACE_MODEL_CATALOG.map(buildHuggingfaceModelDefinition), + }); +} + +export function applyHuggingfaceConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyHuggingfaceProviderConfig(cfg), + HUGGINGFACE_DEFAULT_MODEL_REF, + ); +} diff --git a/extensions/imessage/src/accounts.ts b/extensions/imessage/src/accounts.ts index 21c3c36d356..67ffb5e6865 100644 --- a/extensions/imessage/src/accounts.ts +++ b/extensions/imessage/src/accounts.ts @@ -1,10 +1,10 @@ -import type { IMessageAccountConfig } from "openclaw/plugin-sdk/imessage"; import { type OpenClawConfig, createAccountListHelpers, normalizeAccountId, resolveAccountEntry, } from "../../../src/plugin-sdk-internal/accounts.js"; +import type { IMessageAccountConfig } from "../../../src/plugin-sdk-internal/imessage.js"; export type ResolvedIMessageAccount = { accountId: string; diff --git a/extensions/imessage/src/channel.setup.ts b/extensions/imessage/src/channel.setup.ts index a4e58844b3b..5587914a0ce 100644 --- a/extensions/imessage/src/channel.setup.ts +++ b/extensions/imessage/src/channel.setup.ts @@ -1,101 +1,11 @@ -import { - buildAccountScopedDmSecurityPolicy, - collectAllowlistProviderRestrictSendersWarnings, -} from "openclaw/plugin-sdk/compat"; -import { - buildChannelConfigSchema, - DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, - formatTrimmedAllowFromEntries, - getChatChannelMeta, - IMessageConfigSchema, - resolveIMessageConfigAllowFrom, - resolveIMessageConfigDefaultTo, - setAccountEnabledInConfigSection, - type ChannelPlugin, -} from "openclaw/plugin-sdk/imessage"; -import { - listIMessageAccountIds, - resolveDefaultIMessageAccountId, - resolveIMessageAccount, - type ResolvedIMessageAccount, -} from "./accounts.js"; -import { createIMessageSetupWizardProxy, imessageSetupAdapter } from "./setup-core.js"; +import { type ChannelPlugin } from "openclaw/plugin-sdk/imessage"; +import { type ResolvedIMessageAccount } from "./accounts.js"; +import { imessageSetupAdapter } from "./setup-core.js"; +import { createIMessagePluginBase, imessageSetupWizard } from "./shared.js"; -async function loadIMessageChannelRuntime() { - return await import("./channel.runtime.js"); -} - -const imessageSetupWizard = createIMessageSetupWizardProxy(async () => ({ - imessageSetupWizard: (await loadIMessageChannelRuntime()).imessageSetupWizard, -})); - -export const imessageSetupPlugin: ChannelPlugin = { - id: "imessage", - meta: { - ...getChatChannelMeta("imessage"), - aliases: ["imsg"], - showConfigured: false, +export const imessageSetupPlugin: ChannelPlugin = createIMessagePluginBase( + { + setupWizard: imessageSetupWizard, + setup: imessageSetupAdapter, }, - setupWizard: imessageSetupWizard, - capabilities: { - chatTypes: ["direct", "group"], - media: true, - }, - reload: { configPrefixes: ["channels.imessage"] }, - configSchema: buildChannelConfigSchema(IMessageConfigSchema), - config: { - listAccountIds: (cfg) => listIMessageAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultIMessageAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "imessage", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "imessage", - accountId, - clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"], - }), - isConfigured: (account) => account.configured, - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - }), - resolveAllowFrom: ({ cfg, accountId }) => resolveIMessageConfigAllowFrom({ cfg, accountId }), - formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom), - resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }), - }, - security: { - resolveDmPolicy: ({ cfg, accountId, account }) => - buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "imessage", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - }), - collectWarnings: ({ account, cfg }) => - collectAllowlistProviderRestrictSendersWarnings({ - cfg, - providerConfigPresent: cfg.channels?.imessage !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - surface: "iMessage groups", - openScope: "any member", - groupPolicyPath: "channels.imessage.groupPolicy", - groupAllowFromPath: "channels.imessage.groupAllowFrom", - mentionGated: false, - }), - }, - setup: imessageSetupAdapter, -}; +); diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index b0d94a1a437..95cac7d1123 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -1,50 +1,25 @@ -import { - buildAccountScopedAllowlistConfigEditor, - buildAccountScopedDmSecurityPolicy, - collectAllowlistProviderRestrictSendersWarnings, -} from "openclaw/plugin-sdk/compat"; +import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/compat"; import { buildAgentSessionKey, type RoutePeer } from "openclaw/plugin-sdk/core"; import { - buildChannelConfigSchema, collectStatusIssuesFromLastError, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, formatTrimmedAllowFromEntries, - getChatChannelMeta, - IMessageConfigSchema, looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, - resolveIMessageConfigAllowFrom, - resolveIMessageConfigDefaultTo, resolveIMessageGroupRequireMention, resolveIMessageGroupToolPolicy, - setAccountEnabledInConfigSection, type ChannelPlugin, } from "openclaw/plugin-sdk/imessage"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; -import { - listIMessageAccountIds, - resolveDefaultIMessageAccountId, - resolveIMessageAccount, - type ResolvedIMessageAccount, -} from "./accounts.js"; +import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js"; import { getIMessageRuntime } from "./runtime.js"; -import { createIMessageSetupWizardProxy, imessageSetupAdapter } from "./setup-core.js"; +import { imessageSetupAdapter } from "./setup-core.js"; +import { createIMessagePluginBase, imessageSetupWizard } from "./shared.js"; import { normalizeIMessageHandle, parseIMessageTarget } from "./targets.js"; -const meta = getChatChannelMeta("imessage"); - -async function loadIMessageChannelRuntime() { - return await import("./channel.runtime.js"); -} - -const imessageSetupWizard = createIMessageSetupWizardProxy(async () => ({ - imessageSetupWizard: (await loadIMessageChannelRuntime()).imessageSetupWizard, -})); - type IMessageSendFn = ReturnType< typeof getIMessageRuntime >["channel"]["imessage"]["sendMessageIMessage"]; @@ -157,55 +132,16 @@ function resolveIMessageOutboundSessionRoute(params: { } export const imessagePlugin: ChannelPlugin = { - id: "imessage", - meta: { - ...meta, - aliases: ["imsg"], - showConfigured: false, - }, - setupWizard: imessageSetupWizard, + ...createIMessagePluginBase({ + setupWizard: imessageSetupWizard, + setup: imessageSetupAdapter, + }), pairing: { idLabel: "imessageSenderId", notifyApproval: async ({ id }) => { await getIMessageRuntime().channel.imessage.sendMessageIMessage(id, PAIRING_APPROVED_MESSAGE); }, }, - capabilities: { - chatTypes: ["direct", "group"], - media: true, - }, - reload: { configPrefixes: ["channels.imessage"] }, - configSchema: buildChannelConfigSchema(IMessageConfigSchema), - config: { - listAccountIds: (cfg) => listIMessageAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultIMessageAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "imessage", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "imessage", - accountId, - clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"], - }), - isConfigured: (account) => account.configured, - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - }), - resolveAllowFrom: ({ cfg, accountId }) => resolveIMessageConfigAllowFrom({ cfg, accountId }), - formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom), - resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }), - }, allowlist: { supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", readConfig: ({ cfg, accountId }) => { @@ -226,31 +162,6 @@ export const imessagePlugin: ChannelPlugin = { }), }), }, - security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "imessage", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - }); - }, - collectWarnings: ({ account, cfg }) => { - return collectAllowlistProviderRestrictSendersWarnings({ - cfg, - providerConfigPresent: cfg.channels?.imessage !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - surface: "iMessage groups", - openScope: "any member", - groupPolicyPath: "channels.imessage.groupPolicy", - groupAllowFromPath: "channels.imessage.groupAllowFrom", - mentionGated: false, - }); - }, - }, groups: { resolveRequireMention: resolveIMessageGroupRequireMention, resolveToolPolicy: resolveIMessageGroupToolPolicy, @@ -263,7 +174,6 @@ export const imessagePlugin: ChannelPlugin = { hint: "", }, }, - setup: imessageSetupAdapter, outbound: { deliveryMode: "direct", chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit), diff --git a/extensions/imessage/src/plugin-shared.ts b/extensions/imessage/src/plugin-shared.ts new file mode 100644 index 00000000000..c7ed39cd21a --- /dev/null +++ b/extensions/imessage/src/plugin-shared.ts @@ -0,0 +1,11 @@ +import { type ChannelPlugin } from "../../../src/plugin-sdk-internal/imessage.js"; +import { type ResolvedIMessageAccount } from "./accounts.js"; +import { createIMessageSetupWizardProxy } from "./setup-core.js"; + +async function loadIMessageChannelRuntime() { + return await import("./channel.runtime.js"); +} + +export const imessageSetupWizard = createIMessageSetupWizardProxy(async () => ({ + imessageSetupWizard: (await loadIMessageChannelRuntime()).imessageSetupWizard, +})) satisfies NonNullable["setupWizard"]>; diff --git a/extensions/imessage/src/runtime.ts b/extensions/imessage/src/runtime.ts index 08c9b6ccbbd..3a49020348f 100644 --- a/extensions/imessage/src/runtime.ts +++ b/extensions/imessage/src/runtime.ts @@ -1,5 +1,7 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; -import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import { + createPluginRuntimeStore, + type PluginRuntime, +} from "../../../src/plugin-sdk-internal/core.js"; const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } = createPluginRuntimeStore("iMessage runtime not initialized"); diff --git a/extensions/imessage/src/setup-core.ts b/extensions/imessage/src/setup-core.ts index ada78cc9add..45f385e0691 100644 --- a/extensions/imessage/src/setup-core.ts +++ b/extensions/imessage/src/setup-core.ts @@ -1,9 +1,5 @@ +import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; import { - applyAccountNameToChannelSection, - DEFAULT_ACCOUNT_ID, - formatDocsLink, - migrateBaseNameToDefaultAccount, - normalizeAccountId, parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, setChannelDmPolicyWithAllowFrom, @@ -16,6 +12,7 @@ import type { ChannelSetupDmPolicy, ChannelSetupWizard, } from "../../../src/plugin-sdk-internal/setup.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; import { listIMessageAccountIds, resolveDefaultIMessageAccountId, @@ -98,66 +95,23 @@ async function promptIMessageAllowFrom(params: { }); } -export const imessageSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - imessage: { - ...next.channels?.imessage, - enabled: true, - ...buildIMessageSetupPatch(input), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - imessage: { - ...next.channels?.imessage, - enabled: true, - accounts: { - ...next.channels?.imessage?.accounts, - [accountId]: { - ...next.channels?.imessage?.accounts?.[accountId], - enabled: true, - ...buildIMessageSetupPatch(input), - }, - }, - }, - }, - }; - }, +export const imessageSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ + channelKey: channel, + buildPatch: (input) => buildIMessageSetupPatch(input), +}); + +type IMessageSetupWizardHandlers = { + resolveStatusLines: NonNullable["resolveStatusLines"]; + resolveSelectionHint: NonNullable["resolveSelectionHint"]; + resolveQuickstartScore: NonNullable["resolveQuickstartScore"]; + shouldPromptCliPath: NonNullable< + NonNullable[number]["shouldPrompt"] + >; }; -export function createIMessageSetupWizardProxy( - loadWizard: () => Promise<{ imessageSetupWizard: ChannelSetupWizard }>, -) { +export function createIMessageSetupWizardBase( + handlers: IMessageSetupWizardHandlers, +): ChannelSetupWizard { const imessageDmPolicy: ChannelSetupDmPolicy = { label: "iMessage", channel, @@ -193,12 +147,9 @@ export function createIMessageSetupWizardProxy( account.config.region, ); }), - resolveStatusLines: async (params) => - (await loadWizard()).imessageSetupWizard.status.resolveStatusLines?.(params) ?? [], - resolveSelectionHint: async (params) => - await (await loadWizard()).imessageSetupWizard.status.resolveSelectionHint?.(params), - resolveQuickstartScore: async (params) => - await (await loadWizard()).imessageSetupWizard.status.resolveQuickstartScore?.(params), + resolveStatusLines: handlers.resolveStatusLines, + resolveSelectionHint: handlers.resolveSelectionHint, + resolveQuickstartScore: handlers.resolveQuickstartScore, }, credentials: [], textInputs: [ @@ -209,12 +160,7 @@ export function createIMessageSetupWizardProxy( resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg", currentValue: ({ cfg, accountId }) => resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg", - shouldPrompt: async (params) => { - const input = (await loadWizard()).imessageSetupWizard.textInputs?.find( - (entry) => entry.inputKey === "cliPath", - ); - return (await input?.shouldPrompt?.(params)) ?? false; - }, + shouldPrompt: handlers.shouldPromptCliPath, confirmCurrentValue: false, applyCurrentValue: true, helpTitle: "iMessage", @@ -235,3 +181,22 @@ export function createIMessageSetupWizardProxy( disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; } + +export function createIMessageSetupWizardProxy( + loadWizard: () => Promise<{ imessageSetupWizard: ChannelSetupWizard }>, +) { + return createIMessageSetupWizardBase({ + resolveStatusLines: async (params) => + (await loadWizard()).imessageSetupWizard.status.resolveStatusLines?.(params) ?? [], + resolveSelectionHint: async (params) => + await (await loadWizard()).imessageSetupWizard.status.resolveSelectionHint?.(params), + resolveQuickstartScore: async (params) => + await (await loadWizard()).imessageSetupWizard.status.resolveQuickstartScore?.(params), + shouldPromptCliPath: async (params) => { + const input = (await loadWizard()).imessageSetupWizard.textInputs?.find( + (entry) => entry.inputKey === "cliPath", + ); + return (await input?.shouldPrompt?.(params)) ?? false; + }, + }); +} diff --git a/extensions/imessage/src/setup-surface.ts b/extensions/imessage/src/setup-surface.ts index b8487dff54d..c1158960cec 100644 --- a/extensions/imessage/src/setup-surface.ts +++ b/extensions/imessage/src/setup-surface.ts @@ -1,137 +1,23 @@ -import { - DEFAULT_ACCOUNT_ID, - detectBinary, - formatDocsLink, - type OpenClawConfig, - parseSetupEntriesAllowingWildcard, - promptParsedAllowFromForScopedChannel, - setChannelDmPolicyWithAllowFrom, - setSetupChannelEnabled, - type WizardPrompter, -} from "../../../src/plugin-sdk-internal/setup.js"; -import type { - ChannelSetupDmPolicy, - ChannelSetupWizard, -} from "../../../src/plugin-sdk-internal/setup.js"; -import { - listIMessageAccountIds, - resolveDefaultIMessageAccountId, - resolveIMessageAccount, -} from "./accounts.js"; -import { imessageSetupAdapter, parseIMessageAllowFromEntries } from "./setup-core.js"; +import { detectBinary } from "../../../src/plugin-sdk-internal/setup.js"; +import { createIMessageSetupWizardBase, imessageSetupAdapter } from "./setup-core.js"; -const channel = "imessage" as const; - -async function promptIMessageAllowFrom(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId?: string; -}): Promise { - return promptParsedAllowFromForScopedChannel({ - cfg: params.cfg, - channel, - accountId: params.accountId, - defaultAccountId: resolveDefaultIMessageAccountId(params.cfg), - prompter: params.prompter, - noteTitle: "iMessage allowlist", - noteLines: [ - "Allowlist iMessage DMs by handle or chat target.", - "Examples:", - "- +15555550123", - "- user@example.com", - "- chat_id:123", - "- chat_guid:... or chat_identifier:...", - "Multiple entries: comma-separated.", - `Docs: ${formatDocsLink("/imessage", "imessage")}`, - ], - message: "iMessage allowFrom (handle or chat_id)", - placeholder: "+15555550123, user@example.com, chat_id:123", - parseEntries: parseIMessageAllowFromEntries, - getExistingAllowFrom: ({ cfg, accountId }) => - resolveIMessageAccount({ cfg, accountId }).config.allowFrom ?? [], - }); -} - -const imessageDmPolicy: ChannelSetupDmPolicy = { - label: "iMessage", - channel, - policyKey: "channels.imessage.dmPolicy", - allowFromKey: "channels.imessage.allowFrom", - getCurrent: (cfg) => cfg.channels?.imessage?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => - setChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy: policy, - }), - promptAllowFrom: promptIMessageAllowFrom, -}; - -export const imessageSetupWizard: ChannelSetupWizard = { - channel, - status: { - configuredLabel: "configured", - unconfiguredLabel: "needs setup", - configuredHint: "imsg found", - unconfiguredHint: "imsg missing", - configuredScore: 1, - unconfiguredScore: 0, - resolveConfigured: ({ cfg }) => - listIMessageAccountIds(cfg).some((accountId) => { - const account = resolveIMessageAccount({ cfg, accountId }); - return Boolean( - account.config.cliPath || - account.config.dbPath || - account.config.allowFrom || - account.config.service || - account.config.region, - ); - }), - resolveStatusLines: async ({ cfg, configured }) => { - const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; - const cliDetected = await detectBinary(cliPath); - return [ - `iMessage: ${configured ? "configured" : "needs setup"}`, - `imsg: ${cliDetected ? "found" : "missing"} (${cliPath})`, - ]; - }, - resolveSelectionHint: async ({ cfg }) => { - const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; - return (await detectBinary(cliPath)) ? "imsg found" : "imsg missing"; - }, - resolveQuickstartScore: async ({ cfg }) => { - const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; - return (await detectBinary(cliPath)) ? 1 : 0; - }, +export const imessageSetupWizard = createIMessageSetupWizardBase({ + resolveStatusLines: async ({ cfg, configured }) => { + const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; + const cliDetected = await detectBinary(cliPath); + return [ + `iMessage: ${configured ? "configured" : "needs setup"}`, + `imsg: ${cliDetected ? "found" : "missing"} (${cliPath})`, + ]; }, - credentials: [], - textInputs: [ - { - inputKey: "cliPath", - message: "imsg CLI path", - initialValue: ({ cfg, accountId }) => - resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg", - currentValue: ({ cfg, accountId }) => - resolveIMessageAccount({ cfg, accountId }).config.cliPath ?? "imsg", - shouldPrompt: async ({ currentValue }) => !(await detectBinary(currentValue ?? "imsg")), - confirmCurrentValue: false, - applyCurrentValue: true, - helpTitle: "iMessage", - helpLines: ["imsg CLI path required to enable iMessage."], - }, - ], - completionNote: { - title: "iMessage next steps", - lines: [ - "This is still a work in progress.", - "Ensure OpenClaw has Full Disk Access to Messages DB.", - "Grant Automation permission for Messages when prompted.", - "List chats with: imsg chats --limit 20", - `Docs: ${formatDocsLink("/imessage", "imessage")}`, - ], + resolveSelectionHint: async ({ cfg }) => { + const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; + return (await detectBinary(cliPath)) ? "imsg found" : "imsg missing"; }, - dmPolicy: imessageDmPolicy, - disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), -}; - -export { imessageSetupAdapter, parseIMessageAllowFromEntries }; + resolveQuickstartScore: async ({ cfg }) => { + const cliPath = cfg.channels?.imessage?.cliPath ?? "imsg"; + return (await detectBinary(cliPath)) ? 1 : 0; + }, + shouldPromptCliPath: async ({ currentValue }) => !(await detectBinary(currentValue ?? "imsg")), +}); +export { imessageSetupAdapter, parseIMessageAllowFromEntries } from "./setup-core.js"; diff --git a/extensions/imessage/src/shared.ts b/extensions/imessage/src/shared.ts new file mode 100644 index 00000000000..c4c62f20494 --- /dev/null +++ b/extensions/imessage/src/shared.ts @@ -0,0 +1,119 @@ +import { + buildAccountScopedDmSecurityPolicy, + collectAllowlistProviderRestrictSendersWarnings, +} from "openclaw/plugin-sdk/compat"; +import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + formatTrimmedAllowFromEntries, + getChatChannelMeta, + IMessageConfigSchema, + resolveIMessageConfigAllowFrom, + resolveIMessageConfigDefaultTo, + setAccountEnabledInConfigSection, + type ChannelPlugin, +} from "openclaw/plugin-sdk/imessage"; +import { + listIMessageAccountIds, + resolveDefaultIMessageAccountId, + resolveIMessageAccount, + type ResolvedIMessageAccount, +} from "./accounts.js"; +import { createIMessageSetupWizardProxy } from "./setup-core.js"; + +export const IMESSAGE_CHANNEL = "imessage" as const; + +async function loadIMessageChannelRuntime() { + return await import("./channel.runtime.js"); +} + +export const imessageSetupWizard = createIMessageSetupWizardProxy(async () => ({ + imessageSetupWizard: (await loadIMessageChannelRuntime()).imessageSetupWizard, +})); + +export function createIMessagePluginBase(params: { + setupWizard?: NonNullable["setupWizard"]>; + setup: NonNullable["setup"]>; +}): Pick< + ChannelPlugin, + | "id" + | "meta" + | "setupWizard" + | "capabilities" + | "reload" + | "configSchema" + | "config" + | "security" + | "setup" +> { + return { + id: IMESSAGE_CHANNEL, + meta: { + ...getChatChannelMeta(IMESSAGE_CHANNEL), + aliases: ["imsg"], + showConfigured: false, + }, + setupWizard: params.setupWizard, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + }, + reload: { configPrefixes: ["channels.imessage"] }, + configSchema: buildChannelConfigSchema(IMessageConfigSchema), + config: { + listAccountIds: (cfg) => listIMessageAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultIMessageAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: IMESSAGE_CHANNEL, + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: IMESSAGE_CHANNEL, + accountId, + clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"], + }), + isConfigured: (account) => account.configured, + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + }), + resolveAllowFrom: ({ cfg, accountId }) => resolveIMessageConfigAllowFrom({ cfg, accountId }), + formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom), + resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => + buildAccountScopedDmSecurityPolicy({ + cfg, + channelKey: IMESSAGE_CHANNEL, + accountId, + fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, + policy: account.config.dmPolicy, + allowFrom: account.config.allowFrom ?? [], + policyPathSuffix: "dmPolicy", + }), + collectWarnings: ({ account, cfg }) => + collectAllowlistProviderRestrictSendersWarnings({ + cfg, + providerConfigPresent: cfg.channels?.imessage !== undefined, + configuredGroupPolicy: account.config.groupPolicy, + surface: "iMessage groups", + openScope: "any member", + groupPolicyPath: "channels.imessage.groupPolicy", + groupAllowFromPath: "channels.imessage.groupAllowFrom", + mentionGated: false, + }), + }, + setup: params.setup, + }; +} diff --git a/extensions/imessage/src/target-parsing-helpers.ts b/extensions/imessage/src/target-parsing-helpers.ts index 95ccc3682ce..7995b271fe4 100644 --- a/extensions/imessage/src/target-parsing-helpers.ts +++ b/extensions/imessage/src/target-parsing-helpers.ts @@ -1,4 +1,4 @@ -import { isAllowedParsedChatSender } from "../../../src/plugin-sdk/allow-from.js"; +import { isAllowedParsedChatSender } from "../../../src/plugin-sdk-internal/imessage.js"; export type ServicePrefix = { prefix: string; service: TService }; diff --git a/extensions/irc/src/setup-core.ts b/extensions/irc/src/setup-core.ts index c793098063b..3c28017e1e9 100644 --- a/extensions/irc/src/setup-core.ts +++ b/extensions/irc/src/setup-core.ts @@ -1,6 +1,6 @@ import { - applyAccountNameToChannelSection, patchScopedAccountConfig, + prepareScopedSetupConfig, } from "../../../src/channels/plugins/setup-helpers.js"; import { setTopLevelChannelAllowFrom, @@ -100,7 +100,7 @@ export function setIrcGroupAccess( export const ircSetupAdapter: ChannelSetupAdapter = { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ + prepareScopedSetupConfig({ cfg, channelKey: channel, accountId, @@ -118,7 +118,7 @@ export const ircSetupAdapter: ChannelSetupAdapter = { }, applyAccountConfig: ({ cfg, accountId, input }) => { const setupInput = input as IrcSetupInput; - const namedConfig = applyAccountNameToChannelSection({ + const namedConfig = prepareScopedSetupConfig({ cfg, channelKey: channel, accountId, diff --git a/extensions/kilocode/index.ts b/extensions/kilocode/index.ts index 1eba870856c..7089d212628 100644 --- a/extensions/kilocode/index.ts +++ b/extensions/kilocode/index.ts @@ -3,11 +3,9 @@ import { createKilocodeWrapper, isProxyReasoningUnsupported, } from "../../src/agents/pi-embedded-runner/proxy-stream-wrappers.js"; -import { - applyKilocodeConfig, - KILOCODE_DEFAULT_MODEL_REF, -} from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { applyKilocodeConfig, KILOCODE_DEFAULT_MODEL_REF } from "./onboard.js"; +import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildKilocodeProviderWithDiscovery } from "./provider-catalog.js"; const PROVIDER_ID = "kilocode"; @@ -47,18 +45,12 @@ const kilocodePlugin = { ], catalog: { order: "simple", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - return { - provider: { - ...(await buildKilocodeProviderWithDiscovery()), - apiKey, - }, - }; - }, + run: (ctx) => + buildSingleProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProvider: buildKilocodeProviderWithDiscovery, + }), }, capabilities: { geminiThoughtSignatureSanitization: true, diff --git a/extensions/kilocode/onboard.ts b/extensions/kilocode/onboard.ts new file mode 100644 index 00000000000..260233c3d34 --- /dev/null +++ b/extensions/kilocode/onboard.ts @@ -0,0 +1,35 @@ +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithModelCatalog, +} from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; +import { + KILOCODE_BASE_URL, + KILOCODE_DEFAULT_MODEL_REF, +} from "../../src/providers/kilocode-shared.js"; +import { buildKilocodeProvider } from "./provider-catalog.js"; + +export { KILOCODE_BASE_URL, KILOCODE_DEFAULT_MODEL_REF }; + +export function applyKilocodeProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[KILOCODE_DEFAULT_MODEL_REF] = { + ...models[KILOCODE_DEFAULT_MODEL_REF], + alias: models[KILOCODE_DEFAULT_MODEL_REF]?.alias ?? "Kilo Gateway", + }; + + return applyProviderConfigWithModelCatalog(cfg, { + agentModels: models, + providerId: "kilocode", + api: "openai-completions", + baseUrl: KILOCODE_BASE_URL, + catalogModels: buildKilocodeProvider().models ?? [], + }); +} + +export function applyKilocodeConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyKilocodeProviderConfig(cfg), + KILOCODE_DEFAULT_MODEL_REF, + ); +} diff --git a/extensions/kimi-coding/index.ts b/extensions/kimi-coding/index.ts index 42853a16c0c..709e5a8de4c 100644 --- a/extensions/kimi-coding/index.ts +++ b/extensions/kimi-coding/index.ts @@ -1,47 +1,69 @@ -import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { applyKimiCodeConfig, KIMI_CODING_MODEL_REF } from "../../src/commands/onboard-auth.js"; +import { + emptyPluginConfigSchema, + type OpenClawPluginApi, + type ProviderRuntimeModel, +} from "openclaw/plugin-sdk/core"; +import { findNormalizedProviderValue, normalizeProviderId } from "../../src/agents/provider-id.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; import { isRecord } from "../../src/utils.js"; -import { buildKimiCodingProvider } from "./provider-catalog.js"; +import { applyKimiCodeConfig, KIMI_DEFAULT_MODEL_REF } from "./onboard.js"; +import { + buildKimiProvider, + KIMI_DEFAULT_MODEL_ID, + KIMI_LEGACY_MODEL_ID, + KIMI_UPSTREAM_MODEL_ID, +} from "./provider-catalog.js"; -const PROVIDER_ID = "kimi-coding"; +const PROVIDER_ID = "kimi"; +const KIMI_TRANSPORT_MODEL_IDS = new Set([KIMI_DEFAULT_MODEL_ID, KIMI_LEGACY_MODEL_ID]); + +function normalizeKimiTransportModel(model: ProviderRuntimeModel): ProviderRuntimeModel { + if (!KIMI_TRANSPORT_MODEL_IDS.has(model.id)) { + return model; + } + return { + ...model, + id: KIMI_UPSTREAM_MODEL_ID, + name: "Kimi Code", + }; +} const kimiCodingPlugin = { id: PROVIDER_ID, - name: "Kimi Coding Provider", - description: "Bundled Kimi Coding provider plugin", + name: "Kimi Code Provider", + description: "Bundled Kimi Code provider plugin", configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { api.registerProvider({ id: PROVIDER_ID, - label: "Kimi Coding", - aliases: ["kimi-code"], + label: "Kimi Code", + aliases: ["kimi-code", "kimi-coding"], docsPath: "/providers/moonshot", envVars: ["KIMI_API_KEY", "KIMICODE_API_KEY"], auth: [ createProviderApiKeyAuthMethod({ providerId: PROVIDER_ID, methodId: "api-key", - label: "Kimi Code API key (subscription)", - hint: "Kimi K2.5 + Kimi Coding", + label: "Kimi Code API key", + hint: "Dedicated coding endpoint", optionKey: "kimiCodeApiKey", flagName: "--kimi-code-api-key", envVar: "KIMI_API_KEY", - promptMessage: "Enter Kimi Coding API key", - defaultModel: KIMI_CODING_MODEL_REF, - expectedProviders: ["kimi-code", "kimi-coding"], + promptMessage: "Enter Kimi Code API key", + defaultModel: KIMI_DEFAULT_MODEL_REF, + expectedProviders: ["kimi", "kimi-code", "kimi-coding"], applyConfig: (cfg) => applyKimiCodeConfig(cfg), noteMessage: [ - "Kimi Coding uses a dedicated endpoint and API key.", + "Kimi Code uses a dedicated coding endpoint and API key.", "Get your API key at: https://www.kimi.com/code/en", ].join("\n"), - noteTitle: "Kimi Coding", + noteTitle: "Kimi Code", wizard: { choiceId: "kimi-code-api-key", - choiceLabel: "Kimi Code API key (subscription)", - groupId: "moonshot", - groupLabel: "Moonshot AI (Kimi K2.5)", - groupHint: "Kimi K2.5 + Kimi Coding", + choiceLabel: "Kimi Code API key", + groupId: "kimi-code", + groupLabel: "Kimi Code", + groupHint: "Dedicated coding endpoint", }, }), ], @@ -52,8 +74,11 @@ const kimiCodingPlugin = { if (!apiKey) { return null; } - const explicitProvider = ctx.config.models?.providers?.[PROVIDER_ID]; - const builtInProvider = buildKimiCodingProvider(); + const explicitProvider = findNormalizedProviderValue( + ctx.config.models?.providers, + PROVIDER_ID, + ); + const builtInProvider = buildKimiProvider(); const explicitBaseUrl = typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : ""; const explicitHeaders = isRecord(explicitProvider?.headers) @@ -79,6 +104,12 @@ const kimiCodingPlugin = { capabilities: { preserveAnthropicThinkingSignatures: false, }, + normalizeResolvedModel: (ctx) => { + if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) { + return undefined; + } + return normalizeKimiTransportModel(ctx.model); + }, }); }, }; diff --git a/extensions/kimi-coding/onboard.ts b/extensions/kimi-coding/onboard.ts new file mode 100644 index 00000000000..07feea91327 --- /dev/null +++ b/extensions/kimi-coding/onboard.ts @@ -0,0 +1,44 @@ +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithModelCatalog, +} from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; +import { + buildKimiCodingProvider, + KIMI_BASE_URL, + KIMI_DEFAULT_MODEL_ID, + KIMI_LEGACY_MODEL_ID, +} from "./provider-catalog.js"; + +export const KIMI_DEFAULT_MODEL_REF = `kimi/${KIMI_DEFAULT_MODEL_ID}`; +export const KIMI_LEGACY_MODEL_REF = `kimi/${KIMI_LEGACY_MODEL_ID}`; +export const KIMI_CODING_MODEL_REF = KIMI_DEFAULT_MODEL_REF; + +export function applyKimiCodeProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[KIMI_DEFAULT_MODEL_REF] = { + ...models[KIMI_DEFAULT_MODEL_REF], + alias: models[KIMI_DEFAULT_MODEL_REF]?.alias ?? "Kimi Code", + }; + models[KIMI_LEGACY_MODEL_REF] = { + ...models[KIMI_LEGACY_MODEL_REF], + alias: models[KIMI_LEGACY_MODEL_REF]?.alias ?? "Kimi Code", + }; + + const catalog = buildKimiCodingProvider().models ?? []; + if (catalog.length === 0) { + return cfg; + } + + return applyProviderConfigWithModelCatalog(cfg, { + agentModels: models, + providerId: "kimi", + api: "anthropic-messages", + baseUrl: KIMI_BASE_URL, + catalogModels: catalog, + }); +} + +export function applyKimiCodeConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary(applyKimiCodeProviderConfig(cfg), KIMI_DEFAULT_MODEL_REF); +} diff --git a/extensions/kimi-coding/openclaw.plugin.json b/extensions/kimi-coding/openclaw.plugin.json index c86d7211031..9d2ba7f69bb 100644 --- a/extensions/kimi-coding/openclaw.plugin.json +++ b/extensions/kimi-coding/openclaw.plugin.json @@ -1,22 +1,23 @@ { - "id": "kimi-coding", - "providers": ["kimi-coding"], + "id": "kimi", + "providers": ["kimi", "kimi-coding"], "providerAuthEnvVars": { + "kimi": ["KIMI_API_KEY", "KIMICODE_API_KEY"], "kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"] }, "providerAuthChoices": [ { - "provider": "kimi-coding", + "provider": "kimi", "method": "api-key", "choiceId": "kimi-code-api-key", - "choiceLabel": "Kimi Code API key (subscription)", - "groupId": "moonshot", - "groupLabel": "Moonshot AI (Kimi K2.5)", - "groupHint": "Kimi K2.5 + Kimi Coding", + "choiceLabel": "Kimi Code API key", + "groupId": "kimi-code", + "groupLabel": "Kimi Code", + "groupHint": "Dedicated coding endpoint", "optionKey": "kimiCodeApiKey", "cliFlag": "--kimi-code-api-key", "cliOption": "--kimi-code-api-key ", - "cliDescription": "Kimi Coding API key" + "cliDescription": "Kimi Code API key" } ], "configSchema": { diff --git a/extensions/kimi-coding/package.json b/extensions/kimi-coding/package.json index 738dd1abd1f..9568afa64b4 100644 --- a/extensions/kimi-coding/package.json +++ b/extensions/kimi-coding/package.json @@ -1,8 +1,8 @@ { - "name": "@openclaw/kimi-coding-provider", + "name": "@openclaw/kimi-provider", "version": "2026.3.14", "private": true, - "description": "OpenClaw Kimi Coding provider plugin", + "description": "OpenClaw Kimi provider plugin", "type": "module", "openclaw": { "extensions": [ diff --git a/extensions/kimi-coding/provider-catalog.ts b/extensions/kimi-coding/provider-catalog.ts index f570df20777..439c86fdff0 100644 --- a/extensions/kimi-coding/provider-catalog.ts +++ b/extensions/kimi-coding/provider-catalog.ts @@ -1,8 +1,10 @@ import type { ModelProviderConfig } from "../../src/config/types.models.js"; -export const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/"; +export const KIMI_BASE_URL = "https://api.kimi.com/coding/"; const KIMI_CODING_USER_AGENT = "claude-code/0.1.0"; -export const KIMI_CODING_DEFAULT_MODEL_ID = "k2p5"; +export const KIMI_DEFAULT_MODEL_ID = "kimi-code"; +export const KIMI_UPSTREAM_MODEL_ID = "kimi-for-coding"; +export const KIMI_LEGACY_MODEL_ID = "k2p5"; const KIMI_CODING_DEFAULT_CONTEXT_WINDOW = 262144; const KIMI_CODING_DEFAULT_MAX_TOKENS = 32768; const KIMI_CODING_DEFAULT_COST = { @@ -14,15 +16,24 @@ const KIMI_CODING_DEFAULT_COST = { export function buildKimiCodingProvider(): ModelProviderConfig { return { - baseUrl: KIMI_CODING_BASE_URL, + baseUrl: KIMI_BASE_URL, api: "anthropic-messages", headers: { "User-Agent": KIMI_CODING_USER_AGENT, }, models: [ { - id: KIMI_CODING_DEFAULT_MODEL_ID, - name: "Kimi for Coding", + id: KIMI_DEFAULT_MODEL_ID, + name: "Kimi Code", + reasoning: true, + input: ["text", "image"], + cost: KIMI_CODING_DEFAULT_COST, + contextWindow: KIMI_CODING_DEFAULT_CONTEXT_WINDOW, + maxTokens: KIMI_CODING_DEFAULT_MAX_TOKENS, + }, + { + id: KIMI_LEGACY_MODEL_ID, + name: "Kimi Code (legacy model id)", reasoning: true, input: ["text", "image"], cost: KIMI_CODING_DEFAULT_COST, @@ -32,3 +43,8 @@ export function buildKimiCodingProvider(): ModelProviderConfig { ], }; } + +export const KIMI_CODING_BASE_URL = KIMI_BASE_URL; +export const KIMI_CODING_DEFAULT_MODEL_ID = KIMI_DEFAULT_MODEL_ID; +export const KIMI_CODING_LEGACY_MODEL_ID = KIMI_LEGACY_MODEL_ID; +export const buildKimiProvider = buildKimiCodingProvider; diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 0ed5c0eda97..cba95624f07 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -45,6 +45,7 @@ function fakeApi(overrides: Partial = {}): OpenClawPluginApi registerService() {}, registerProvider() {}, registerSpeechProvider() {}, + registerMediaUnderstandingProvider() {}, registerWebSearchProvider() {}, registerInteractiveHandler() {}, registerHook() {}, diff --git a/extensions/matrix/src/setup-core.ts b/extensions/matrix/src/setup-core.ts index f0fc395a344..2e6bc895e0c 100644 --- a/extensions/matrix/src/setup-core.ts +++ b/extensions/matrix/src/setup-core.ts @@ -1,10 +1,7 @@ -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; +import { prepareScopedSetupConfig } from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import { normalizeSecretInputString } from "../../../src/config/types.secrets.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; import type { CoreConfig } from "./types.js"; const channel = "matrix" as const; @@ -44,12 +41,12 @@ export function buildMatrixConfigUpdate( export const matrixSetupAdapter: ChannelSetupAdapter = { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ + prepareScopedSetupConfig({ cfg: cfg as CoreConfig, channelKey: channel, accountId, name, - }), + }) as CoreConfig, validateInput: ({ input }) => { if (input.useEnv) { return null; @@ -74,19 +71,13 @@ export const matrixSetupAdapter: ChannelSetupAdapter = { return null; }, applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ + const next = prepareScopedSetupConfig({ cfg: cfg as CoreConfig, channelKey: channel, accountId, name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; + migrateBaseName: true, + }) as CoreConfig; if (input.useEnv) { return { ...next, diff --git a/extensions/matrix/src/setup-surface.ts b/extensions/matrix/src/setup-surface.ts index 0f79545358e..09e9438a410 100644 --- a/extensions/matrix/src/setup-surface.ts +++ b/extensions/matrix/src/setup-surface.ts @@ -1,20 +1,20 @@ import { addWildcardAllowFrom, buildSingleChannelSecretPromptState, + DEFAULT_ACCOUNT_ID, + formatDocsLink, + formatResolvedUnresolvedNote, + hasConfiguredSecretInput, mergeAllowFromEntries, promptSingleChannelSecretInput, setTopLevelChannelGroupPolicy, -} from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js"; -import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { DmPolicy } from "../../../src/config/types.js"; -import type { SecretInput } from "../../../src/config/types.secrets.js"; -import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; -import { formatResolvedUnresolvedNote } from "../../../src/plugin-sdk/resolution-notes.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; -import type { WizardPrompter } from "../../../src/wizard/prompts.js"; + type ChannelSetupDmPolicy, + type ChannelSetupWizard, + type DmPolicy, + type OpenClawConfig, + type SecretInput, + type WizardPrompter, +} from "openclaw/plugin-sdk/setup"; import { listMatrixDirectoryGroupsLive } from "./directory-live.js"; import { resolveMatrixAccount } from "./matrix/accounts.js"; import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js"; diff --git a/extensions/mattermost/src/setup-core.ts b/extensions/mattermost/src/setup-core.ts index 946b1af728e..45bfbc5ac82 100644 --- a/extensions/mattermost/src/setup-core.ts +++ b/extensions/mattermost/src/setup-core.ts @@ -1,12 +1,9 @@ import { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, DEFAULT_ACCOUNT_ID, hasConfiguredSecretInput, - migrateBaseNameToDefaultAccount, - normalizeAccountId, type OpenClawConfig, } from "openclaw/plugin-sdk/mattermost"; +import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import { resolveMattermostAccount, type ResolvedMattermostAccount } from "./mattermost/accounts.js"; import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; @@ -27,15 +24,8 @@ export function resolveMattermostAccountWithSecrets(cfg: OpenClawConfig, account }); } -export const mattermostSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), +export const mattermostSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ + channelKey: channel, validateInput: ({ accountId, input }) => { const token = input.botToken ?? input.token; const baseUrl = normalizeMattermostBaseUrl(input.httpUrl); @@ -50,32 +40,14 @@ export const mattermostSetupAdapter: ChannelSetupAdapter = { } return null; }, - applyAccountConfig: ({ cfg, accountId, input }) => { + buildPatch: (input) => { const token = input.botToken ?? input.token; const baseUrl = normalizeMattermostBaseUrl(input.httpUrl); - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: channel, - accountId, - patch: input.useEnv - ? {} - : { - ...(token ? { botToken: token } : {}), - ...(baseUrl ? { baseUrl } : {}), - }, - }); + return input.useEnv + ? {} + : { + ...(token ? { botToken: token } : {}), + ...(baseUrl ? { baseUrl } : {}), + }; }, -}; +}); diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index e87a60556fa..8dbe47f466c 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -8,13 +8,14 @@ import { } from "openclaw/plugin-sdk/minimax-portal-auth"; import { ensureAuthProfileStore, listProfilesForProvider } from "../../src/agents/auth-profiles.js"; import { MINIMAX_OAUTH_MARKER } from "../../src/agents/model-auth-markers.js"; -import { - applyMinimaxApiConfig, - applyMinimaxApiConfigCn, -} from "../../src/commands/onboard-auth.config-minimax.js"; import { fetchMinimaxUsage } from "../../src/infra/provider-usage.fetch.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { + minimaxMediaUnderstandingProvider, + minimaxPortalMediaUnderstandingProvider, +} from "./media-understanding-provider.js"; import { loginMiniMaxPortalOAuth, type MiniMaxRegion } from "./oauth.js"; +import { applyMinimaxApiConfig, applyMinimaxApiConfigCn } from "./onboard.js"; import { buildMinimaxPortalProvider, buildMinimaxProvider } from "./provider-catalog.js"; const API_PROVIDER_ID = "minimax"; @@ -273,6 +274,8 @@ const minimaxPlugin = { ], isModernModelRef: ({ modelId }) => isModernMiniMaxModel(modelId), }); + api.registerMediaUnderstandingProvider(minimaxMediaUnderstandingProvider); + api.registerMediaUnderstandingProvider(minimaxPortalMediaUnderstandingProvider); }, }; diff --git a/extensions/minimax/media-understanding-provider.ts b/extensions/minimax/media-understanding-provider.ts new file mode 100644 index 00000000000..2798bbf9593 --- /dev/null +++ b/extensions/minimax/media-understanding-provider.ts @@ -0,0 +1,14 @@ +import { describeImageWithModel } from "../../src/media-understanding/providers/image.js"; +import type { MediaUnderstandingProvider } from "../../src/media-understanding/types.js"; + +export const minimaxMediaUnderstandingProvider: MediaUnderstandingProvider = { + id: "minimax", + capabilities: ["image"], + describeImage: describeImageWithModel, +}; + +export const minimaxPortalMediaUnderstandingProvider: MediaUnderstandingProvider = { + id: "minimax-portal", + capabilities: ["image"], + describeImage: describeImageWithModel, +}; diff --git a/extensions/minimax/model-definitions.ts b/extensions/minimax/model-definitions.ts new file mode 100644 index 00000000000..a913a933cf7 --- /dev/null +++ b/extensions/minimax/model-definitions.ts @@ -0,0 +1,64 @@ +import type { ModelDefinitionConfig } from "../../src/config/types.models.js"; + +export const DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1"; +export const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic"; +export const MINIMAX_CN_API_BASE_URL = "https://api.minimaxi.com/anthropic"; +export const MINIMAX_HOSTED_MODEL_ID = "MiniMax-M2.5"; +export const MINIMAX_HOSTED_MODEL_REF = `minimax/${MINIMAX_HOSTED_MODEL_ID}`; +export const DEFAULT_MINIMAX_CONTEXT_WINDOW = 200000; +export const DEFAULT_MINIMAX_MAX_TOKENS = 8192; + +export const MINIMAX_API_COST = { + input: 0.3, + output: 1.2, + cacheRead: 0.03, + cacheWrite: 0.12, +}; +export const MINIMAX_HOSTED_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; +export const MINIMAX_LM_STUDIO_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +const MINIMAX_MODEL_CATALOG = { + "MiniMax-M2.5": { name: "MiniMax M2.5", reasoning: true }, + "MiniMax-M2.5-highspeed": { name: "MiniMax M2.5 Highspeed", reasoning: true }, +} as const; + +type MinimaxCatalogId = keyof typeof MINIMAX_MODEL_CATALOG; + +export function buildMinimaxModelDefinition(params: { + id: string; + name?: string; + reasoning?: boolean; + cost: ModelDefinitionConfig["cost"]; + contextWindow: number; + maxTokens: number; +}): ModelDefinitionConfig { + const catalog = MINIMAX_MODEL_CATALOG[params.id as MinimaxCatalogId]; + return { + id: params.id, + name: params.name ?? catalog?.name ?? `MiniMax ${params.id}`, + reasoning: params.reasoning ?? catalog?.reasoning ?? false, + input: ["text"], + cost: params.cost, + contextWindow: params.contextWindow, + maxTokens: params.maxTokens, + }; +} + +export function buildMinimaxApiModelDefinition(modelId: string): ModelDefinitionConfig { + return buildMinimaxModelDefinition({ + id: modelId, + cost: MINIMAX_API_COST, + contextWindow: DEFAULT_MINIMAX_CONTEXT_WINDOW, + maxTokens: DEFAULT_MINIMAX_MAX_TOKENS, + }); +} diff --git a/extensions/minimax/onboard.ts b/extensions/minimax/onboard.ts new file mode 100644 index 00000000000..6a2ff47e1f0 --- /dev/null +++ b/extensions/minimax/onboard.ts @@ -0,0 +1,104 @@ +import { + applyAgentDefaultModelPrimary, + applyOnboardAuthAgentModelsAndProviders, +} from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; +import type { ModelProviderConfig } from "../../src/config/types.models.js"; +import { + buildMinimaxApiModelDefinition, + MINIMAX_API_BASE_URL, + MINIMAX_CN_API_BASE_URL, +} from "./model-definitions.js"; + +type MinimaxApiProviderConfigParams = { + providerId: string; + modelId: string; + baseUrl: string; +}; + +function applyMinimaxApiProviderConfigWithBaseUrl( + cfg: OpenClawConfig, + params: MinimaxApiProviderConfigParams, +): OpenClawConfig { + const providers = { ...cfg.models?.providers } as Record; + const existingProvider = providers[params.providerId]; + const existingModels = existingProvider?.models ?? []; + const apiModel = buildMinimaxApiModelDefinition(params.modelId); + const hasApiModel = existingModels.some((model) => model.id === params.modelId); + const mergedModels = hasApiModel ? existingModels : [...existingModels, apiModel]; + const { apiKey: existingApiKey, ...existingProviderRest } = existingProvider ?? { + baseUrl: params.baseUrl, + models: [], + }; + const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined; + const normalizedApiKey = resolvedApiKey?.trim() === "minimax" ? "" : resolvedApiKey; + providers[params.providerId] = { + ...existingProviderRest, + baseUrl: params.baseUrl, + api: "anthropic-messages", + authHeader: true, + ...(normalizedApiKey?.trim() ? { apiKey: normalizedApiKey } : {}), + models: mergedModels.length > 0 ? mergedModels : [apiModel], + }; + + const models = { ...cfg.agents?.defaults?.models }; + const modelRef = `${params.providerId}/${params.modelId}`; + models[modelRef] = { + ...models[modelRef], + alias: "Minimax", + }; + + return applyOnboardAuthAgentModelsAndProviders(cfg, { agentModels: models, providers }); +} + +function applyMinimaxApiConfigWithBaseUrl( + cfg: OpenClawConfig, + params: MinimaxApiProviderConfigParams, +): OpenClawConfig { + const next = applyMinimaxApiProviderConfigWithBaseUrl(cfg, params); + return applyAgentDefaultModelPrimary(next, `${params.providerId}/${params.modelId}`); +} + +export function applyMinimaxApiProviderConfig( + cfg: OpenClawConfig, + modelId: string = "MiniMax-M2.5", +): OpenClawConfig { + return applyMinimaxApiProviderConfigWithBaseUrl(cfg, { + providerId: "minimax", + modelId, + baseUrl: MINIMAX_API_BASE_URL, + }); +} + +export function applyMinimaxApiConfig( + cfg: OpenClawConfig, + modelId: string = "MiniMax-M2.5", +): OpenClawConfig { + return applyMinimaxApiConfigWithBaseUrl(cfg, { + providerId: "minimax", + modelId, + baseUrl: MINIMAX_API_BASE_URL, + }); +} + +export function applyMinimaxApiProviderConfigCn( + cfg: OpenClawConfig, + modelId: string = "MiniMax-M2.5", +): OpenClawConfig { + return applyMinimaxApiProviderConfigWithBaseUrl(cfg, { + providerId: "minimax", + modelId, + baseUrl: MINIMAX_CN_API_BASE_URL, + }); +} + +export function applyMinimaxApiConfigCn( + cfg: OpenClawConfig, + modelId: string = "MiniMax-M2.5", +): OpenClawConfig { + return applyMinimaxApiConfigWithBaseUrl(cfg, { + providerId: "minimax", + modelId, + baseUrl: MINIMAX_CN_API_BASE_URL, + }); +} diff --git a/extensions/mistral/index.ts b/extensions/mistral/index.ts index 56e24f8560c..6da8e431759 100644 --- a/extensions/mistral/index.ts +++ b/extensions/mistral/index.ts @@ -1,6 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { applyMistralConfig, MISTRAL_DEFAULT_MODEL_REF } from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { mistralMediaUnderstandingProvider } from "./media-understanding-provider.js"; +import { applyMistralConfig, MISTRAL_DEFAULT_MODEL_REF } from "./onboard.js"; const PROVIDER_ID = "mistral"; @@ -50,6 +51,7 @@ const mistralPlugin = { ], }, }); + api.registerMediaUnderstandingProvider(mistralMediaUnderstandingProvider); }, }; diff --git a/extensions/mistral/media-understanding-provider.ts b/extensions/mistral/media-understanding-provider.ts new file mode 100644 index 00000000000..6ffe1f0f898 --- /dev/null +++ b/extensions/mistral/media-understanding-provider.ts @@ -0,0 +1,17 @@ +import { transcribeOpenAiCompatibleAudio } from "../../src/media-understanding/providers/openai-compatible-audio.js"; +import type { MediaUnderstandingProvider } from "../../src/media-understanding/types.js"; + +const DEFAULT_MISTRAL_AUDIO_BASE_URL = "https://api.mistral.ai/v1"; +const DEFAULT_MISTRAL_AUDIO_MODEL = "voxtral-mini-latest"; + +export const mistralMediaUnderstandingProvider: MediaUnderstandingProvider = { + id: "mistral", + capabilities: ["audio"], + transcribeAudio: async (req) => + await transcribeOpenAiCompatibleAudio({ + ...req, + baseUrl: req.baseUrl ?? DEFAULT_MISTRAL_AUDIO_BASE_URL, + defaultBaseUrl: DEFAULT_MISTRAL_AUDIO_BASE_URL, + defaultModel: DEFAULT_MISTRAL_AUDIO_MODEL, + }), +}; diff --git a/extensions/mistral/model-definitions.ts b/extensions/mistral/model-definitions.ts new file mode 100644 index 00000000000..90d3c84c73d --- /dev/null +++ b/extensions/mistral/model-definitions.ts @@ -0,0 +1,25 @@ +import type { ModelDefinitionConfig } from "../../src/config/types.models.js"; + +export const MISTRAL_BASE_URL = "https://api.mistral.ai/v1"; +export const MISTRAL_DEFAULT_MODEL_ID = "mistral-large-latest"; +export const MISTRAL_DEFAULT_MODEL_REF = `mistral/${MISTRAL_DEFAULT_MODEL_ID}`; +export const MISTRAL_DEFAULT_CONTEXT_WINDOW = 262144; +export const MISTRAL_DEFAULT_MAX_TOKENS = 262144; +export const MISTRAL_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +export function buildMistralModelDefinition(): ModelDefinitionConfig { + return { + id: MISTRAL_DEFAULT_MODEL_ID, + name: "Mistral Large", + reasoning: false, + input: ["text", "image"], + cost: MISTRAL_DEFAULT_COST, + contextWindow: MISTRAL_DEFAULT_CONTEXT_WINDOW, + maxTokens: MISTRAL_DEFAULT_MAX_TOKENS, + }; +} diff --git a/extensions/mistral/onboard.ts b/extensions/mistral/onboard.ts new file mode 100644 index 00000000000..9a60e3f7c72 --- /dev/null +++ b/extensions/mistral/onboard.ts @@ -0,0 +1,34 @@ +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithDefaultModel, +} from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; +import { + buildMistralModelDefinition, + MISTRAL_BASE_URL, + MISTRAL_DEFAULT_MODEL_ID, + MISTRAL_DEFAULT_MODEL_REF, +} from "./model-definitions.js"; + +export { MISTRAL_DEFAULT_MODEL_REF }; + +export function applyMistralProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[MISTRAL_DEFAULT_MODEL_REF] = { + ...models[MISTRAL_DEFAULT_MODEL_REF], + alias: models[MISTRAL_DEFAULT_MODEL_REF]?.alias ?? "Mistral", + }; + + return applyProviderConfigWithDefaultModel(cfg, { + agentModels: models, + providerId: "mistral", + api: "openai-completions", + baseUrl: MISTRAL_BASE_URL, + defaultModel: buildMistralModelDefinition(), + defaultModelId: MISTRAL_DEFAULT_MODEL_ID, + }); +} + +export function applyMistralConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary(applyMistralProviderConfig(cfg), MISTRAL_DEFAULT_MODEL_REF); +} diff --git a/extensions/modelstudio/index.ts b/extensions/modelstudio/index.ts index 08e8730dfbc..e4dc27ee6df 100644 --- a/extensions/modelstudio/index.ts +++ b/extensions/modelstudio/index.ts @@ -1,10 +1,11 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { applyModelStudioConfig, applyModelStudioConfigCn, MODELSTUDIO_DEFAULT_MODEL_REF, -} from "../../src/commands/onboard-auth.js"; -import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +} from "./onboard.js"; import { buildModelStudioProvider } from "./provider-catalog.js"; const PROVIDER_ID = "modelstudio"; @@ -78,22 +79,13 @@ const modelStudioPlugin = { ], catalog: { order: "simple", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - const explicitProvider = ctx.config.models?.providers?.[PROVIDER_ID]; - const explicitBaseUrl = - typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : ""; - return { - provider: { - ...buildModelStudioProvider(), - ...(explicitBaseUrl ? { baseUrl: explicitBaseUrl } : {}), - apiKey, - }, - }; - }, + run: (ctx) => + buildSingleProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProvider: buildModelStudioProvider, + allowExplicitBaseUrl: true, + }), }, }); }, diff --git a/extensions/modelstudio/model-definitions.ts b/extensions/modelstudio/model-definitions.ts new file mode 100644 index 00000000000..765e3962329 --- /dev/null +++ b/extensions/modelstudio/model-definitions.ts @@ -0,0 +1,102 @@ +import type { ModelDefinitionConfig } from "../../src/config/types.models.js"; + +export const MODELSTUDIO_CN_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1"; +export const MODELSTUDIO_GLOBAL_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1"; +export const MODELSTUDIO_DEFAULT_MODEL_ID = "qwen3.5-plus"; +export const MODELSTUDIO_DEFAULT_MODEL_REF = `modelstudio/${MODELSTUDIO_DEFAULT_MODEL_ID}`; +export const MODELSTUDIO_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +const MODELSTUDIO_MODEL_CATALOG = { + "qwen3.5-plus": { + name: "qwen3.5-plus", + reasoning: false, + input: ["text", "image"], + contextWindow: 1000000, + maxTokens: 65536, + }, + "qwen3-max-2026-01-23": { + name: "qwen3-max-2026-01-23", + reasoning: false, + input: ["text"], + contextWindow: 262144, + maxTokens: 65536, + }, + "qwen3-coder-next": { + name: "qwen3-coder-next", + reasoning: false, + input: ["text"], + contextWindow: 262144, + maxTokens: 65536, + }, + "qwen3-coder-plus": { + name: "qwen3-coder-plus", + reasoning: false, + input: ["text"], + contextWindow: 1000000, + maxTokens: 65536, + }, + "MiniMax-M2.5": { + name: "MiniMax-M2.5", + reasoning: false, + input: ["text"], + contextWindow: 1000000, + maxTokens: 65536, + }, + "glm-5": { + name: "glm-5", + reasoning: false, + input: ["text"], + contextWindow: 202752, + maxTokens: 16384, + }, + "glm-4.7": { + name: "glm-4.7", + reasoning: false, + input: ["text"], + contextWindow: 202752, + maxTokens: 16384, + }, + "kimi-k2.5": { + name: "kimi-k2.5", + reasoning: false, + input: ["text", "image"], + contextWindow: 262144, + maxTokens: 32768, + }, +} as const; + +type ModelStudioCatalogId = keyof typeof MODELSTUDIO_MODEL_CATALOG; + +export function buildModelStudioModelDefinition(params: { + id: string; + name?: string; + reasoning?: boolean; + input?: string[]; + cost?: ModelDefinitionConfig["cost"]; + contextWindow?: number; + maxTokens?: number; +}): ModelDefinitionConfig { + const catalog = MODELSTUDIO_MODEL_CATALOG[params.id as ModelStudioCatalogId]; + return { + id: params.id, + name: params.name ?? catalog?.name ?? params.id, + reasoning: params.reasoning ?? catalog?.reasoning ?? false, + input: + (params.input as ("text" | "image")[]) ?? + ([...(catalog?.input ?? ["text"])] as ("text" | "image")[]), + cost: params.cost ?? MODELSTUDIO_DEFAULT_COST, + contextWindow: params.contextWindow ?? catalog?.contextWindow ?? 262144, + maxTokens: params.maxTokens ?? catalog?.maxTokens ?? 65536, + }; +} + +export function buildModelStudioDefaultModelDefinition(): ModelDefinitionConfig { + return buildModelStudioModelDefinition({ + id: MODELSTUDIO_DEFAULT_MODEL_ID, + }); +} diff --git a/extensions/modelstudio/onboard.ts b/extensions/modelstudio/onboard.ts new file mode 100644 index 00000000000..9a8760b8550 --- /dev/null +++ b/extensions/modelstudio/onboard.ts @@ -0,0 +1,61 @@ +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithModelCatalog, +} from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; +import { + MODELSTUDIO_CN_BASE_URL, + MODELSTUDIO_DEFAULT_MODEL_REF, + MODELSTUDIO_GLOBAL_BASE_URL, +} from "./model-definitions.js"; +import { buildModelStudioProvider } from "./provider-catalog.js"; + +export { MODELSTUDIO_CN_BASE_URL, MODELSTUDIO_DEFAULT_MODEL_REF, MODELSTUDIO_GLOBAL_BASE_URL }; + +function applyModelStudioProviderConfigWithBaseUrl( + cfg: OpenClawConfig, + baseUrl: string, +): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + const provider = buildModelStudioProvider(); + for (const model of provider.models ?? []) { + const modelRef = `modelstudio/${model.id}`; + if (!models[modelRef]) { + models[modelRef] = {}; + } + } + models[MODELSTUDIO_DEFAULT_MODEL_REF] = { + ...models[MODELSTUDIO_DEFAULT_MODEL_REF], + alias: models[MODELSTUDIO_DEFAULT_MODEL_REF]?.alias ?? "Qwen", + }; + + return applyProviderConfigWithModelCatalog(cfg, { + agentModels: models, + providerId: "modelstudio", + api: provider.api ?? "openai-completions", + baseUrl, + catalogModels: provider.models ?? [], + }); +} + +export function applyModelStudioProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyModelStudioProviderConfigWithBaseUrl(cfg, MODELSTUDIO_GLOBAL_BASE_URL); +} + +export function applyModelStudioProviderConfigCn(cfg: OpenClawConfig): OpenClawConfig { + return applyModelStudioProviderConfigWithBaseUrl(cfg, MODELSTUDIO_CN_BASE_URL); +} + +export function applyModelStudioConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyModelStudioProviderConfig(cfg), + MODELSTUDIO_DEFAULT_MODEL_REF, + ); +} + +export function applyModelStudioConfigCn(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyModelStudioProviderConfigCn(cfg), + MODELSTUDIO_DEFAULT_MODEL_REF, + ); +} diff --git a/extensions/moonshot/index.ts b/extensions/moonshot/index.ts index 94e01d3a069..5ef777edcc4 100644 --- a/extensions/moonshot/index.ts +++ b/extensions/moonshot/index.ts @@ -7,14 +7,16 @@ import { getScopedCredentialValue, setScopedCredentialValue, } from "../../src/agents/tools/web-search-plugin-factory.js"; +import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; +import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; +import type { OpenClawPluginApi } from "../../src/plugins/types.js"; +import { moonshotMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { applyMoonshotConfig, applyMoonshotConfigCn, -} from "../../src/commands/onboard-auth.config-core.js"; -import { MOONSHOT_DEFAULT_MODEL_REF } from "../../src/commands/onboard-auth.models.js"; -import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; -import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; -import type { OpenClawPluginApi } from "../../src/plugins/types.js"; + MOONSHOT_DEFAULT_MODEL_REF, +} from "./onboard.js"; import { buildMoonshotProvider } from "./provider-catalog.js"; const PROVIDER_ID = "moonshot"; @@ -34,8 +36,8 @@ const moonshotPlugin = { createProviderApiKeyAuthMethod({ providerId: PROVIDER_ID, methodId: "api-key", - label: "Kimi API key (.ai)", - hint: "Kimi K2.5 + Kimi Coding", + label: "Moonshot API key (.ai)", + hint: "Kimi K2.5", optionKey: "moonshotApiKey", flagName: "--moonshot-api-key", envVar: "MOONSHOT_API_KEY", @@ -45,17 +47,17 @@ const moonshotPlugin = { applyConfig: (cfg) => applyMoonshotConfig(cfg), wizard: { choiceId: "moonshot-api-key", - choiceLabel: "Kimi API key (.ai)", + choiceLabel: "Moonshot API key (.ai)", groupId: "moonshot", groupLabel: "Moonshot AI (Kimi K2.5)", - groupHint: "Kimi K2.5 + Kimi Coding", + groupHint: "Kimi K2.5", }, }), createProviderApiKeyAuthMethod({ providerId: PROVIDER_ID, methodId: "api-key-cn", - label: "Kimi API key (.cn)", - hint: "Kimi K2.5 + Kimi Coding", + label: "Moonshot API key (.cn)", + hint: "Kimi K2.5", optionKey: "moonshotApiKey", flagName: "--moonshot-api-key", envVar: "MOONSHOT_API_KEY", @@ -65,31 +67,22 @@ const moonshotPlugin = { applyConfig: (cfg) => applyMoonshotConfigCn(cfg), wizard: { choiceId: "moonshot-api-key-cn", - choiceLabel: "Kimi API key (.cn)", + choiceLabel: "Moonshot API key (.cn)", groupId: "moonshot", groupLabel: "Moonshot AI (Kimi K2.5)", - groupHint: "Kimi K2.5 + Kimi Coding", + groupHint: "Kimi K2.5", }, }), ], catalog: { order: "simple", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - const explicitProvider = ctx.config.models?.providers?.[PROVIDER_ID]; - const explicitBaseUrl = - typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : ""; - return { - provider: { - ...buildMoonshotProvider(), - ...(explicitBaseUrl ? { baseUrl: explicitBaseUrl } : {}), - apiKey, - }, - }; - }, + run: (ctx) => + buildSingleProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProvider: buildMoonshotProvider, + allowExplicitBaseUrl: true, + }), }, wrapStreamFn: (ctx) => { const thinkingType = resolveMoonshotThinkingType({ @@ -99,6 +92,7 @@ const moonshotPlugin = { return createMoonshotThinkingWrapper(ctx.streamFn, thinkingType); }, }); + api.registerMediaUnderstandingProvider(moonshotMediaUnderstandingProvider); api.registerWebSearchProvider( createPluginBackedWebSearchProvider({ id: "kimi", diff --git a/src/media-understanding/providers/moonshot/video.ts b/extensions/moonshot/media-understanding-provider.ts similarity index 82% rename from src/media-understanding/providers/moonshot/video.ts rename to extensions/moonshot/media-understanding-provider.ts index 0cc6f55a7e3..52bc9701c26 100644 --- a/src/media-understanding/providers/moonshot/video.ts +++ b/extensions/moonshot/media-understanding-provider.ts @@ -1,5 +1,14 @@ -import type { VideoDescriptionRequest, VideoDescriptionResult } from "../../types.js"; -import { assertOkOrThrowHttpError, normalizeBaseUrl, postJsonRequest } from "../shared.js"; +import { describeImageWithModel } from "../../src/media-understanding/providers/image.js"; +import { + assertOkOrThrowHttpError, + normalizeBaseUrl, + postJsonRequest, +} from "../../src/media-understanding/providers/shared.js"; +import type { + MediaUnderstandingProvider, + VideoDescriptionRequest, + VideoDescriptionResult, +} from "../../src/media-understanding/types.js"; export const DEFAULT_MOONSHOT_VIDEO_BASE_URL = "https://api.moonshot.ai/v1"; const DEFAULT_MOONSHOT_VIDEO_MODEL = "kimi-k2.5"; @@ -104,3 +113,10 @@ export async function describeMoonshotVideo( await release(); } } + +export const moonshotMediaUnderstandingProvider: MediaUnderstandingProvider = { + id: "moonshot", + capabilities: ["image", "video"], + describeImage: describeImageWithModel, + describeVideo: describeMoonshotVideo, +}; diff --git a/extensions/moonshot/onboard.ts b/extensions/moonshot/onboard.ts new file mode 100644 index 00000000000..57459b724ce --- /dev/null +++ b/extensions/moonshot/onboard.ts @@ -0,0 +1,60 @@ +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithDefaultModel, +} from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; +import { + buildMoonshotProvider, + MOONSHOT_BASE_URL, + MOONSHOT_DEFAULT_MODEL_ID, +} from "./provider-catalog.js"; + +export const MOONSHOT_CN_BASE_URL = "https://api.moonshot.cn/v1"; +export const MOONSHOT_DEFAULT_MODEL_REF = `moonshot/${MOONSHOT_DEFAULT_MODEL_ID}`; + +export function applyMoonshotProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyMoonshotProviderConfigWithBaseUrl(cfg, MOONSHOT_BASE_URL); +} + +export function applyMoonshotProviderConfigCn(cfg: OpenClawConfig): OpenClawConfig { + return applyMoonshotProviderConfigWithBaseUrl(cfg, MOONSHOT_CN_BASE_URL); +} + +function applyMoonshotProviderConfigWithBaseUrl( + cfg: OpenClawConfig, + baseUrl: string, +): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[MOONSHOT_DEFAULT_MODEL_REF] = { + ...models[MOONSHOT_DEFAULT_MODEL_REF], + alias: models[MOONSHOT_DEFAULT_MODEL_REF]?.alias ?? "Kimi", + }; + + const defaultModel = buildMoonshotProvider().models[0]; + if (!defaultModel) { + return cfg; + } + + return applyProviderConfigWithDefaultModel(cfg, { + agentModels: models, + providerId: "moonshot", + api: "openai-completions", + baseUrl, + defaultModel, + defaultModelId: MOONSHOT_DEFAULT_MODEL_ID, + }); +} + +export function applyMoonshotConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyMoonshotProviderConfig(cfg), + MOONSHOT_DEFAULT_MODEL_REF, + ); +} + +export function applyMoonshotConfigCn(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyMoonshotProviderConfigCn(cfg), + MOONSHOT_DEFAULT_MODEL_REF, + ); +} diff --git a/extensions/moonshot/openclaw.plugin.json b/extensions/moonshot/openclaw.plugin.json index cad9e255a2b..66bbfd2b6c8 100644 --- a/extensions/moonshot/openclaw.plugin.json +++ b/extensions/moonshot/openclaw.plugin.json @@ -9,10 +9,10 @@ "provider": "moonshot", "method": "api-key", "choiceId": "moonshot-api-key", - "choiceLabel": "Kimi API key (.ai)", + "choiceLabel": "Moonshot API key (.ai)", "groupId": "moonshot", "groupLabel": "Moonshot AI (Kimi K2.5)", - "groupHint": "Kimi K2.5 + Kimi Coding", + "groupHint": "Kimi K2.5", "optionKey": "moonshotApiKey", "cliFlag": "--moonshot-api-key", "cliOption": "--moonshot-api-key ", @@ -22,10 +22,10 @@ "provider": "moonshot", "method": "api-key-cn", "choiceId": "moonshot-api-key-cn", - "choiceLabel": "Kimi API key (.cn)", + "choiceLabel": "Moonshot API key (.cn)", "groupId": "moonshot", "groupLabel": "Moonshot AI (Kimi K2.5)", - "groupHint": "Kimi K2.5 + Kimi Coding", + "groupHint": "Kimi K2.5", "optionKey": "moonshotApiKey", "cliFlag": "--moonshot-api-key", "cliOption": "--moonshot-api-key ", diff --git a/extensions/msteams/src/setup-core.ts b/extensions/msteams/src/setup-core.ts index 74079aaf389..fb4246a8d0a 100644 --- a/extensions/msteams/src/setup-core.ts +++ b/extensions/msteams/src/setup-core.ts @@ -1,5 +1,4 @@ -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID, type ChannelSetupAdapter } from "openclaw/plugin-sdk/setup"; export const msteamsSetupAdapter: ChannelSetupAdapter = { resolveAccountId: () => DEFAULT_ACCOUNT_ID, diff --git a/extensions/msteams/src/setup-surface.ts b/extensions/msteams/src/setup-surface.ts index e3bc6169f6c..185bf3d7362 100644 --- a/extensions/msteams/src/setup-surface.ts +++ b/extensions/msteams/src/setup-surface.ts @@ -1,17 +1,18 @@ +import type { MSTeamsTeamConfig } from "openclaw/plugin-sdk/msteams"; import { + DEFAULT_ACCOUNT_ID, + formatDocsLink, mergeAllowFromEntries, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, setTopLevelChannelGroupPolicy, splitSetupEntries, -} from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js"; -import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { DmPolicy, MSTeamsTeamConfig } from "../../../src/config/types.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; -import type { WizardPrompter } from "../../../src/wizard/prompts.js"; + type ChannelSetupDmPolicy, + type ChannelSetupWizard, + type DmPolicy, + type OpenClawConfig, + type WizardPrompter, +} from "openclaw/plugin-sdk/setup"; import { parseMSTeamsTeamEntry, resolveMSTeamsChannelAllowlist, diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts index 1d45a392fd1..a94482b8d43 100644 --- a/extensions/nextcloud-talk/src/setup-core.ts +++ b/extensions/nextcloud-talk/src/setup-core.ts @@ -1,6 +1,6 @@ import { - applyAccountNameToChannelSection, patchScopedAccountConfig, + prepareScopedSetupConfig, } from "../../../src/channels/plugins/setup-helpers.js"; import { mergeAllowFromEntries, @@ -115,7 +115,7 @@ export function clearNextcloudTalkAccountFields( } as CoreConfig; } -async function promptNextcloudTalkAllowFrom(params: { +export async function promptNextcloudTalkAllowFrom(params: { cfg: CoreConfig; prompter: WizardPrompter; accountId: string; @@ -127,7 +127,7 @@ async function promptNextcloudTalkAllowFrom(params: { "1) Check the Nextcloud admin panel for user IDs", "2) Or look at the webhook payload logs when someone messages", "3) User IDs are typically lowercase usernames in Nextcloud", - `Docs: ${formatDocsLink("/channels/nextcloud-talk", "nextcloud-talk")}`, + `Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`, ].join("\n"), "Nextcloud Talk user id", ); @@ -158,7 +158,7 @@ async function promptNextcloudTalkAllowFrom(params: { }); } -async function promptNextcloudTalkAllowFromForAccount(params: { +export async function promptNextcloudTalkAllowFromForAccount(params: { cfg: OpenClawConfig; prompter: WizardPrompter; accountId?: string; @@ -174,7 +174,7 @@ async function promptNextcloudTalkAllowFromForAccount(params: { }); } -const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = { +export const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = { label: "Nextcloud Talk", channel, policyKey: "channels.nextcloud-talk.dmPolicy", @@ -187,7 +187,7 @@ const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = { export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ + prepareScopedSetupConfig({ cfg, channelKey: channel, accountId, @@ -208,7 +208,7 @@ export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = { }, applyAccountConfig: ({ cfg, accountId, input }) => { const setupInput = input as NextcloudSetupInput; - const namedConfig = applyAccountNameToChannelSection({ + const namedConfig = prepareScopedSetupConfig({ cfg, channelKey: channel, accountId, diff --git a/extensions/nextcloud-talk/src/setup-surface.ts b/extensions/nextcloud-talk/src/setup-surface.ts index da839359ff2..46561f5b274 100644 --- a/extensions/nextcloud-talk/src/setup-surface.ts +++ b/extensions/nextcloud-talk/src/setup-surface.ts @@ -1,111 +1,22 @@ -import { - mergeAllowFromEntries, - resolveSetupAccountId, - setSetupChannelEnabled, - setTopLevelChannelDmPolicyWithAllowFrom, -} from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js"; +import { setSetupChannelEnabled } from "../../../src/channels/plugins/setup-wizard-helpers.js"; import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; -import type { WizardPrompter } from "../../../src/wizard/prompts.js"; -import { - listNextcloudTalkAccountIds, - resolveDefaultNextcloudTalkAccountId, - resolveNextcloudTalkAccount, -} from "./accounts.js"; +import { listNextcloudTalkAccountIds, resolveNextcloudTalkAccount } from "./accounts.js"; import { clearNextcloudTalkAccountFields, + nextcloudTalkDmPolicy, nextcloudTalkSetupAdapter, normalizeNextcloudTalkBaseUrl, setNextcloudTalkAccountConfig, validateNextcloudTalkBaseUrl, } from "./setup-core.js"; -import type { CoreConfig, DmPolicy } from "./types.js"; +import type { CoreConfig } from "./types.js"; const channel = "nextcloud-talk" as const; const CONFIGURE_API_FLAG = "__nextcloudTalkConfigureApiCredentials"; -function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy, - }) as CoreConfig; -} - -async function promptNextcloudTalkAllowFrom(params: { - cfg: CoreConfig; - prompter: WizardPrompter; - accountId: string; -}): Promise { - const resolved = resolveNextcloudTalkAccount({ cfg: params.cfg, accountId: params.accountId }); - const existingAllowFrom = resolved.config.allowFrom ?? []; - await params.prompter.note( - [ - "1) Check the Nextcloud admin panel for user IDs", - "2) Or look at the webhook payload logs when someone messages", - "3) User IDs are typically lowercase usernames in Nextcloud", - `Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`, - ].join("\n"), - "Nextcloud Talk user id", - ); - - let resolvedIds: string[] = []; - while (resolvedIds.length === 0) { - const entry = await params.prompter.text({ - message: "Nextcloud Talk allowFrom (user id)", - placeholder: "username", - initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - resolvedIds = String(entry) - .split(/[\n,;]+/g) - .map((value) => value.trim().toLowerCase()) - .filter(Boolean); - if (resolvedIds.length === 0) { - await params.prompter.note("Please enter at least one valid user ID.", "Nextcloud Talk"); - } - } - - return setNextcloudTalkAccountConfig(params.cfg, params.accountId, { - dmPolicy: "allowlist", - allowFrom: mergeAllowFromEntries( - existingAllowFrom.map((value) => String(value).trim().toLowerCase()), - resolvedIds, - ), - }); -} - -async function promptNextcloudTalkAllowFromForAccount(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId?: string; -}): Promise { - const accountId = resolveSetupAccountId({ - accountId: params.accountId, - defaultAccountId: resolveDefaultNextcloudTalkAccountId(params.cfg as CoreConfig), - }); - return await promptNextcloudTalkAllowFrom({ - cfg: params.cfg as CoreConfig, - prompter: params.prompter, - accountId, - }); -} - -const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = { - label: "Nextcloud Talk", - channel, - policyKey: "channels.nextcloud-talk.dmPolicy", - allowFromKey: "channels.nextcloud-talk.allowFrom", - getCurrent: (cfg) => cfg.channels?.["nextcloud-talk"]?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => setNextcloudTalkDmPolicy(cfg as CoreConfig, policy as DmPolicy), - promptAllowFrom: promptNextcloudTalkAllowFromForAccount, -}; - export const nextcloudTalkSetupWizard: ChannelSetupWizard = { channel, stepOrder: "text-first", diff --git a/extensions/nvidia/index.ts b/extensions/nvidia/index.ts index 02df4f8e6a3..82b59e40a93 100644 --- a/extensions/nvidia/index.ts +++ b/extensions/nvidia/index.ts @@ -1,4 +1,5 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; +import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildNvidiaProvider } from "./provider-catalog.js"; const PROVIDER_ID = "nvidia"; @@ -17,18 +18,12 @@ const nvidiaPlugin = { auth: [], catalog: { order: "simple", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - return { - provider: { - ...buildNvidiaProvider(), - apiKey, - }, - }; - }, + run: (ctx) => + buildSingleProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProvider: buildNvidiaProvider, + }), }, }); }, diff --git a/extensions/openai/index.ts b/extensions/openai/index.ts index cd528f72211..e45c9718087 100644 --- a/extensions/openai/index.ts +++ b/extensions/openai/index.ts @@ -1,5 +1,6 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { buildOpenAISpeechProvider } from "../../src/tts/providers/openai.js"; +import { openaiMediaUnderstandingProvider } from "./media-understanding-provider.js"; import { buildOpenAICodexProviderPlugin } from "./openai-codex-provider.js"; import { buildOpenAIProvider } from "./openai-provider.js"; @@ -12,6 +13,7 @@ const openAIPlugin = { api.registerProvider(buildOpenAIProvider()); api.registerProvider(buildOpenAICodexProviderPlugin()); api.registerSpeechProvider(buildOpenAISpeechProvider()); + api.registerMediaUnderstandingProvider(openaiMediaUnderstandingProvider); }, }; diff --git a/extensions/openai/media-understanding-provider.ts b/extensions/openai/media-understanding-provider.ts new file mode 100644 index 00000000000..c97f317bf4d --- /dev/null +++ b/extensions/openai/media-understanding-provider.ts @@ -0,0 +1,23 @@ +import { describeImageWithModel } from "../../src/media-understanding/providers/image.js"; +import { transcribeOpenAiCompatibleAudio } from "../../src/media-understanding/providers/openai-compatible-audio.js"; +import type { MediaUnderstandingProvider } from "../../src/media-understanding/types.js"; + +export const DEFAULT_OPENAI_AUDIO_BASE_URL = "https://api.openai.com/v1"; +const DEFAULT_OPENAI_AUDIO_MODEL = "gpt-4o-mini-transcribe"; + +export async function transcribeOpenAiAudio( + params: import("../../src/media-understanding/types.js").AudioTranscriptionRequest, +) { + return await transcribeOpenAiCompatibleAudio({ + ...params, + defaultBaseUrl: DEFAULT_OPENAI_AUDIO_BASE_URL, + defaultModel: DEFAULT_OPENAI_AUDIO_MODEL, + }); +} + +export const openaiMediaUnderstandingProvider: MediaUnderstandingProvider = { + id: "openai", + capabilities: ["image", "audio"], + describeImage: describeImageWithModel, + transcribeAudio: transcribeOpenAiAudio, +}; diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index 49c6f7272a9..e8be8bd4eb1 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -4,6 +4,7 @@ import type { ProviderResolveDynamicModelContext, ProviderRuntimeModel, } from "openclaw/plugin-sdk/core"; +import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/core"; import { CODEX_CLI_PROFILE_ID } from "../../src/agents/auth-profiles.js"; import { listProfilesForProvider } from "../../src/agents/auth-profiles/profiles.js"; import { ensureAuthProfileStore } from "../../src/agents/auth-profiles/store.js"; @@ -13,7 +14,6 @@ import { normalizeModelCompat } from "../../src/agents/model-compat.js"; import { normalizeProviderId } from "../../src/agents/provider-id.js"; import { loginOpenAICodexOAuth } from "../../src/commands/openai-codex-oauth.js"; import { fetchCodexUsage } from "../../src/infra/provider-usage.fetch.js"; -import { buildOauthProviderAuthResult } from "../../src/plugin-sdk/provider-auth-result.js"; import type { ProviderPlugin } from "../../src/plugins/types.js"; import { buildOpenAICodexProvider } from "./openai-codex-catalog.js"; import { diff --git a/extensions/openai/shared.ts b/extensions/openai/shared.ts index 4e4c8c2d850..ad469a2f136 100644 --- a/extensions/openai/shared.ts +++ b/extensions/openai/shared.ts @@ -1,4 +1,5 @@ import { normalizeModelCompat } from "../../src/agents/model-compat.js"; +import { findCatalogTemplate } from "../../src/plugins/provider-catalog.js"; import type { ProviderResolveDynamicModelContext, ProviderRuntimeModel, @@ -48,18 +49,4 @@ export function cloneFirstTemplateModel(params: { return undefined; } -export function findCatalogTemplate(params: { - entries: ReadonlyArray<{ provider: string; id: string }>; - providerId: string; - templateIds: readonly string[]; -}) { - return params.templateIds - .map((templateId) => - params.entries.find( - (entry) => - entry.provider.toLowerCase() === params.providerId.toLowerCase() && - entry.id.toLowerCase() === templateId.toLowerCase(), - ), - ) - .find((entry) => entry !== undefined); -} +export { findCatalogTemplate }; diff --git a/extensions/opencode-go/index.ts b/extensions/opencode-go/index.ts index c0a8cea9b91..ddfd9a5858c 100644 --- a/extensions/opencode-go/index.ts +++ b/extensions/opencode-go/index.ts @@ -1,7 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { applyOpencodeGoConfig } from "../../src/commands/onboard-auth.config-opencode-go.js"; import { OPENCODE_GO_DEFAULT_MODEL_REF } from "../../src/commands/opencode-go-model-default.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { applyOpencodeGoConfig } from "./onboard.js"; const PROVIDER_ID = "opencode-go"; diff --git a/extensions/opencode-go/onboard.ts b/extensions/opencode-go/onboard.ts new file mode 100644 index 00000000000..8ca47a0f9d0 --- /dev/null +++ b/extensions/opencode-go/onboard.ts @@ -0,0 +1,39 @@ +import { applyAgentDefaultModelPrimary } from "../../src/commands/onboard-auth.config-shared.js"; +import { OPENCODE_GO_DEFAULT_MODEL_REF } from "../../src/commands/opencode-go-model-default.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; + +export { OPENCODE_GO_DEFAULT_MODEL_REF }; + +const OPENCODE_GO_ALIAS_DEFAULTS: Record = { + "opencode-go/kimi-k2.5": "Kimi", + "opencode-go/glm-5": "GLM", + "opencode-go/minimax-m2.5": "MiniMax", +}; + +export function applyOpencodeGoProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + for (const [modelRef, alias] of Object.entries(OPENCODE_GO_ALIAS_DEFAULTS)) { + models[modelRef] = { + ...models[modelRef], + alias: models[modelRef]?.alias ?? alias, + }; + } + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + }, + }, + }; +} + +export function applyOpencodeGoConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyOpencodeGoProviderConfig(cfg), + OPENCODE_GO_DEFAULT_MODEL_REF, + ); +} diff --git a/extensions/opencode/index.ts b/extensions/opencode/index.ts index d00ae301bc5..01ccea24656 100644 --- a/extensions/opencode/index.ts +++ b/extensions/opencode/index.ts @@ -1,7 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { applyOpencodeZenConfig } from "../../src/commands/onboard-auth.config-opencode.js"; import { OPENCODE_ZEN_DEFAULT_MODEL } from "../../src/commands/opencode-zen-model-default.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { applyOpencodeZenConfig } from "./onboard.js"; const PROVIDER_ID = "opencode"; const MINIMAX_PREFIX = "minimax-m2.5"; diff --git a/extensions/opencode/onboard.ts b/extensions/opencode/onboard.ts new file mode 100644 index 00000000000..a308129b688 --- /dev/null +++ b/extensions/opencode/onboard.ts @@ -0,0 +1,31 @@ +import { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../../src/agents/opencode-zen-models.js"; +import { applyAgentDefaultModelPrimary } from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; + +export { OPENCODE_ZEN_DEFAULT_MODEL_REF }; + +export function applyOpencodeZenProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[OPENCODE_ZEN_DEFAULT_MODEL_REF] = { + ...models[OPENCODE_ZEN_DEFAULT_MODEL_REF], + alias: models[OPENCODE_ZEN_DEFAULT_MODEL_REF]?.alias ?? "Opus", + }; + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + }, + }, + }; +} + +export function applyOpencodeZenConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyOpencodeZenProviderConfig(cfg), + OPENCODE_ZEN_DEFAULT_MODEL_REF, + ); +} diff --git a/extensions/openrouter/index.ts b/extensions/openrouter/index.ts index 0fdac10ea0e..2246424787a 100644 --- a/extensions/openrouter/index.ts +++ b/extensions/openrouter/index.ts @@ -15,11 +15,8 @@ import { createOpenRouterWrapper, isProxyReasoningUnsupported, } from "../../src/agents/pi-embedded-runner/proxy-stream-wrappers.js"; -import { - applyOpenrouterConfig, - OPENROUTER_DEFAULT_MODEL_REF, -} from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildOpenrouterProvider } from "./provider-catalog.js"; const PROVIDER_ID = "openrouter"; diff --git a/extensions/openrouter/onboard.ts b/extensions/openrouter/onboard.ts new file mode 100644 index 00000000000..03ec7bf86bc --- /dev/null +++ b/extensions/openrouter/onboard.ts @@ -0,0 +1,30 @@ +import { applyAgentDefaultModelPrimary } from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; + +export const OPENROUTER_DEFAULT_MODEL_REF = "openrouter/auto"; + +export function applyOpenrouterProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[OPENROUTER_DEFAULT_MODEL_REF] = { + ...models[OPENROUTER_DEFAULT_MODEL_REF], + alias: models[OPENROUTER_DEFAULT_MODEL_REF]?.alias ?? "OpenRouter", + }; + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + }, + }, + }; +} + +export function applyOpenrouterConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyOpenrouterProviderConfig(cfg), + OPENROUTER_DEFAULT_MODEL_REF, + ); +} diff --git a/extensions/qianfan/index.ts b/extensions/qianfan/index.ts index 6ce5bd21008..04bd8429755 100644 --- a/extensions/qianfan/index.ts +++ b/extensions/qianfan/index.ts @@ -1,6 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { applyQianfanConfig, QIANFAN_DEFAULT_MODEL_REF } from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { applyQianfanConfig, QIANFAN_DEFAULT_MODEL_REF } from "./onboard.js"; +import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildQianfanProvider } from "./provider-catalog.js"; const PROVIDER_ID = "qianfan"; @@ -40,18 +41,12 @@ const qianfanPlugin = { ], catalog: { order: "simple", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - return { - provider: { - ...buildQianfanProvider(), - apiKey, - }, - }; - }, + run: (ctx) => + buildSingleProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProvider: buildQianfanProvider, + }), }, }); }, diff --git a/extensions/qianfan/onboard.ts b/extensions/qianfan/onboard.ts new file mode 100644 index 00000000000..6df59e49a40 --- /dev/null +++ b/extensions/qianfan/onboard.ts @@ -0,0 +1,48 @@ +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithDefaultModels, +} from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; +import type { ModelApi } from "../../src/config/types.models.js"; +import { + buildQianfanProvider, + QIANFAN_BASE_URL, + QIANFAN_DEFAULT_MODEL_ID, +} from "./provider-catalog.js"; + +export const QIANFAN_DEFAULT_MODEL_REF = `qianfan/${QIANFAN_DEFAULT_MODEL_ID}`; + +export function applyQianfanProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[QIANFAN_DEFAULT_MODEL_REF] = { + ...models[QIANFAN_DEFAULT_MODEL_REF], + alias: models[QIANFAN_DEFAULT_MODEL_REF]?.alias ?? "QIANFAN", + }; + const defaultProvider = buildQianfanProvider(); + const existingProvider = cfg.models?.providers?.qianfan as + | { + baseUrl?: unknown; + api?: unknown; + } + | undefined; + const existingBaseUrl = + typeof existingProvider?.baseUrl === "string" ? existingProvider.baseUrl.trim() : ""; + const resolvedBaseUrl = existingBaseUrl || QIANFAN_BASE_URL; + const resolvedApi = + typeof existingProvider?.api === "string" + ? (existingProvider.api as ModelApi) + : "openai-completions"; + + return applyProviderConfigWithDefaultModels(cfg, { + agentModels: models, + providerId: "qianfan", + api: resolvedApi, + baseUrl: resolvedBaseUrl, + defaultModels: defaultProvider.models ?? [], + defaultModelId: QIANFAN_DEFAULT_MODEL_ID, + }); +} + +export function applyQianfanConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary(applyQianfanProviderConfig(cfg), QIANFAN_DEFAULT_MODEL_REF); +} diff --git a/extensions/signal/src/accounts.ts b/extensions/signal/src/accounts.ts index 0bf9db0e79a..30a3b56189c 100644 --- a/extensions/signal/src/accounts.ts +++ b/extensions/signal/src/accounts.ts @@ -1,10 +1,10 @@ -import type { SignalAccountConfig } from "openclaw/plugin-sdk/signal"; import { type OpenClawConfig, createAccountListHelpers, normalizeAccountId, resolveAccountEntry, } from "../../../src/plugin-sdk-internal/accounts.js"; +import type { SignalAccountConfig } from "../../../src/plugin-sdk-internal/signal.js"; export type ResolvedSignalAccount = { accountId: string; diff --git a/extensions/signal/src/channel.setup.ts b/extensions/signal/src/channel.setup.ts index 88a7035c199..d633ff6a251 100644 --- a/extensions/signal/src/channel.setup.ts +++ b/extensions/signal/src/channel.setup.ts @@ -1,114 +1,9 @@ -import { - createScopedAccountConfigAccessors, - buildAccountScopedDmSecurityPolicy, - collectAllowlistProviderRestrictSendersWarnings, -} from "openclaw/plugin-sdk/compat"; -import { - buildChannelConfigSchema, - DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, - getChatChannelMeta, - normalizeE164, - setAccountEnabledInConfigSection, - SignalConfigSchema, - type ChannelPlugin, -} from "openclaw/plugin-sdk/signal"; -import { - listSignalAccountIds, - resolveDefaultSignalAccountId, - resolveSignalAccount, - type ResolvedSignalAccount, -} from "./accounts.js"; -import { createSignalSetupWizardProxy, signalSetupAdapter } from "./setup-core.js"; +import { type ChannelPlugin } from "openclaw/plugin-sdk/signal"; +import { type ResolvedSignalAccount } from "./accounts.js"; +import { signalSetupAdapter } from "./setup-core.js"; +import { createSignalPluginBase, signalSetupWizard } from "./shared.js"; -async function loadSignalChannelRuntime() { - return await import("./channel.runtime.js"); -} - -const signalSetupWizard = createSignalSetupWizardProxy(async () => ({ - signalSetupWizard: (await loadSignalChannelRuntime()).signalSetupWizard, -})); - -const signalConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }), - resolveAllowFrom: (account: ResolvedSignalAccount) => account.config.allowFrom, - formatAllowFrom: (allowFrom) => - allowFrom - .map((entry) => String(entry).trim()) - .filter(Boolean) - .map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, "")))) - .filter(Boolean), - resolveDefaultTo: (account: ResolvedSignalAccount) => account.config.defaultTo, -}); - -export const signalSetupPlugin: ChannelPlugin = { - id: "signal", - meta: { - ...getChatChannelMeta("signal"), - }, +export const signalSetupPlugin: ChannelPlugin = createSignalPluginBase({ setupWizard: signalSetupWizard, - capabilities: { - chatTypes: ["direct", "group"], - media: true, - reactions: true, - }, - streaming: { - blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, - }, - reload: { configPrefixes: ["channels.signal"] }, - configSchema: buildChannelConfigSchema(SignalConfigSchema), - config: { - listAccountIds: (cfg) => listSignalAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultSignalAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "signal", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "signal", - accountId, - clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"], - }), - isConfigured: (account) => account.configured, - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - baseUrl: account.baseUrl, - }), - ...signalConfigAccessors, - }, - security: { - resolveDmPolicy: ({ cfg, accountId, account }) => - buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "signal", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()), - }), - collectWarnings: ({ account, cfg }) => - collectAllowlistProviderRestrictSendersWarnings({ - cfg, - providerConfigPresent: cfg.channels?.signal !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - surface: "Signal groups", - openScope: "any member", - groupPolicyPath: "channels.signal.groupPolicy", - groupAllowFromPath: "channels.signal.groupAllowFrom", - mentionGated: false, - }), - }, setup: signalSetupAdapter, -}; +}); diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index e1675a019d1..2b392bbacf2 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -1,26 +1,15 @@ -import { - buildAccountScopedAllowlistConfigEditor, - buildAccountScopedDmSecurityPolicy, - createScopedAccountConfigAccessors, - collectAllowlistProviderRestrictSendersWarnings, -} from "openclaw/plugin-sdk/compat"; +import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/compat"; import { buildAgentSessionKey, type RoutePeer } from "openclaw/plugin-sdk/core"; import { buildBaseAccountStatusSnapshot, buildBaseChannelStatusSummary, - buildChannelConfigSchema, collectStatusIssuesFromLastError, createDefaultChannelRuntimeState, DEFAULT_ACCOUNT_ID, - deleteAccountFromConfigSection, - getChatChannelMeta, looksLikeSignalTargetId, - normalizeE164, normalizeSignalMessagingTarget, PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, - setAccountEnabledInConfigSection, - SignalConfigSchema, type ChannelMessageActionAdapter, type ChannelPlugin, } from "openclaw/plugin-sdk/signal"; @@ -42,15 +31,8 @@ import { } from "./identity.js"; import type { SignalProbe } from "./probe.js"; import { getSignalRuntime } from "./runtime.js"; -import { createSignalSetupWizardProxy, signalSetupAdapter } from "./setup-core.js"; - -async function loadSignalChannelRuntime() { - return await import("./channel.runtime.js"); -} - -const signalSetupWizard = createSignalSetupWizardProxy(async () => ({ - signalSetupWizard: (await loadSignalChannelRuntime()).signalSetupWizard, -})); +import { signalSetupAdapter } from "./setup-core.js"; +import { createSignalPluginBase, signalConfigAccessors, signalSetupWizard } from "./shared.js"; const signalMessageActions: ChannelMessageActionAdapter = { listActions: (ctx) => getSignalRuntime().channel.signal.messageActions?.listActions?.(ctx) ?? [], @@ -65,18 +47,6 @@ const signalMessageActions: ChannelMessageActionAdapter = { }, }; -const signalConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }), - resolveAllowFrom: (account: ResolvedSignalAccount) => account.config.allowFrom, - formatAllowFrom: (allowFrom) => - allowFrom - .map((entry) => String(entry).trim()) - .filter(Boolean) - .map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, "")))) - .filter(Boolean), - resolveDefaultTo: (account: ResolvedSignalAccount) => account.config.defaultTo, -}); - type SignalSendFn = ReturnType["channel"]["signal"]["sendMessageSignal"]; function resolveSignalSendContext(params: { @@ -312,11 +282,10 @@ async function sendFormattedSignalMedia(ctx: { } export const signalPlugin: ChannelPlugin = { - id: "signal", - meta: { - ...getChatChannelMeta("signal"), - }, - setupWizard: signalSetupWizard, + ...createSignalPluginBase({ + setupWizard: signalSetupWizard, + setup: signalSetupAdapter, + }), pairing: { idLabel: "signalNumber", normalizeAllowEntry: (entry) => entry.replace(/^signal:/i, ""), @@ -324,46 +293,7 @@ export const signalPlugin: ChannelPlugin = { await getSignalRuntime().channel.signal.sendMessageSignal(id, PAIRING_APPROVED_MESSAGE); }, }, - capabilities: { - chatTypes: ["direct", "group"], - media: true, - reactions: true, - }, actions: signalMessageActions, - streaming: { - blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, - }, - reload: { configPrefixes: ["channels.signal"] }, - configSchema: buildChannelConfigSchema(SignalConfigSchema), - config: { - listAccountIds: (cfg) => listSignalAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultSignalAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => - setAccountEnabledInConfigSection({ - cfg, - sectionKey: "signal", - accountId, - enabled, - allowTopLevel: true, - }), - deleteAccount: ({ cfg, accountId }) => - deleteAccountFromConfigSection({ - cfg, - sectionKey: "signal", - accountId, - clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"], - }), - isConfigured: (account) => account.configured, - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: account.configured, - baseUrl: account.baseUrl, - }), - ...signalConfigAccessors, - }, allowlist: { supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", readConfig: ({ cfg, accountId }) => { @@ -385,32 +315,6 @@ export const signalPlugin: ChannelPlugin = { }), }), }, - security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "signal", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.config.dmPolicy, - allowFrom: account.config.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()), - }); - }, - collectWarnings: ({ account, cfg }) => { - return collectAllowlistProviderRestrictSendersWarnings({ - cfg, - providerConfigPresent: cfg.channels?.signal !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - surface: "Signal groups", - openScope: "any member", - groupPolicyPath: "channels.signal.groupPolicy", - groupAllowFromPath: "channels.signal.groupAllowFrom", - mentionGated: false, - }); - }, - }, messaging: { normalizeTarget: normalizeSignalMessagingTarget, parseExplicitTarget: ({ raw }) => parseSignalExplicitTarget(raw), @@ -421,7 +325,6 @@ export const signalPlugin: ChannelPlugin = { hint: "", }, }, - setup: signalSetupAdapter, outbound: { deliveryMode: "direct", chunker: (text, limit) => getSignalRuntime().channel.text.chunkText(text, limit), diff --git a/extensions/signal/src/identity.ts b/extensions/signal/src/identity.ts index c39b0dd5eaa..464713559c3 100644 --- a/extensions/signal/src/identity.ts +++ b/extensions/signal/src/identity.ts @@ -1,4 +1,4 @@ -import { evaluateSenderGroupAccessForPolicy } from "../../../src/plugin-sdk/group-access.js"; +import { evaluateSenderGroupAccessForPolicy } from "../../../src/plugin-sdk-internal/signal.js"; import { normalizeE164 } from "../../../src/utils.js"; export type SignalSender = diff --git a/extensions/signal/src/plugin-shared.ts b/extensions/signal/src/plugin-shared.ts new file mode 100644 index 00000000000..60559f09dcb --- /dev/null +++ b/extensions/signal/src/plugin-shared.ts @@ -0,0 +1,25 @@ +import { createScopedAccountConfigAccessors } from "../../../src/plugin-sdk-internal/channel-config.js"; +import { normalizeE164, type OpenClawConfig } from "../../../src/plugin-sdk-internal/signal.js"; +import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js"; +import { createSignalSetupWizardProxy } from "./setup-core.js"; + +async function loadSignalChannelRuntime() { + return await import("./channel.runtime.js"); +} + +export const signalSetupWizard = createSignalSetupWizardProxy(async () => ({ + signalSetupWizard: (await loadSignalChannelRuntime()).signalSetupWizard, +})); + +export const signalConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => + resolveSignalAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedSignalAccount) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, "")))) + .filter(Boolean), + resolveDefaultTo: (account: ResolvedSignalAccount) => account.config.defaultTo, +}); diff --git a/extensions/signal/src/runtime.ts b/extensions/signal/src/runtime.ts index b7cc4160f1c..99bdf04a447 100644 --- a/extensions/signal/src/runtime.ts +++ b/extensions/signal/src/runtime.ts @@ -1,5 +1,7 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; -import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import { + createPluginRuntimeStore, + type PluginRuntime, +} from "../../../src/plugin-sdk-internal/core.js"; const { setRuntime: setSignalRuntime, getRuntime: getSignalRuntime } = createPluginRuntimeStore("Signal runtime not initialized"); diff --git a/extensions/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts index 7e78fbf64a5..5e3901f0fae 100644 --- a/extensions/signal/src/setup-core.ts +++ b/extensions/signal/src/setup-core.ts @@ -1,10 +1,5 @@ +import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; import { - applyAccountNameToChannelSection, - DEFAULT_ACCOUNT_ID, - formatCliCommand, - formatDocsLink, - migrateBaseNameToDefaultAccount, - normalizeAccountId, normalizeE164, parseSetupEntriesAllowingWildcard, promptParsedAllowFromForScopedChannel, @@ -18,6 +13,7 @@ import type { ChannelSetupDmPolicy, ChannelSetupWizard, } from "../../../src/plugin-sdk-internal/setup.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; import { listSignalAccountIds, resolveDefaultSignalAccountId, @@ -28,7 +24,7 @@ const channel = "signal" as const; const MIN_E164_DIGITS = 5; const MAX_E164_DIGITS = 15; const DIGITS_ONLY = /^\d+$/; -const INVALID_SIGNAL_ACCOUNT_ERROR = +export const INVALID_SIGNAL_ACCOUNT_ERROR = "Invalid E.164 phone number (must start with + and country code, e.g. +15555550123)"; export function normalizeSignalAccountInput(value: string | null | undefined): string | null { @@ -87,7 +83,7 @@ function buildSignalSetupPatch(input: { }; } -async function promptSignalAllowFrom(params: { +export async function promptSignalAllowFrom(params: { cfg: OpenClawConfig; prompter: WizardPrompter; accountId?: string; @@ -115,15 +111,8 @@ async function promptSignalAllowFrom(params: { }); } -export const signalSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), +export const signalSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ + channelKey: channel, validateInput: ({ input }) => { if ( !input.signalNumber && @@ -136,74 +125,40 @@ export const signalSetupAdapter: ChannelSetupAdapter = { } return null; }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - signal: { - ...next.channels?.signal, - enabled: true, - ...buildSignalSetupPatch(input), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - signal: { - ...next.channels?.signal, - enabled: true, - accounts: { - ...next.channels?.signal?.accounts, - [accountId]: { - ...next.channels?.signal?.accounts?.[accountId], - enabled: true, - ...buildSignalSetupPatch(input), - }, - }, - }, - }, - }; - }, + buildPatch: (input) => buildSignalSetupPatch(input), +}); + +type SignalSetupWizardHandlers = { + resolveStatusLines: NonNullable["resolveStatusLines"]; + resolveSelectionHint: NonNullable["resolveSelectionHint"]; + resolveQuickstartScore: NonNullable["resolveQuickstartScore"]; + prepare?: ChannelSetupWizard["prepare"]; + shouldPromptCliPath: NonNullable< + NonNullable[number]["shouldPrompt"] + >; }; -export function createSignalSetupWizardProxy( - loadWizard: () => Promise<{ signalSetupWizard: ChannelSetupWizard }>, -) { +export function createSignalSetupWizardBase( + handlers: SignalSetupWizardHandlers, +): ChannelSetupWizard { + const setupChannel = "signal" as const; const signalDmPolicy: ChannelSetupDmPolicy = { label: "Signal", - channel, + channel: setupChannel, policyKey: "channels.signal.dmPolicy", allowFromKey: "channels.signal.allowFrom", getCurrent: (cfg: OpenClawConfig) => cfg.channels?.signal?.dmPolicy ?? "pairing", setPolicy: (cfg: OpenClawConfig, policy) => setChannelDmPolicyWithAllowFrom({ cfg, - channel, + channel: setupChannel, dmPolicy: policy, }), promptAllowFrom: promptSignalAllowFrom, }; return { - channel, + channel: setupChannel, status: { configuredLabel: "configured", unconfiguredLabel: "needs setup", @@ -215,14 +170,11 @@ export function createSignalSetupWizardProxy( listSignalAccountIds(cfg).some( (accountId) => resolveSignalAccount({ cfg, accountId }).configured, ), - resolveStatusLines: async (params) => - (await loadWizard()).signalSetupWizard.status.resolveStatusLines?.(params) ?? [], - resolveSelectionHint: async (params) => - await (await loadWizard()).signalSetupWizard.status.resolveSelectionHint?.(params), - resolveQuickstartScore: async (params) => - await (await loadWizard()).signalSetupWizard.status.resolveQuickstartScore?.(params), + resolveStatusLines: handlers.resolveStatusLines, + resolveSelectionHint: handlers.resolveSelectionHint, + resolveQuickstartScore: handlers.resolveQuickstartScore, }, - prepare: async (params) => await (await loadWizard()).signalSetupWizard.prepare?.(params), + prepare: handlers.prepare, credentials: [], textInputs: [ { @@ -236,12 +188,7 @@ export function createSignalSetupWizardProxy( (typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ?? resolveSignalAccount({ cfg, accountId }).config.cliPath ?? "signal-cli", - shouldPrompt: async (params) => { - const input = (await loadWizard()).signalSetupWizard.textInputs?.find( - (entry) => entry.inputKey === "cliPath", - ); - return (await input?.shouldPrompt?.(params)) ?? false; - }, + shouldPrompt: handlers.shouldPromptCliPath, confirmCurrentValue: false, applyCurrentValue: true, helpTitle: "Signal", @@ -266,11 +213,31 @@ export function createSignalSetupWizardProxy( lines: [ 'Link device with: signal-cli link -n "OpenClaw"', "Scan QR in Signal -> Linked Devices", - `Then run: ${formatCliCommand("openclaw gateway call channels.status --params '{\"probe\":true}'")}`, - `Docs: ${formatDocsLink("/signal", "signal")}`, + `Then run: openclaw gateway call channels.status --params '{"probe":true}'`, + "Docs: https://docs.openclaw.ai/signal", ], }, dmPolicy: signalDmPolicy, - disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), + disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, setupChannel, false), } satisfies ChannelSetupWizard; } + +export function createSignalSetupWizardProxy( + loadWizard: () => Promise<{ signalSetupWizard: ChannelSetupWizard }>, +) { + return createSignalSetupWizardBase({ + resolveStatusLines: async (params) => + (await loadWizard()).signalSetupWizard.status.resolveStatusLines?.(params) ?? [], + resolveSelectionHint: async (params) => + await (await loadWizard()).signalSetupWizard.status.resolveSelectionHint?.(params), + resolveQuickstartScore: async (params) => + await (await loadWizard()).signalSetupWizard.status.resolveQuickstartScore?.(params), + prepare: async (params) => await (await loadWizard()).signalSetupWizard.prepare?.(params), + shouldPromptCliPath: async (params) => { + const input = (await loadWizard()).signalSetupWizard.textInputs?.find( + (entry) => entry.inputKey === "cliPath", + ); + return (await input?.shouldPrompt?.(params)) ?? false; + }, + }); +} diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts index 5c40ba0788e..e3ac6f7e42a 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -1,107 +1,34 @@ import { - DEFAULT_ACCOUNT_ID, detectBinary, - formatCliCommand, - formatDocsLink, installSignalCli, type OpenClawConfig, - parseSetupEntriesAllowingWildcard, - promptParsedAllowFromForScopedChannel, - setChannelDmPolicyWithAllowFrom, - setSetupChannelEnabled, - type WizardPrompter, -} from "../../../src/plugin-sdk-internal/setup.js"; -import type { - ChannelSetupDmPolicy, - ChannelSetupWizard, } from "../../../src/plugin-sdk-internal/setup.js"; +import type { ChannelSetupWizard } from "../../../src/plugin-sdk-internal/setup.js"; +import { resolveSignalAccount } from "./accounts.js"; import { - listSignalAccountIds, - resolveDefaultSignalAccountId, - resolveSignalAccount, -} from "./accounts.js"; -import { + createSignalSetupWizardBase, + INVALID_SIGNAL_ACCOUNT_ERROR, normalizeSignalAccountInput, - parseSignalAllowFromEntries, + promptSignalAllowFrom, signalSetupAdapter, } from "./setup-core.js"; -const channel = "signal" as const; -const INVALID_SIGNAL_ACCOUNT_ERROR = - "Invalid E.164 phone number (must start with + and country code, e.g. +15555550123)"; - -async function promptSignalAllowFrom(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId?: string; -}): Promise { - return promptParsedAllowFromForScopedChannel({ - cfg: params.cfg, - channel, - accountId: params.accountId, - defaultAccountId: resolveDefaultSignalAccountId(params.cfg), - prompter: params.prompter, - noteTitle: "Signal allowlist", - noteLines: [ - "Allowlist Signal DMs by sender id.", - "Examples:", - "- +15555550123", - "- uuid:123e4567-e89b-12d3-a456-426614174000", - "Multiple entries: comma-separated.", - `Docs: ${formatDocsLink("/signal", "signal")}`, - ], - message: "Signal allowFrom (E.164 or uuid)", - placeholder: "+15555550123, uuid:123e4567-e89b-12d3-a456-426614174000", - parseEntries: parseSignalAllowFromEntries, - getExistingAllowFrom: ({ cfg, accountId }) => - resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? [], - }); -} - -const signalDmPolicy: ChannelSetupDmPolicy = { - label: "Signal", - channel, - policyKey: "channels.signal.dmPolicy", - allowFromKey: "channels.signal.allowFrom", - getCurrent: (cfg) => cfg.channels?.signal?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => - setChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy: policy, - }), - promptAllowFrom: promptSignalAllowFrom, -}; - -export const signalSetupWizard: ChannelSetupWizard = { - channel, - status: { - configuredLabel: "configured", - unconfiguredLabel: "needs setup", - configuredHint: "signal-cli found", - unconfiguredHint: "signal-cli missing", - configuredScore: 1, - unconfiguredScore: 0, - resolveConfigured: ({ cfg }) => - listSignalAccountIds(cfg).some( - (accountId) => resolveSignalAccount({ cfg, accountId }).configured, - ), - resolveStatusLines: async ({ cfg, configured }) => { - const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli"; - const signalCliDetected = await detectBinary(signalCliPath); - return [ - `Signal: ${configured ? "configured" : "needs setup"}`, - `signal-cli: ${signalCliDetected ? "found" : "missing"} (${signalCliPath})`, - ]; - }, - resolveSelectionHint: async ({ cfg }) => { - const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli"; - return (await detectBinary(signalCliPath)) ? "signal-cli found" : "signal-cli missing"; - }, - resolveQuickstartScore: async ({ cfg }) => { - const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli"; - return (await detectBinary(signalCliPath)) ? 1 : 0; - }, +export const signalSetupWizard: ChannelSetupWizard = createSignalSetupWizardBase({ + resolveStatusLines: async ({ cfg, configured }) => { + const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli"; + const signalCliDetected = await detectBinary(signalCliPath); + return [ + `Signal: ${configured ? "configured" : "needs setup"}`, + `signal-cli: ${signalCliDetected ? "found" : "missing"} (${signalCliPath})`, + ]; + }, + resolveSelectionHint: async ({ cfg }) => { + const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli"; + return (await detectBinary(signalCliPath)) ? "signal-cli found" : "signal-cli missing"; + }, + resolveQuickstartScore: async ({ cfg }) => { + const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli"; + return (await detectBinary(signalCliPath)) ? 1 : 0; }, prepare: async ({ cfg, accountId, credentialValues, runtime, prompter, options }) => { if (!options?.allowSignalInstall) { @@ -138,50 +65,13 @@ export const signalSetupWizard: ChannelSetupWizard = { await prompter.note(`signal-cli install failed: ${String(error)}`, "Signal"); } }, - credentials: [], - textInputs: [ - { - inputKey: "cliPath", - message: "signal-cli path", - currentValue: ({ cfg, accountId, credentialValues }) => - (typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ?? - resolveSignalAccount({ cfg, accountId }).config.cliPath ?? - "signal-cli", - initialValue: ({ cfg, accountId, credentialValues }) => - (typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ?? - resolveSignalAccount({ cfg, accountId }).config.cliPath ?? - "signal-cli", - shouldPrompt: async ({ currentValue }) => !(await detectBinary(currentValue ?? "signal-cli")), - confirmCurrentValue: false, - applyCurrentValue: true, - helpTitle: "Signal", - helpLines: [ - "signal-cli not found. Install it, then rerun this step or set channels.signal.cliPath.", - ], - }, - { - inputKey: "signalNumber", - message: "Signal bot number (E.164)", - currentValue: ({ cfg, accountId }) => - normalizeSignalAccountInput(resolveSignalAccount({ cfg, accountId }).config.account) ?? - undefined, - keepPrompt: (value) => `Signal account set (${value}). Keep it?`, - validate: ({ value }) => - normalizeSignalAccountInput(value) ? undefined : INVALID_SIGNAL_ACCOUNT_ERROR, - normalizeValue: ({ value }) => normalizeSignalAccountInput(value) ?? value, - }, - ], - completionNote: { - title: "Signal next steps", - lines: [ - 'Link device with: signal-cli link -n "OpenClaw"', - "Scan QR in Signal -> Linked Devices", - `Then run: ${formatCliCommand("openclaw gateway call channels.status --params '{\"probe\":true}'")}`, - `Docs: ${formatDocsLink("/signal", "signal")}`, - ], - }, - dmPolicy: signalDmPolicy, - disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), -}; + shouldPromptCliPath: async ({ currentValue }) => + !(await detectBinary(currentValue ?? "signal-cli")), +}); -export { normalizeSignalAccountInput, parseSignalAllowFromEntries, signalSetupAdapter }; +export { + INVALID_SIGNAL_ACCOUNT_ERROR, + normalizeSignalAccountInput, + promptSignalAllowFrom, + signalSetupAdapter, +}; diff --git a/extensions/signal/src/shared.ts b/extensions/signal/src/shared.ts new file mode 100644 index 00000000000..7c914f7ddf2 --- /dev/null +++ b/extensions/signal/src/shared.ts @@ -0,0 +1,133 @@ +import { + buildAccountScopedDmSecurityPolicy, + collectAllowlistProviderRestrictSendersWarnings, + createScopedAccountConfigAccessors, +} from "openclaw/plugin-sdk/compat"; +import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + getChatChannelMeta, + normalizeE164, + setAccountEnabledInConfigSection, + SignalConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/signal"; +import { + listSignalAccountIds, + resolveDefaultSignalAccountId, + resolveSignalAccount, + type ResolvedSignalAccount, +} from "./accounts.js"; +import { createSignalSetupWizardProxy } from "./setup-core.js"; + +export const SIGNAL_CHANNEL = "signal" as const; + +async function loadSignalChannelRuntime() { + return await import("./channel.runtime.js"); +} + +export const signalSetupWizard = createSignalSetupWizardProxy(async () => ({ + signalSetupWizard: (await loadSignalChannelRuntime()).signalSetupWizard, +})); + +export const signalConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedSignalAccount) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => (entry === "*" ? "*" : normalizeE164(entry.replace(/^signal:/i, "")))) + .filter(Boolean), + resolveDefaultTo: (account: ResolvedSignalAccount) => account.config.defaultTo, +}); + +export function createSignalPluginBase(params: { + setupWizard?: NonNullable["setupWizard"]>; + setup: NonNullable["setup"]>; +}): Pick< + ChannelPlugin, + | "id" + | "meta" + | "setupWizard" + | "capabilities" + | "streaming" + | "reload" + | "configSchema" + | "config" + | "security" + | "setup" +> { + return { + id: SIGNAL_CHANNEL, + meta: { + ...getChatChannelMeta(SIGNAL_CHANNEL), + }, + setupWizard: params.setupWizard, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + reactions: true, + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.signal"] }, + configSchema: buildChannelConfigSchema(SignalConfigSchema), + config: { + listAccountIds: (cfg) => listSignalAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultSignalAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: SIGNAL_CHANNEL, + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: SIGNAL_CHANNEL, + accountId, + clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"], + }), + isConfigured: (account) => account.configured, + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + baseUrl: account.baseUrl, + }), + ...signalConfigAccessors, + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => + buildAccountScopedDmSecurityPolicy({ + cfg, + channelKey: SIGNAL_CHANNEL, + accountId, + fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, + policy: account.config.dmPolicy, + allowFrom: account.config.allowFrom ?? [], + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => normalizeE164(raw.replace(/^signal:/i, "").trim()), + }), + collectWarnings: ({ account, cfg }) => + collectAllowlistProviderRestrictSendersWarnings({ + cfg, + providerConfigPresent: cfg.channels?.signal !== undefined, + configuredGroupPolicy: account.config.groupPolicy, + surface: "Signal groups", + openScope: "any member", + groupPolicyPath: "channels.signal.groupPolicy", + groupAllowFromPath: "channels.signal.groupAllowFrom", + mentionGated: false, + }), + }, + setup: params.setup, + }; +} diff --git a/extensions/slack/src/account-inspect.ts b/extensions/slack/src/account-inspect.ts index 8ada00e9832..1cc3f2b8509 100644 --- a/extensions/slack/src/account-inspect.ts +++ b/extensions/slack/src/account-inspect.ts @@ -1,13 +1,13 @@ +import { + hasConfiguredSecretInput, + normalizeSecretInputString, +} from "../../../src/config/types.secrets.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId, type OpenClawConfig, type SlackAccountConfig, -} from "openclaw/plugin-sdk/slack"; -import { - hasConfiguredSecretInput, - normalizeSecretInputString, -} from "../../../src/config/types.secrets.js"; +} from "../../../src/plugin-sdk-internal/slack.js"; import type { SlackAccountSurfaceFields } from "./account-surface-fields.js"; import { mergeSlackAccountConfig, diff --git a/extensions/slack/src/accounts.ts b/extensions/slack/src/accounts.ts index 51faf8a4a6b..4297e74902b 100644 --- a/extensions/slack/src/accounts.ts +++ b/extensions/slack/src/accounts.ts @@ -1,4 +1,3 @@ -import type { SlackAccountConfig } from "openclaw/plugin-sdk/slack"; import { type OpenClawConfig, createAccountListHelpers, @@ -7,6 +6,7 @@ import { normalizeChatType, resolveAccountEntry, } from "../../../src/plugin-sdk-internal/accounts.js"; +import type { SlackAccountConfig } from "../../../src/plugin-sdk-internal/slack.js"; import type { SlackAccountSurfaceFields } from "./account-surface-fields.js"; import { resolveSlackAppToken, resolveSlackBotToken, resolveSlackUserToken } from "./token.js"; diff --git a/extensions/slack/src/channel.setup.ts b/extensions/slack/src/channel.setup.ts index c221cc9cebf..003c33e04b4 100644 --- a/extensions/slack/src/channel.setup.ts +++ b/extensions/slack/src/channel.setup.ts @@ -1,13 +1,7 @@ -import { - buildChannelConfigSchema, - getChatChannelMeta, - SlackConfigSchema, - type ChannelPlugin, -} from "openclaw/plugin-sdk/slack"; +import { type ChannelPlugin } from "openclaw/plugin-sdk/slack"; import { type ResolvedSlackAccount } from "./accounts.js"; -import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js"; -import { isSlackPluginAccountConfigured, slackConfigAccessors, slackConfigBase } from "./shared.js"; +import { createSlackPluginBase } from "./shared.js"; async function loadSlackChannelRuntime() { return await import("./channel.runtime.js"); @@ -18,47 +12,8 @@ const slackSetupWizard = createSlackSetupWizardProxy(async () => ({ })); export const slackSetupPlugin: ChannelPlugin = { - id: "slack", - meta: { - ...getChatChannelMeta("slack"), - preferSessionLookupForAnnounceTarget: true, - }, - setupWizard: slackSetupWizard, - capabilities: { - chatTypes: ["direct", "channel", "thread"], - reactions: true, - threads: true, - media: true, - nativeCommands: true, - }, - agentPrompt: { - messageToolHints: ({ cfg, accountId }) => - isSlackInteractiveRepliesEnabled({ cfg, accountId }) - ? [ - "- Slack interactive replies: use `[[slack_buttons: Label:value, Other:other]]` to add action buttons that route clicks back as Slack interaction system events.", - "- Slack selects: use `[[slack_select: Placeholder | Label:value, Other:other]]` to add a static select menu that routes the chosen value back as a Slack interaction system event.", - ] - : [ - "- Slack interactive replies are disabled. If needed, ask to set `channels.slack.capabilities.interactiveReplies=true` (or the same under `channels.slack.accounts..capabilities`).", - ], - }, - streaming: { - blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, - }, - reload: { configPrefixes: ["channels.slack"] }, - configSchema: buildChannelConfigSchema(SlackConfigSchema), - config: { - ...slackConfigBase, - isConfigured: (account) => isSlackPluginAccountConfigured(account), - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: isSlackPluginAccountConfigured(account), - botTokenSource: account.botTokenSource, - appTokenSource: account.appTokenSource, - }), - ...slackConfigAccessors, - }, - setup: slackSetupAdapter, + ...createSlackPluginBase({ + setupWizard: slackSetupWizard, + setup: slackSetupAdapter, + }), }; diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 4a43055c142..2980316a138 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -11,9 +11,7 @@ import { } from "openclaw/plugin-sdk/core"; import { buildComputedAccountStatusSnapshot, - buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, - getChatChannelMeta, listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig, looksLikeSlackTargetId, @@ -23,11 +21,12 @@ import { resolveConfiguredFromRequiredCredentialStatuses, resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy, - SlackConfigSchema, type ChannelPlugin, type OpenClawConfig, } from "openclaw/plugin-sdk/slack"; +import { createSlackActions } from "../../../src/channels/plugins/slack.actions.js"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; +import { normalizeOutboundThreadId } from "../../../src/infra/outbound/thread-id.js"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { listEnabledSlackAccounts, @@ -38,19 +37,20 @@ import { import { parseSlackBlocksInput } from "./blocks-input.js"; import { createSlackWebClient } from "./client.js"; import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; -import { handleSlackMessageAction } from "./message-action-dispatch.js"; -import { extractSlackToolSend, listSlackMessageActions } from "./message-actions.js"; import { normalizeAllowListLower } from "./monitor/allow-list.js"; import type { SlackProbe } from "./probe.js"; import { resolveSlackUserAllowlist } from "./resolve-users.js"; import { getSlackRuntime } from "./runtime.js"; import { fetchSlackScopes } from "./scopes.js"; import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js"; -import { isSlackPluginAccountConfigured, slackConfigAccessors, slackConfigBase } from "./shared.js"; +import { + createSlackPluginBase, + isSlackPluginAccountConfigured, + slackConfigAccessors, +} from "./shared.js"; import { parseSlackTarget } from "./targets.js"; import { buildSlackThreadingToolContext } from "./threading-tool-context.js"; -const meta = getChatChannelMeta("slack"); const SLACK_CHANNEL_TYPE_CACHE = new Map(); async function loadSlackChannelRuntime() { @@ -136,20 +136,6 @@ function parseSlackExplicitTarget(raw: string) { }; } -function normalizeOutboundThreadId(value?: string | number | null): string | undefined { - if (value == null) { - return undefined; - } - if (typeof value === "number") { - if (!Number.isFinite(value)) { - return undefined; - } - return String(Math.trunc(value)); - } - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; -} - function buildSlackBaseSessionKey(params: { cfg: OpenClawConfig; agentId: string; @@ -332,13 +318,17 @@ const slackSetupWizard = createSlackSetupWizardProxy(async () => ({ slackSetupWizard: (await loadSlackChannelRuntime()).slackSetupWizard, })); +const slackActions = createSlackActions("slack", { + invoke: () => async (action, cfg, toolContext) => + await getSlackRuntime().channel.slack.handleSlackAction(action, cfg, toolContext), + skipNormalizeChannelId: true, +}); + export const slackPlugin: ChannelPlugin = { - id: "slack", - meta: { - ...meta, - preferSessionLookupForAnnounceTarget: true, - }, - setupWizard: slackSetupWizard, + ...createSlackPluginBase({ + setupWizard: slackSetupWizard, + setup: slackSetupAdapter, + }), pairing: { idLabel: "slackUserId", normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""), @@ -367,42 +357,6 @@ export const slackPlugin: ChannelPlugin = { } }, }, - capabilities: { - chatTypes: ["direct", "channel", "thread"], - reactions: true, - threads: true, - media: true, - nativeCommands: true, - }, - agentPrompt: { - messageToolHints: ({ cfg, accountId }) => - isSlackInteractiveRepliesEnabled({ cfg, accountId }) - ? [ - "- Slack interactive replies: use `[[slack_buttons: Label:value, Other:other]]` to add action buttons that route clicks back as Slack interaction system events.", - "- Slack selects: use `[[slack_select: Placeholder | Label:value, Other:other]]` to add a static select menu that routes the chosen value back as a Slack interaction system event.", - ] - : [ - "- Slack interactive replies are disabled. If needed, ask to set `channels.slack.capabilities.interactiveReplies=true` (or the same under `channels.slack.accounts..capabilities`).", - ], - }, - streaming: { - blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, - }, - reload: { configPrefixes: ["channels.slack"] }, - configSchema: buildChannelConfigSchema(SlackConfigSchema), - config: { - ...slackConfigBase, - isConfigured: (account) => isSlackPluginAccountConfigured(account), - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: isSlackPluginAccountConfigured(account), - botTokenSource: account.botTokenSource, - appTokenSource: account.appTokenSource, - }), - ...slackConfigAccessors, - }, allowlist: { supportsScope: ({ scope }) => scope === "dm", readConfig: ({ cfg, accountId }) => @@ -557,29 +511,7 @@ export const slackPlugin: ChannelPlugin = { return resolved.map((entry) => toResolvedTarget(entry, entry.note)); }, }, - actions: { - listActions: ({ cfg }) => listSlackMessageActions(cfg), - getCapabilities: ({ cfg }) => { - const capabilities = new Set<"interactive" | "blocks">(); - if (listSlackMessageActions(cfg).includes("send")) { - capabilities.add("blocks"); - } - if (isSlackInteractiveRepliesEnabled({ cfg })) { - capabilities.add("interactive"); - } - return Array.from(capabilities); - }, - extractToolSend: ({ args }) => extractSlackToolSend(args), - handleAction: async (ctx) => - await handleSlackMessageAction({ - providerId: meta.id, - ctx, - includeReadThreadId: true, - invoke: async (action, cfg, toolContext) => - await getSlackRuntime().channel.slack.handleSlackAction(action, cfg, toolContext), - }), - }, - setup: slackSetupAdapter, + actions: slackActions, outbound: { deliveryMode: "direct", chunker: null, diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index b0883be083d..486acfd4b2b 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -1 +1 @@ -export { handleSlackMessageAction } from "../../../src/plugin-sdk/slack-message-actions.js"; +export { handleSlackMessageAction } from "../../../src/plugin-sdk-internal/slack.js"; diff --git a/extensions/slack/src/monitor/media.ts b/extensions/slack/src/monitor/media.ts index 7c5a619129f..ef494f2e48c 100644 --- a/extensions/slack/src/monitor/media.ts +++ b/extensions/slack/src/monitor/media.ts @@ -1,9 +1,9 @@ import type { WebClient as SlackWebClient } from "@slack/web-api"; +import { resolveRequestUrl } from "openclaw/plugin-sdk/request-url"; import { normalizeHostname } from "../../../../src/infra/net/hostname.js"; import type { FetchLike } from "../../../../src/media/fetch.js"; import { fetchRemoteMedia } from "../../../../src/media/fetch.js"; import { saveMediaBuffer } from "../../../../src/media/store.js"; -import { resolveRequestUrl } from "../../../../src/plugin-sdk/request-url.js"; import type { SlackAttachment, SlackFile } from "../types.js"; function isSlackHostname(hostname: string): boolean { diff --git a/extensions/slack/src/monitor/policy.ts b/extensions/slack/src/monitor/policy.ts index ab5d9230a62..9f58c758c51 100644 --- a/extensions/slack/src/monitor/policy.ts +++ b/extensions/slack/src/monitor/policy.ts @@ -1,4 +1,4 @@ -import { evaluateGroupRouteAccessForPolicy } from "../../../../src/plugin-sdk/group-access.js"; +import { evaluateGroupRouteAccessForPolicy } from "openclaw/plugin-sdk/group-access"; export function isSlackChannelAllowedByPolicy(params: { groupPolicy: "open" | "disabled" | "allowlist"; diff --git a/extensions/slack/src/plugin-shared.ts b/extensions/slack/src/plugin-shared.ts new file mode 100644 index 00000000000..0c5a6c7957e --- /dev/null +++ b/extensions/slack/src/plugin-shared.ts @@ -0,0 +1,53 @@ +import { + createScopedAccountConfigAccessors, + createScopedChannelConfigBase, + formatAllowFromLowercase, +} from "../../../src/plugin-sdk-internal/channel-config.js"; +import { type OpenClawConfig } from "../../../src/plugin-sdk-internal/slack.js"; +import { inspectSlackAccount } from "./account-inspect.js"; +import { + listSlackAccountIds, + resolveDefaultSlackAccountId, + resolveSlackAccount, + type ResolvedSlackAccount, +} from "./accounts.js"; +import { createSlackSetupWizardProxy } from "./setup-core.js"; + +async function loadSlackChannelRuntime() { + return await import("./channel.runtime.js"); +} + +export function isSlackAccountConfigured(account: ResolvedSlackAccount): boolean { + const mode = account.config.mode ?? "socket"; + const hasBotToken = Boolean(account.botToken?.trim()); + if (!hasBotToken) { + return false; + } + if (mode === "http") { + return Boolean(account.config.signingSecret?.trim()); + } + return Boolean(account.appToken?.trim()); +} + +export const isSlackPluginAccountConfigured = isSlackAccountConfigured; + +export const slackConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => + resolveSlackAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedSlackAccount) => account.dm?.allowFrom, + formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), + resolveDefaultTo: (account: ResolvedSlackAccount) => account.config.defaultTo, +}); + +export const slackConfigBase = createScopedChannelConfigBase({ + sectionKey: "slack", + listAccountIds: listSlackAccountIds, + resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultSlackAccountId, + clearBaseFields: ["botToken", "appToken", "name"], +}); + +export const slackSetupWizard = createSlackSetupWizardProxy(async () => ({ + slackSetupWizard: (await loadSlackChannelRuntime()).slackSetupWizard, +})); diff --git a/extensions/slack/src/runtime.ts b/extensions/slack/src/runtime.ts index 313f472eec4..d7d09dbcb6b 100644 --- a/extensions/slack/src/runtime.ts +++ b/extensions/slack/src/runtime.ts @@ -1,5 +1,7 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; -import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import { + createPluginRuntimeStore, + type PluginRuntime, +} from "../../../src/plugin-sdk-internal/core.js"; const { setRuntime: setSlackRuntime, getRuntime: getSlackRuntime } = createPluginRuntimeStore("Slack runtime not initialized"); diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index a0f068b3e81..b53472c3ce9 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -1,10 +1,7 @@ +import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; import { - applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, - formatDocsLink, hasConfiguredSecretInput, - migrateBaseNameToDefaultAccount, - normalizeAccountId, type OpenClawConfig, noteChannelLookupFailure, noteChannelLookupSummary, @@ -20,6 +17,7 @@ import { type ChannelSetupWizard, type ChannelSetupWizardAllowFromEntry, } from "../../../src/plugin-sdk-internal/setup.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; import { inspectSlackAccount } from "./account-inspect.js"; import { listSlackAccountIds, resolveSlackAccount, type ResolvedSlackAccount } from "./accounts.js"; import { @@ -38,15 +36,8 @@ function enableSlackAccount(cfg: OpenClawConfig, accountId: string): OpenClawCon }); } -export const slackSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), +export const slackSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ + channelKey: channel, validateInput: ({ accountId, input }) => { if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { return "Slack env tokens can only be used for the default account."; @@ -56,63 +47,93 @@ export const slackSetupAdapter: ChannelSetupAdapter = { } return null; }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - slack: { - ...next.channels?.slack, - enabled: true, - ...(input.useEnv - ? {} - : { - ...(input.botToken ? { botToken: input.botToken } : {}), - ...(input.appToken ? { appToken: input.appToken } : {}), - }), - }, + buildPatch: (input) => + input.useEnv + ? {} + : { + ...(input.botToken ? { botToken: input.botToken } : {}), + ...(input.appToken ? { appToken: input.appToken } : {}), }, - }; - } - return { - ...next, - channels: { - ...next.channels, - slack: { - ...next.channels?.slack, - enabled: true, - accounts: { - ...next.channels?.slack?.accounts, - [accountId]: { - ...next.channels?.slack?.accounts?.[accountId], - enabled: true, - ...(input.botToken ? { botToken: input.botToken } : {}), - ...(input.appToken ? { appToken: input.appToken } : {}), - }, - }, - }, - }, - }; - }, +}); + +type SlackAllowFromResolverParams = { + cfg: OpenClawConfig; + accountId: string; + credentialValues: { botToken?: string }; + entries: string[]; }; -export function createSlackSetupWizardProxy( - loadWizard: () => Promise<{ slackSetupWizard: ChannelSetupWizard }>, -) { +type SlackGroupAllowlistResolverParams = SlackAllowFromResolverParams & { + prompter: { note: (message: string, title?: string) => Promise }; +}; + +type SlackSetupWizardHandlers = { + promptAllowFrom: (params: { + cfg: OpenClawConfig; + prompter: import("../../../src/plugin-sdk-internal/setup.js").WizardPrompter; + accountId?: string; + }) => Promise; + resolveAllowFromEntries: ( + params: SlackAllowFromResolverParams, + ) => Promise; + resolveGroupAllowlist: (params: SlackGroupAllowlistResolverParams) => Promise; +}; + +function buildSlackTokenCredential(params: { + inputKey: "botToken" | "appToken"; + providerHint: "slack-bot" | "slack-app"; + credentialLabel: string; + preferredEnvVar: "SLACK_BOT_TOKEN" | "SLACK_APP_TOKEN"; + inputPrompt: string; +}): NonNullable[number] { + const configKey = params.inputKey; + return { + inputKey: params.inputKey, + providerHint: params.providerHint, + credentialLabel: params.credentialLabel, + preferredEnvVar: params.preferredEnvVar, + envPrompt: `${params.preferredEnvVar} detected. Use env var?`, + keepPrompt: `${params.credentialLabel} already configured. Keep it?`, + inputPrompt: params.inputPrompt, + allowEnv: ({ accountId }: { accountId: string }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => { + const resolved = resolveSlackAccount({ cfg, accountId }); + const tokenValue = resolved[configKey]?.trim() || undefined; + const configuredValue = resolved.config[configKey]; + return { + accountConfigured: Boolean(tokenValue) || hasConfiguredSecretInput(configuredValue), + hasConfiguredValue: hasConfiguredSecretInput(configuredValue), + resolvedValue: tokenValue, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env[params.preferredEnvVar]?.trim() + : undefined, + }; + }, + applyUseEnv: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => + enableSlackAccount(cfg, accountId), + applySet: ({ + cfg, + accountId, + value, + }: { + cfg: OpenClawConfig; + accountId: string; + value: unknown; + }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { + enabled: true, + [configKey]: value, + }, + }), + }; +} + +export function createSlackSetupWizardBase(handlers: SlackSetupWizardHandlers): ChannelSetupWizard { const slackDmPolicy: ChannelSetupDmPolicy = { label: "Slack", channel, @@ -126,13 +147,7 @@ export function createSlackSetupWizardProxy( channel, dmPolicy: policy, }), - promptAllowFrom: async ({ cfg, prompter, accountId }) => { - const wizard = (await loadWizard()).slackSetupWizard; - if (!wizard.dmPolicy?.promptAllowFrom) { - return cfg; - } - return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId }); - }, + promptAllowFrom: handlers.promptAllowFrom, }; return { @@ -167,88 +182,20 @@ export function createSlackSetupWizardProxy( apply: ({ cfg, accountId }) => enableSlackAccount(cfg, accountId), }, credentials: [ - { + buildSlackTokenCredential({ inputKey: "botToken", providerHint: "slack-bot", credentialLabel: "Slack bot token", preferredEnvVar: "SLACK_BOT_TOKEN", - envPrompt: "SLACK_BOT_TOKEN detected. Use env var?", - keepPrompt: "Slack bot token already configured. Keep it?", inputPrompt: "Enter Slack bot token (xoxb-...)", - allowEnv: ({ accountId }: { accountId: string }) => accountId === DEFAULT_ACCOUNT_ID, - inspect: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => { - const resolved = resolveSlackAccount({ cfg, accountId }); - return { - accountConfigured: - Boolean(resolved.botToken) || hasConfiguredSecretInput(resolved.config.botToken), - hasConfiguredValue: hasConfiguredSecretInput(resolved.config.botToken), - resolvedValue: resolved.botToken?.trim() || undefined, - envValue: - accountId === DEFAULT_ACCOUNT_ID ? process.env.SLACK_BOT_TOKEN?.trim() : undefined, - }; - }, - applyUseEnv: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => - enableSlackAccount(cfg, accountId), - applySet: ({ - cfg, - accountId, - value, - }: { - cfg: OpenClawConfig; - accountId: string; - value: unknown; - }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { - enabled: true, - botToken: value, - }, - }), - }, - { + }), + buildSlackTokenCredential({ inputKey: "appToken", providerHint: "slack-app", credentialLabel: "Slack app token", preferredEnvVar: "SLACK_APP_TOKEN", - envPrompt: "SLACK_APP_TOKEN detected. Use env var?", - keepPrompt: "Slack app token already configured. Keep it?", inputPrompt: "Enter Slack app token (xapp-...)", - allowEnv: ({ accountId }: { accountId: string }) => accountId === DEFAULT_ACCOUNT_ID, - inspect: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => { - const resolved = resolveSlackAccount({ cfg, accountId }); - return { - accountConfigured: - Boolean(resolved.appToken) || hasConfiguredSecretInput(resolved.config.appToken), - hasConfiguredValue: hasConfiguredSecretInput(resolved.config.appToken), - resolvedValue: resolved.appToken?.trim() || undefined, - envValue: - accountId === DEFAULT_ACCOUNT_ID ? process.env.SLACK_APP_TOKEN?.trim() : undefined, - }; - }, - applyUseEnv: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => - enableSlackAccount(cfg, accountId), - applySet: ({ - cfg, - accountId, - value, - }: { - cfg: OpenClawConfig; - accountId: string; - value: unknown; - }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { - enabled: true, - appToken: value, - }, - }), - }, + }), ], dmPolicy: slackDmPolicy, allowFrom: { @@ -273,28 +220,7 @@ export function createSlackSetupWizardProxy( idPattern: /^[A-Z][A-Z0-9]+$/i, normalizeId: (id) => id.toUpperCase(), }), - resolveEntries: async ({ - cfg, - accountId, - credentialValues, - entries, - }: { - cfg: OpenClawConfig; - accountId: string; - credentialValues: { botToken?: string }; - entries: string[]; - }) => { - const wizard = (await loadWizard()).slackSetupWizard; - if (!wizard.allowFrom) { - return entries.map((input) => ({ input, resolved: false, id: null })); - } - return await wizard.allowFrom.resolveEntries({ - cfg, - accountId, - credentialValues, - entries, - }); - }, + resolveEntries: handlers.resolveAllowFromEntries, apply: ({ cfg, accountId, @@ -337,44 +263,22 @@ export function createSlackSetupWizardProxy( accountId, groupPolicy: policy, }), - resolveAllowlist: async ({ - cfg, - accountId, - credentialValues, - entries, - prompter, - }: { - cfg: OpenClawConfig; - accountId: string; - credentialValues: { botToken?: string }; - entries: string[]; - prompter: { note: (message: string, title?: string) => Promise }; - }) => { + resolveAllowlist: async (params: SlackGroupAllowlistResolverParams) => { try { - const wizard = (await loadWizard()).slackSetupWizard; - if (!wizard.groupAccess?.resolveAllowlist) { - return entries; - } - return await wizard.groupAccess.resolveAllowlist({ - cfg, - accountId, - credentialValues, - entries, - prompter, - }); + return await handlers.resolveGroupAllowlist(params); } catch (error) { await noteChannelLookupFailure({ - prompter, + prompter: params.prompter, label: "Slack channels", error, }); await noteChannelLookupSummary({ - prompter, + prompter: params.prompter, label: "Slack channels", resolvedSections: [], - unresolved: entries, + unresolved: params.entries, }); - return entries; + return params.entries; } }, applyAllowlist: ({ @@ -390,3 +294,42 @@ export function createSlackSetupWizardProxy( disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false), } satisfies ChannelSetupWizard; } + +export function createSlackSetupWizardProxy( + loadWizard: () => Promise<{ slackSetupWizard: ChannelSetupWizard }>, +) { + return createSlackSetupWizardBase({ + promptAllowFrom: async ({ cfg, prompter, accountId }) => { + const wizard = (await loadWizard()).slackSetupWizard; + if (!wizard.dmPolicy?.promptAllowFrom) { + return cfg; + } + return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId }); + }, + resolveAllowFromEntries: async ({ cfg, accountId, credentialValues, entries }) => { + const wizard = (await loadWizard()).slackSetupWizard; + if (!wizard.allowFrom) { + return entries.map((input) => ({ input, resolved: false, id: null })); + } + return await wizard.allowFrom.resolveEntries({ + cfg, + accountId, + credentialValues, + entries, + }); + }, + resolveGroupAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { + const wizard = (await loadWizard()).slackSetupWizard; + if (!wizard.groupAccess?.resolveAllowlist) { + return entries; + } + return (await wizard.groupAccess.resolveAllowlist({ + cfg, + accountId, + credentialValues, + entries, + prompter, + })) as string[]; + }, + }); +} diff --git a/extensions/slack/src/setup-surface.ts b/extensions/slack/src/setup-surface.ts index de7dc06e40e..8f5024276ca 100644 --- a/extensions/slack/src/setup-surface.ts +++ b/extensions/slack/src/setup-surface.ts @@ -1,50 +1,22 @@ import { - DEFAULT_ACCOUNT_ID, formatDocsLink, - hasConfiguredSecretInput, noteChannelLookupFailure, noteChannelLookupSummary, - normalizeAccountId, type OpenClawConfig, parseMentionOrPrefixedId, - patchChannelConfigForAccount, promptLegacyChannelAllowFrom, resolveSetupAccountId, - setAccountGroupPolicyForChannel, - setLegacyChannelDmPolicyWithAllowFrom, - setSetupChannelEnabled, type WizardPrompter, } from "../../../src/plugin-sdk-internal/setup.js"; import type { - ChannelSetupDmPolicy, ChannelSetupWizard, ChannelSetupWizardAllowFromEntry, } from "../../../src/plugin-sdk-internal/setup.js"; -import { inspectSlackAccount } from "./account-inspect.js"; -import { - listSlackAccountIds, - resolveDefaultSlackAccountId, - resolveSlackAccount, - type ResolvedSlackAccount, -} from "./accounts.js"; +import { resolveDefaultSlackAccountId, resolveSlackAccount } from "./accounts.js"; import { resolveSlackChannelAllowlist } from "./resolve-channels.js"; import { resolveSlackUserAllowlist } from "./resolve-users.js"; -import { slackSetupAdapter } from "./setup-core.js"; -import { - buildSlackSetupLines, - isSlackSetupAccountConfigured, - setSlackChannelAllowlist, - SLACK_CHANNEL as channel, -} from "./shared.js"; - -function enableSlackAccount(cfg: OpenClawConfig, accountId: string): OpenClawConfig { - return patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { enabled: true }, - }); -} +import { createSlackSetupWizardBase } from "./setup-core.js"; +import { SLACK_CHANNEL as channel } from "./shared.js"; async function resolveSlackAllowFromEntries(params: { token?: string; @@ -117,211 +89,45 @@ async function promptSlackAllowFrom(params: { }); } -const slackDmPolicy: ChannelSetupDmPolicy = { - label: "Slack", - channel, - policyKey: "channels.slack.dmPolicy", - allowFromKey: "channels.slack.allowFrom", - getCurrent: (cfg) => - cfg.channels?.slack?.dmPolicy ?? cfg.channels?.slack?.dm?.policy ?? "pairing", - setPolicy: (cfg, policy) => - setLegacyChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy: policy, - }), +export const slackSetupWizard: ChannelSetupWizard = createSlackSetupWizardBase({ promptAllowFrom: promptSlackAllowFrom, -}; - -export const slackSetupWizard: ChannelSetupWizard = { - channel, - status: { - configuredLabel: "configured", - unconfiguredLabel: "needs tokens", - configuredHint: "configured", - unconfiguredHint: "needs tokens", - configuredScore: 2, - unconfiguredScore: 1, - resolveConfigured: ({ cfg }) => - listSlackAccountIds(cfg).some((accountId) => { - const account = inspectSlackAccount({ cfg, accountId }); - return account.configured; - }), - }, - introNote: { - title: "Slack socket mode tokens", - lines: buildSlackSetupLines(), - shouldShow: ({ cfg, accountId }) => - !isSlackSetupAccountConfigured(resolveSlackAccount({ cfg, accountId })), - }, - envShortcut: { - prompt: "SLACK_BOT_TOKEN + SLACK_APP_TOKEN detected. Use env vars?", - preferredEnvVar: "SLACK_BOT_TOKEN", - isAvailable: ({ cfg, accountId }) => - accountId === DEFAULT_ACCOUNT_ID && - Boolean(process.env.SLACK_BOT_TOKEN?.trim()) && - Boolean(process.env.SLACK_APP_TOKEN?.trim()) && - !isSlackSetupAccountConfigured(resolveSlackAccount({ cfg, accountId })), - apply: ({ cfg, accountId }) => enableSlackAccount(cfg, accountId), - }, - credentials: [ - { - inputKey: "botToken", - providerHint: "slack-bot", - credentialLabel: "Slack bot token", - preferredEnvVar: "SLACK_BOT_TOKEN", - envPrompt: "SLACK_BOT_TOKEN detected. Use env var?", - keepPrompt: "Slack bot token already configured. Keep it?", - inputPrompt: "Enter Slack bot token (xoxb-...)", - allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, - inspect: ({ cfg, accountId }) => { - const resolved = resolveSlackAccount({ cfg, accountId }); - return { - accountConfigured: - Boolean(resolved.botToken) || hasConfiguredSecretInput(resolved.config.botToken), - hasConfiguredValue: hasConfiguredSecretInput(resolved.config.botToken), - resolvedValue: resolved.botToken?.trim() || undefined, - envValue: - accountId === DEFAULT_ACCOUNT_ID ? process.env.SLACK_BOT_TOKEN?.trim() : undefined, - }; - }, - applyUseEnv: ({ cfg, accountId }) => enableSlackAccount(cfg, accountId), - applySet: ({ cfg, accountId, value }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { - enabled: true, - botToken: value, - }, - }), - }, - { - inputKey: "appToken", - providerHint: "slack-app", - credentialLabel: "Slack app token", - preferredEnvVar: "SLACK_APP_TOKEN", - envPrompt: "SLACK_APP_TOKEN detected. Use env var?", - keepPrompt: "Slack app token already configured. Keep it?", - inputPrompt: "Enter Slack app token (xapp-...)", - allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, - inspect: ({ cfg, accountId }) => { - const resolved = resolveSlackAccount({ cfg, accountId }); - return { - accountConfigured: - Boolean(resolved.appToken) || hasConfiguredSecretInput(resolved.config.appToken), - hasConfiguredValue: hasConfiguredSecretInput(resolved.config.appToken), - resolvedValue: resolved.appToken?.trim() || undefined, - envValue: - accountId === DEFAULT_ACCOUNT_ID ? process.env.SLACK_APP_TOKEN?.trim() : undefined, - }; - }, - applyUseEnv: ({ cfg, accountId }) => enableSlackAccount(cfg, accountId), - applySet: ({ cfg, accountId, value }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { - enabled: true, - appToken: value, - }, - }), - }, - ], - dmPolicy: slackDmPolicy, - allowFrom: { - helpTitle: "Slack allowlist", - helpLines: [ - "Allowlist Slack DMs by username (we resolve to user ids).", - "Examples:", - "- U12345678", - "- @alice", - "Multiple entries: comma-separated.", - `Docs: ${formatDocsLink("/slack", "slack")}`, - ], - credentialInputKey: "botToken", - message: "Slack allowFrom (usernames or ids)", - placeholder: "@alice, U12345678", - invalidWithoutCredentialNote: "Slack token missing; use user ids (or mention form) only.", - parseId: (value) => - parseMentionOrPrefixedId({ - value, - mentionPattern: /^<@([A-Z0-9]+)>$/i, - prefixPattern: /^(slack:|user:)/i, - idPattern: /^[A-Z][A-Z0-9]+$/i, - normalizeId: (id) => id.toUpperCase(), - }), - resolveEntries: async ({ credentialValues, entries }) => - await resolveSlackAllowFromEntries({ - token: credentialValues.botToken, - entries, - }), - apply: ({ cfg, accountId, allowFrom }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { dmPolicy: "allowlist", allowFrom }, - }), - }, - groupAccess: { - label: "Slack channels", - placeholder: "#general, #private, C123", - currentPolicy: ({ cfg, accountId }) => - resolveSlackAccount({ cfg, accountId }).config.groupPolicy ?? "allowlist", - currentEntries: ({ cfg, accountId }) => - Object.entries(resolveSlackAccount({ cfg, accountId }).config.channels ?? {}) - .filter(([, value]) => value?.allow !== false && value?.enabled !== false) - .map(([key]) => key), - updatePrompt: ({ cfg, accountId }) => - Boolean(resolveSlackAccount({ cfg, accountId }).config.channels), - setPolicy: ({ cfg, accountId, policy }) => - setAccountGroupPolicyForChannel({ - cfg, - channel, - accountId, - groupPolicy: policy, - }), - resolveAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { - let keys = entries; - const accountWithTokens = resolveSlackAccount({ - cfg, - accountId, - }); - const activeBotToken = accountWithTokens.botToken || credentialValues.botToken || ""; - if (activeBotToken && entries.length > 0) { - try { - const resolved = await resolveSlackChannelAllowlist({ - token: activeBotToken, - entries, - }); - const resolvedKeys = resolved - .filter((entry) => entry.resolved && entry.id) - .map((entry) => entry.id as string); - const unresolved = resolved - .filter((entry) => !entry.resolved) - .map((entry) => entry.input); - keys = [...resolvedKeys, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; - await noteChannelLookupSummary({ - prompter, - label: "Slack channels", - resolvedSections: [{ title: "Resolved", values: resolvedKeys }], - unresolved, - }); - } catch (error) { - await noteChannelLookupFailure({ - prompter, - label: "Slack channels", - error, - }); - } + resolveAllowFromEntries: async ({ credentialValues, entries }) => + await resolveSlackAllowFromEntries({ + token: credentialValues.botToken, + entries, + }), + resolveGroupAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => { + let keys = entries; + const accountWithTokens = resolveSlackAccount({ + cfg, + accountId, + }); + const activeBotToken = accountWithTokens.botToken || credentialValues.botToken || ""; + if (activeBotToken && entries.length > 0) { + try { + const resolved = await resolveSlackChannelAllowlist({ + token: activeBotToken, + entries, + }); + const resolvedKeys = resolved + .filter((entry) => entry.resolved && entry.id) + .map((entry) => entry.id as string); + const unresolved = resolved.filter((entry) => !entry.resolved).map((entry) => entry.input); + keys = [...resolvedKeys, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; + await noteChannelLookupSummary({ + prompter, + label: "Slack channels", + resolvedSections: [{ title: "Resolved", values: resolvedKeys }], + unresolved, + }); + } catch (error) { + await noteChannelLookupFailure({ + prompter, + label: "Slack channels", + error, + }); } - return keys; - }, - applyAllowlist: ({ cfg, accountId, resolved }) => - setSlackChannelAllowlist(cfg, accountId, resolved as string[]), + } + return keys; }, - disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), -}; +}); diff --git a/extensions/slack/src/shared.ts b/extensions/slack/src/shared.ts index 7345de3a22c..e7276da9ae1 100644 --- a/extensions/slack/src/shared.ts +++ b/extensions/slack/src/shared.ts @@ -1,3 +1,9 @@ +import { + buildChannelConfigSchema, + getChatChannelMeta, + SlackConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/slack"; import { patchChannelConfigForAccount } from "../../../src/channels/plugins/setup-wizard-helpers.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; @@ -14,6 +20,7 @@ import { resolveSlackAccount, type ResolvedSlackAccount, } from "./accounts.js"; +import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; export const SLACK_CHANNEL = "slack" as const; @@ -150,3 +157,66 @@ export const slackConfigBase = createScopedChannelConfigBase({ defaultAccountId: resolveDefaultSlackAccountId, clearBaseFields: ["botToken", "appToken", "name"], }); + +export function createSlackPluginBase(params: { + setupWizard: NonNullable["setupWizard"]>; + setup: NonNullable["setup"]>; +}): Pick< + ChannelPlugin, + | "id" + | "meta" + | "setupWizard" + | "capabilities" + | "agentPrompt" + | "streaming" + | "reload" + | "configSchema" + | "config" + | "setup" +> { + return { + id: SLACK_CHANNEL, + meta: { + ...getChatChannelMeta(SLACK_CHANNEL), + preferSessionLookupForAnnounceTarget: true, + }, + setupWizard: params.setupWizard, + capabilities: { + chatTypes: ["direct", "channel", "thread"], + reactions: true, + threads: true, + media: true, + nativeCommands: true, + }, + agentPrompt: { + messageToolHints: ({ cfg, accountId }) => + isSlackInteractiveRepliesEnabled({ cfg, accountId }) + ? [ + "- Slack interactive replies: use `[[slack_buttons: Label:value, Other:other]]` to add action buttons that route clicks back as Slack interaction system events.", + "- Slack selects: use `[[slack_select: Placeholder | Label:value, Other:other]]` to add a static select menu that routes the chosen value back as a Slack interaction system event.", + ] + : [ + "- Slack interactive replies are disabled. If needed, ask to set `channels.slack.capabilities.interactiveReplies=true` (or the same under `channels.slack.accounts..capabilities`).", + ], + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.slack"] }, + configSchema: buildChannelConfigSchema(SlackConfigSchema), + config: { + ...slackConfigBase, + isConfigured: (account) => isSlackPluginAccountConfigured(account), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: isSlackPluginAccountConfigured(account), + botTokenSource: account.botTokenSource, + appTokenSource: account.appTokenSource, + }), + ...slackConfigAccessors, + }, + setup: params.setup, + }; +} diff --git a/extensions/synology-chat/src/setup-surface.ts b/extensions/synology-chat/src/setup-surface.ts index d998022365b..7985199eda6 100644 --- a/extensions/synology-chat/src/setup-surface.ts +++ b/extensions/synology-chat/src/setup-surface.ts @@ -1,13 +1,14 @@ import { + DEFAULT_ACCOUNT_ID, + formatDocsLink, mergeAllowFromEntries, + normalizeAccountId, setSetupChannelEnabled, splitSetupEntries, -} from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; + type ChannelSetupAdapter, + type ChannelSetupWizard, + type OpenClawConfig, +} from "openclaw/plugin-sdk/setup"; import { listAccountIds, resolveAccount } from "./accounts.js"; import type { SynologyChatAccountRaw, SynologyChatChannelConfig } from "./types.js"; diff --git a/extensions/synthetic/index.ts b/extensions/synthetic/index.ts index 6e0d6072bf1..ed029dc7cce 100644 --- a/extensions/synthetic/index.ts +++ b/extensions/synthetic/index.ts @@ -1,9 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { - applySyntheticConfig, - SYNTHETIC_DEFAULT_MODEL_REF, -} from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { applySyntheticConfig, SYNTHETIC_DEFAULT_MODEL_REF } from "./onboard.js"; +import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildSyntheticProvider } from "./provider-catalog.js"; const PROVIDER_ID = "synthetic"; @@ -43,18 +41,12 @@ const syntheticPlugin = { ], catalog: { order: "simple", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - return { - provider: { - ...buildSyntheticProvider(), - apiKey, - }, - }; - }, + run: (ctx) => + buildSingleProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProvider: buildSyntheticProvider, + }), }, }); }, diff --git a/extensions/synthetic/onboard.ts b/extensions/synthetic/onboard.ts new file mode 100644 index 00000000000..34199d4db2b --- /dev/null +++ b/extensions/synthetic/onboard.ts @@ -0,0 +1,36 @@ +import { + buildSyntheticModelDefinition, + SYNTHETIC_BASE_URL, + SYNTHETIC_DEFAULT_MODEL_REF, + SYNTHETIC_MODEL_CATALOG, +} from "../../src/agents/synthetic-models.js"; +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithModelCatalog, +} from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; + +export { SYNTHETIC_DEFAULT_MODEL_REF }; + +export function applySyntheticProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[SYNTHETIC_DEFAULT_MODEL_REF] = { + ...models[SYNTHETIC_DEFAULT_MODEL_REF], + alias: models[SYNTHETIC_DEFAULT_MODEL_REF]?.alias ?? "MiniMax M2.5", + }; + + return applyProviderConfigWithModelCatalog(cfg, { + agentModels: models, + providerId: "synthetic", + api: "anthropic-messages", + baseUrl: SYNTHETIC_BASE_URL, + catalogModels: SYNTHETIC_MODEL_CATALOG.map(buildSyntheticModelDefinition), + }); +} + +export function applySyntheticConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applySyntheticProviderConfig(cfg), + SYNTHETIC_DEFAULT_MODEL_REF, + ); +} diff --git a/extensions/talk-voice/index.test.ts b/extensions/talk-voice/index.test.ts new file mode 100644 index 00000000000..2d0a991aa47 --- /dev/null +++ b/extensions/talk-voice/index.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawPluginCommandDefinition } from "../../src/plugins/types.js"; +import { createPluginRuntimeMock } from "../test-utils/plugin-runtime-mock.js"; +import register from "./index.js"; + +function createHarness(config: Record) { + let command: OpenClawPluginCommandDefinition | undefined; + const runtime = createPluginRuntimeMock({ + config: { + loadConfig: vi.fn(() => config), + writeConfigFile: vi.fn().mockResolvedValue(undefined), + }, + tts: { + listVoices: vi.fn(), + }, + }); + const api = { + runtime, + registerCommand: vi.fn((definition: OpenClawPluginCommandDefinition) => { + command = definition; + }), + }; + register(api as never); + if (!command) { + throw new Error("talk-voice command not registered"); + } + return { command, runtime }; +} + +function createCommandContext(args: string, channel: string = "discord") { + return { + args, + channel, + channelId: channel, + isAuthorizedSender: true, + commandBody: args ? `/voice ${args}` : "/voice", + config: {}, + requestConversationBinding: vi.fn(), + detachConversationBinding: vi.fn(), + getCurrentConversationBinding: vi.fn(), + }; +} + +describe("talk-voice plugin", () => { + it("reports active provider status", async () => { + const { command } = createHarness({ + talk: { + provider: "microsoft", + providers: { + microsoft: { + voiceId: "en-US-AvaNeural", + apiKey: "secret-token", + }, + }, + }, + }); + + const result = await command.handler(createCommandContext("")); + + expect(result).toEqual({ + text: + "Talk voice status:\n" + + "- provider: microsoft\n" + + "- talk.voiceId: en-US-AvaNeural\n" + + "- microsoft.apiKey: secret…", + }); + }); + + it("lists voices from the active provider", async () => { + const { command, runtime } = createHarness({ + talk: { + provider: "elevenlabs", + providers: { + elevenlabs: { + apiKey: "sk-eleven", + baseUrl: "https://voices.example.test", + }, + }, + }, + }); + vi.mocked(runtime.tts.listVoices).mockResolvedValue([ + { id: "voice-a", name: "Claudia", category: "general" }, + { id: "voice-b", name: "Bert" }, + ]); + + const result = await command.handler(createCommandContext("list 1")); + + expect(runtime.tts.listVoices).toHaveBeenCalledWith({ + provider: "elevenlabs", + cfg: { + talk: { + provider: "elevenlabs", + providers: { + elevenlabs: { + apiKey: "sk-eleven", + baseUrl: "https://voices.example.test", + }, + }, + }, + }, + apiKey: "sk-eleven", + baseUrl: "https://voices.example.test", + }); + expect(result).toEqual({ + text: + "ElevenLabs voices: 2\n\n" + + "- Claudia · general\n" + + " id: voice-a\n\n" + + "(showing first 1)", + }); + }); + + it("surfaces richer provider voice metadata when available", async () => { + const { command, runtime } = createHarness({ + talk: { + provider: "microsoft", + providers: { + microsoft: {}, + }, + }, + }); + vi.mocked(runtime.tts.listVoices).mockResolvedValue([ + { + id: "en-US-AvaNeural", + name: "Ava", + category: "General", + locale: "en-US", + gender: "Female", + personalities: ["Friendly", "Positive"], + description: "Friendly, Positive", + }, + ]); + + const result = await command.handler(createCommandContext("list")); + + expect(result).toEqual({ + text: + "Microsoft voices: 1\n\n" + + "- Ava · General\n" + + " id: en-US-AvaNeural\n" + + " meta: en-US · Female · Friendly, Positive\n" + + " note: Friendly, Positive", + }); + }); + + it("writes canonical talk provider config and legacy elevenlabs voice id", async () => { + const { command, runtime } = createHarness({ + talk: { + provider: "elevenlabs", + providers: { + elevenlabs: { + apiKey: "sk-eleven", + }, + }, + }, + }); + vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]); + + const result = await command.handler(createCommandContext("set Claudia")); + + expect(runtime.config.writeConfigFile).toHaveBeenCalledWith({ + talk: { + provider: "elevenlabs", + providers: { + elevenlabs: { + apiKey: "sk-eleven", + voiceId: "voice-a", + }, + }, + voiceId: "voice-a", + }, + }); + expect(result).toEqual({ + text: "✅ ElevenLabs Talk voice set to Claudia\nvoice-a", + }); + }); + + it("writes provider voice id without legacy top-level field for microsoft", async () => { + const { command, runtime } = createHarness({ + talk: { + provider: "microsoft", + providers: { + microsoft: {}, + }, + }, + }); + vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "en-US-AvaNeural", name: "Ava" }]); + + await command.handler(createCommandContext("set Ava")); + + expect(runtime.config.writeConfigFile).toHaveBeenCalledWith({ + talk: { + provider: "microsoft", + providers: { + microsoft: { + voiceId: "en-US-AvaNeural", + }, + }, + }, + }); + }); + + it("returns provider lookup errors cleanly", async () => { + const { command, runtime } = createHarness({ + talk: { + provider: "microsoft", + providers: { + microsoft: {}, + }, + }, + }); + vi.mocked(runtime.tts.listVoices).mockRejectedValue( + new Error("speech provider microsoft does not support voice listing"), + ); + + const result = await command.handler(createCommandContext("list")); + + expect(result).toEqual({ + text: "Microsoft voice list failed: speech provider microsoft does not support voice listing", + }); + }); +}); diff --git a/extensions/talk-voice/index.ts b/extensions/talk-voice/index.ts index 3445e91e81f..8f698262e3e 100644 --- a/extensions/talk-voice/index.ts +++ b/extensions/talk-voice/index.ts @@ -1,11 +1,6 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/talk-voice"; - -type ElevenLabsVoice = { - voice_id: string; - name?: string; - category?: string; - description?: string; -}; +import { resolveActiveTalkProviderConfig } from "../../src/config/talk.js"; +import type { SpeechVoiceOption } from "../../src/tts/provider-types.js"; function mask(s: string, keep: number = 6): string { const trimmed = s.trim(); @@ -23,30 +18,48 @@ function isLikelyVoiceId(value: string): boolean { return /^[a-zA-Z0-9_-]+$/.test(v); } -async function listVoices(apiKey: string): Promise { - const res = await fetch("https://api.elevenlabs.io/v1/voices", { - headers: { - "xi-api-key": apiKey, - }, - }); - if (!res.ok) { - throw new Error(`ElevenLabs voices API error (${res.status})`); +function resolveProviderLabel(providerId: string): string { + switch (providerId) { + case "openai": + return "OpenAI"; + case "microsoft": + return "Microsoft"; + case "elevenlabs": + return "ElevenLabs"; + default: + return providerId; } - const json = (await res.json()) as { voices?: ElevenLabsVoice[] }; - return Array.isArray(json.voices) ? json.voices : []; } -function formatVoiceList(voices: ElevenLabsVoice[], limit: number): string { +function formatVoiceMeta(voice: SpeechVoiceOption): string | undefined { + const parts = [voice.locale, voice.gender]; + const personalities = voice.personalities?.filter((value) => value.trim().length > 0) ?? []; + if (personalities.length > 0) { + parts.push(personalities.join(", ")); + } + const filtered = parts.filter((part): part is string => Boolean(part?.trim())); + return filtered.length > 0 ? filtered.join(" · ") : undefined; +} + +function formatVoiceList(voices: SpeechVoiceOption[], limit: number, providerId: string): string { const sliced = voices.slice(0, Math.max(1, Math.min(limit, 50))); const lines: string[] = []; - lines.push(`Voices: ${voices.length}`); + lines.push(`${resolveProviderLabel(providerId)} voices: ${voices.length}`); lines.push(""); for (const v of sliced) { const name = (v.name ?? "").trim() || "(unnamed)"; const category = (v.category ?? "").trim(); const meta = category ? ` · ${category}` : ""; lines.push(`- ${name}${meta}`); - lines.push(` id: ${v.voice_id}`); + lines.push(` id: ${v.id}`); + const details = formatVoiceMeta(v); + if (details) { + lines.push(` meta: ${details}`); + } + const description = (v.description ?? "").trim(); + if (description) { + lines.push(` note: ${description}`); + } } if (voices.length > sliced.length) { lines.push(""); @@ -55,13 +68,13 @@ function formatVoiceList(voices: ElevenLabsVoice[], limit: number): string { return lines.join("\n"); } -function findVoice(voices: ElevenLabsVoice[], query: string): ElevenLabsVoice | null { +function findVoice(voices: SpeechVoiceOption[], query: string): SpeechVoiceOption | null { const q = query.trim(); if (!q) { return null; } const lower = q.toLowerCase(); - const byId = voices.find((v) => v.voice_id === q); + const byId = voices.find((v) => v.id === q); if (byId) { return byId; } @@ -81,13 +94,18 @@ function resolveCommandLabel(channel: string): string { return channel === "discord" ? "/talkvoice" : "/voice"; } +function asProviderBaseUrl(value: unknown): string | undefined { + const trimmed = asTrimmedString(value); + return trimmed || undefined; +} + export default function register(api: OpenClawPluginApi) { api.registerCommand({ name: "voice", nativeNames: { discord: "talkvoice", }, - description: "List/set ElevenLabs Talk voice (affects iOS Talk playback).", + description: "List/set Talk provider voices (affects iOS Talk playback).", acceptsArgs: true, handler: async (ctx) => { const commandLabel = resolveCommandLabel(ctx.channel); @@ -96,31 +114,49 @@ export default function register(api: OpenClawPluginApi) { const action = (tokens[0] ?? "status").toLowerCase(); const cfg = api.runtime.config.loadConfig(); - const apiKey = asTrimmedString(cfg.talk?.apiKey); - if (!apiKey) { + const active = resolveActiveTalkProviderConfig(cfg.talk); + if (!active) { return { text: "Talk voice is not configured.\n\n" + - "Missing: talk.apiKey (ElevenLabs API key).\n" + + "Missing: talk.provider and talk.providers..\n" + "Set it on the gateway, then retry.", }; } + const providerId = active.provider; + const providerLabel = resolveProviderLabel(providerId); + const apiKey = asTrimmedString(active.config.apiKey); + const baseUrl = asProviderBaseUrl(active.config.baseUrl); - const currentVoiceId = (cfg.talk?.voiceId ?? "").trim(); + const currentVoiceId = + asTrimmedString(active.config.voiceId) || asTrimmedString(cfg.talk?.voiceId); if (action === "status") { return { text: "Talk voice status:\n" + + `- provider: ${providerId}\n` + `- talk.voiceId: ${currentVoiceId ? currentVoiceId : "(unset)"}\n` + - `- talk.apiKey: ${mask(apiKey)}`, + `- ${providerId}.apiKey: ${apiKey ? mask(apiKey) : "(unset)"}`, }; } if (action === "list") { const limit = Number.parseInt(tokens[1] ?? "12", 10); - const voices = await listVoices(apiKey); - return { text: formatVoiceList(voices, Number.isFinite(limit) ? limit : 12) }; + try { + const voices = await api.runtime.tts.listVoices({ + provider: providerId, + cfg, + apiKey: apiKey || undefined, + baseUrl, + }); + return { + text: formatVoiceList(voices, Number.isFinite(limit) ? limit : 12, providerId), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { text: `${providerLabel} voice list failed: ${message}` }; + } } if (action === "set") { @@ -128,7 +164,18 @@ export default function register(api: OpenClawPluginApi) { if (!query) { return { text: `Usage: ${commandLabel} set ` }; } - const voices = await listVoices(apiKey); + let voices: SpeechVoiceOption[]; + try { + voices = await api.runtime.tts.listVoices({ + provider: providerId, + cfg, + apiKey: apiKey || undefined, + baseUrl, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { text: `${providerLabel} voice lookup failed: ${message}` }; + } const chosen = findVoice(voices, query); if (!chosen) { const hint = isLikelyVoiceId(query) ? query : `"${query}"`; @@ -139,13 +186,21 @@ export default function register(api: OpenClawPluginApi) { ...cfg, talk: { ...cfg.talk, - voiceId: chosen.voice_id, + provider: providerId, + providers: { + ...(cfg.talk?.providers ?? {}), + [providerId]: { + ...(cfg.talk?.providers?.[providerId] ?? {}), + voiceId: chosen.id, + }, + }, + ...(providerId === "elevenlabs" ? { voiceId: chosen.id } : {}), }, }; await api.runtime.config.writeConfigFile(nextConfig); const name = (chosen.name ?? "").trim() || "(unnamed)"; - return { text: `✅ Talk voice set to ${name}\n${chosen.voice_id}` }; + return { text: `✅ ${providerLabel} Talk voice set to ${name}\n${chosen.id}` }; } return { diff --git a/extensions/telegram/src/account-inspect.ts b/extensions/telegram/src/account-inspect.ts index 6aca9122b43..1e428c237fa 100644 --- a/extensions/telegram/src/account-inspect.ts +++ b/extensions/telegram/src/account-inspect.ts @@ -1,4 +1,3 @@ -import type { TelegramAccountConfig } from "openclaw/plugin-sdk/telegram"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { coerceSecretRef, @@ -6,7 +5,8 @@ import { normalizeSecretInputString, } from "../../../src/config/types.secrets.js"; import { tryReadSecretFileSync } from "../../../src/infra/secret-file.js"; -import { resolveAccountWithDefaultFallback } from "../../../src/plugin-sdk/account-resolution.js"; +import { resolveAccountWithDefaultFallback } from "../../../src/plugin-sdk-internal/accounts.js"; +import type { TelegramAccountConfig } from "../../../src/plugin-sdk-internal/telegram.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; import { resolveDefaultSecretProviderAlias } from "../../../src/secrets/ref-contract.js"; import { diff --git a/extensions/telegram/src/accounts.ts b/extensions/telegram/src/accounts.ts index ab94be5845c..6d2255e00a1 100644 --- a/extensions/telegram/src/accounts.ts +++ b/extensions/telegram/src/accounts.ts @@ -1,5 +1,4 @@ import util from "node:util"; -import type { TelegramAccountConfig, TelegramActionConfig } from "openclaw/plugin-sdk/telegram"; import { createAccountActionGate } from "../../../src/channels/plugins/account-action-gate.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { isTruthyEnvValue } from "../../../src/infra/env.js"; @@ -7,7 +6,11 @@ import { createSubsystemLogger } from "../../../src/logging/subsystem.js"; import { listConfiguredAccountIds as listConfiguredAccountIdsFromSection, resolveAccountWithDefaultFallback, -} from "../../../src/plugin-sdk/account-resolution.js"; +} from "../../../src/plugin-sdk-internal/accounts.js"; +import type { + TelegramAccountConfig, + TelegramActionConfig, +} from "../../../src/plugin-sdk-internal/telegram.js"; import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; import { listBoundAccountIds, diff --git a/extensions/telegram/src/bot-native-commands.registry.test.ts b/extensions/telegram/src/bot-native-commands.registry.test.ts index d264a059505..a6fb431c349 100644 --- a/extensions/telegram/src/bot-native-commands.registry.test.ts +++ b/extensions/telegram/src/bot-native-commands.registry.test.ts @@ -147,6 +147,54 @@ describe("registerTelegramNativeCommands real plugin registry", () => { expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found."); }); + it("round-trips Telegram native aliases through the real plugin registry", async () => { + const { bot, commandHandlers, sendMessage, setMyCommands } = createCommandBot(); + + expect( + registerPluginCommand("demo-plugin", { + name: "pair", + nativeNames: { + telegram: "pair_device", + discord: "pairdiscord", + }, + description: "Pair device", + acceptsArgs: true, + requireAuth: false, + handler: async ({ args }) => ({ text: `paired:${args ?? ""}` }), + }), + ).toEqual({ ok: true }); + + registerTelegramNativeCommands({ + ...buildParams({}), + bot, + }); + + const registeredCommands = await waitForRegisteredCommands(setMyCommands); + expect(registeredCommands).toEqual( + expect.arrayContaining([{ command: "pair_device", description: "Pair device" }]), + ); + + const handler = commandHandlers.get("pair_device"); + expect(handler).toBeTruthy(); + + await handler?.({ + match: "now", + message: { + message_id: 2, + date: Math.floor(Date.now() / 1000), + chat: { id: 123, type: "private" }, + from: { id: 456, username: "alice" }, + }, + }); + + expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + replies: [expect.objectContaining({ text: "paired:now" })], + }), + ); + expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found."); + }); + it("keeps real plugin command handlers available when native menu registration is disabled", () => { const { bot, commandHandlers, setMyCommands } = createCommandBot(); diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index 84548374f05..c9ae46ca823 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -15,8 +15,7 @@ import type { ChannelMessageActionName, } from "../../../src/channels/plugins/types.js"; import type { TelegramActionConfig } from "../../../src/config/types.telegram.js"; -import { readBooleanParam } from "../../../src/plugin-sdk/boolean-param.js"; -import { extractToolSend } from "../../../src/plugin-sdk/tool-send.js"; +import { extractToolSend, readBooleanParam } from "../../../src/plugin-sdk-internal/telegram.js"; import { resolveTelegramPollVisibility } from "../../../src/poll-params.js"; import { createTelegramActionGate, diff --git a/extensions/telegram/src/channel.setup.ts b/extensions/telegram/src/channel.setup.ts index 8cc6b39fc19..0ed71ae568c 100644 --- a/extensions/telegram/src/channel.setup.ts +++ b/extensions/telegram/src/channel.setup.ts @@ -1,127 +1,12 @@ -import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; -import { - createScopedAccountConfigAccessors, - formatAllowFromLowercase, -} from "openclaw/plugin-sdk/compat"; -import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; -import { - buildChannelConfigSchema, - getChatChannelMeta, - normalizeAccountId, - TelegramConfigSchema, - type OpenClawConfig, -} from "openclaw/plugin-sdk/telegram"; -import { inspectTelegramAccount } from "./account-inspect.js"; -import { - listTelegramAccountIds, - resolveDefaultTelegramAccountId, - resolveTelegramAccount, - type ResolvedTelegramAccount, -} from "./accounts.js"; +import { type ChannelPlugin } from "openclaw/plugin-sdk/telegram"; +import { type ResolvedTelegramAccount } from "./accounts.js"; import type { TelegramProbe } from "./probe.js"; import { telegramSetupAdapter } from "./setup-core.js"; import { telegramSetupWizard } from "./setup-surface.js"; +import { createTelegramPluginBase } from "./shared.js"; -function findTelegramTokenOwnerAccountId(params: { - cfg: OpenClawConfig; - accountId: string; -}): string | null { - const normalizedAccountId = normalizeAccountId(params.accountId); - const tokenOwners = new Map(); - for (const id of listTelegramAccountIds(params.cfg)) { - const account = inspectTelegramAccount({ cfg: params.cfg, accountId: id }); - const token = (account.token ?? "").trim(); - if (!token) { - continue; - } - const ownerAccountId = tokenOwners.get(token); - if (!ownerAccountId) { - tokenOwners.set(token, account.accountId); - continue; - } - if (account.accountId === normalizedAccountId) { - return ownerAccountId; - } - } - return null; -} - -function formatDuplicateTelegramTokenReason(params: { - accountId: string; - ownerAccountId: string; -}): string { - return ( - `Duplicate Telegram bot token: account "${params.accountId}" shares a token with ` + - `account "${params.ownerAccountId}". Keep one owner account per bot token.` - ); -} - -const telegramConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }), - resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom, - formatAllowFrom: (allowFrom) => - formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(telegram|tg):/i }), - resolveDefaultTo: (account: ResolvedTelegramAccount) => account.config.defaultTo, -}); - -const telegramConfigBase = createScopedChannelConfigBase({ - sectionKey: "telegram", - listAccountIds: listTelegramAccountIds, - resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }), - inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }), - defaultAccountId: resolveDefaultTelegramAccountId, - clearBaseFields: ["botToken", "tokenFile", "name"], -}); - -export const telegramSetupPlugin: ChannelPlugin = { - id: "telegram", - meta: { - ...getChatChannelMeta("telegram"), - quickstartAllowFrom: true, - }, - setupWizard: telegramSetupWizard, - capabilities: { - chatTypes: ["direct", "group", "channel", "thread"], - reactions: true, - threads: true, - media: true, - polls: true, - nativeCommands: true, - blockStreaming: true, - }, - reload: { configPrefixes: ["channels.telegram"] }, - configSchema: buildChannelConfigSchema(TelegramConfigSchema), - config: { - ...telegramConfigBase, - isConfigured: (account, cfg) => { - if (!account.token?.trim()) { - return false; - } - return !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); - }, - unconfiguredReason: (account, cfg) => { - if (!account.token?.trim()) { - return "not configured"; - } - const ownerAccountId = findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); - if (!ownerAccountId) { - return "not configured"; - } - return formatDuplicateTelegramTokenReason({ - accountId: account.accountId, - ownerAccountId, - }); - }, - describeAccount: (account, cfg) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: - Boolean(account.token?.trim()) && - !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }), - tokenSource: account.tokenSource, - }), - ...telegramConfigAccessors, - }, - setup: telegramSetupAdapter, -}; +export const telegramSetupPlugin: ChannelPlugin = + createTelegramPluginBase({ + setupWizard: telegramSetupWizard, + setup: telegramSetupAdapter, + }); diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 6fcc12552c8..797b60c85d8 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -1,34 +1,27 @@ -import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; import { buildAccountScopedAllowlistConfigEditor, collectAllowlistProviderGroupPolicyWarnings, collectOpenGroupPolicyRouteAllowlistWarnings, - createScopedAccountConfigAccessors, createScopedDmSecurityResolver, - formatAllowFromLowercase, } from "openclaw/plugin-sdk/compat"; import { buildAgentSessionKey, resolveThreadSessionKeys, type RoutePeer, } from "openclaw/plugin-sdk/core"; -import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; import { - buildChannelConfigSchema, buildTokenChannelStatusSummary, clearAccountEntryFields, DEFAULT_ACCOUNT_ID, - getChatChannelMeta, listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig, - normalizeAccountId, PAIRING_APPROVED_MESSAGE, projectCredentialSnapshotFields, resolveConfiguredFromCredentialStatuses, resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, - TelegramConfigSchema, type ChannelMessageActionAdapter, + type ChannelPlugin, type OpenClawConfig, } from "openclaw/plugin-sdk/telegram"; import { parseTelegramTopicConversation } from "../../../src/acp/conversation-id.js"; @@ -38,6 +31,7 @@ import { type OutboundSendDeps, resolveOutboundSendDep, } from "../../../src/infra/outbound/send-deps.js"; +import { normalizeOutboundThreadId } from "../../../src/infra/outbound/thread-id.js"; import { normalizeMessageChannel } from "../../../src/utils/message-channel.js"; import { inspectTelegramAccount } from "./account-inspect.js"; import { @@ -62,6 +56,12 @@ import { getTelegramRuntime } from "./runtime.js"; import { sendTypingTelegram } from "./send.js"; import { telegramSetupAdapter } from "./setup-core.js"; import { telegramSetupWizard } from "./setup-surface.js"; +import { + createTelegramPluginBase, + findTelegramTokenOwnerAccountId, + formatDuplicateTelegramTokenReason, + telegramConfigAccessors, +} from "./shared.js"; import { collectTelegramStatusIssues } from "./status-issues.js"; import { parseTelegramTarget } from "./targets.js"; @@ -69,42 +69,6 @@ type TelegramSendFn = ReturnType< typeof getTelegramRuntime >["channel"]["telegram"]["sendMessageTelegram"]; -const meta = getChatChannelMeta("telegram"); - -function findTelegramTokenOwnerAccountId(params: { - cfg: OpenClawConfig; - accountId: string; -}): string | null { - const normalizedAccountId = normalizeAccountId(params.accountId); - const tokenOwners = new Map(); - for (const id of listTelegramAccountIds(params.cfg)) { - const account = inspectTelegramAccount({ cfg: params.cfg, accountId: id }); - const token = (account.token ?? "").trim(); - if (!token) { - continue; - } - const ownerAccountId = tokenOwners.get(token); - if (!ownerAccountId) { - tokenOwners.set(token, account.accountId); - continue; - } - if (account.accountId === normalizedAccountId) { - return ownerAccountId; - } - } - return null; -} - -function formatDuplicateTelegramTokenReason(params: { - accountId: string; - ownerAccountId: string; -}): string { - return ( - `Duplicate Telegram bot token: account "${params.accountId}" shares a token with ` + - `account "${params.ownerAccountId}". Keep one owner account per bot token.` - ); -} - type TelegramSendOptions = NonNullable[2]>; function buildTelegramSendOptions(params: { @@ -222,20 +186,6 @@ function parseTelegramExplicitTarget(raw: string) { }; } -function normalizeOutboundThreadId(value?: string | number | null): string | undefined { - if (value == null) { - return undefined; - } - if (typeof value === "number") { - if (!Number.isFinite(value)) { - return undefined; - } - return String(Math.trunc(value)); - } - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; -} - function buildTelegramBaseSessionKey(params: { cfg: OpenClawConfig; agentId: string; @@ -329,23 +279,6 @@ const telegramMessageActions: ChannelMessageActionAdapter = { }, }; -const telegramConfigAccessors = createScopedAccountConfigAccessors({ - resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }), - resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom, - formatAllowFrom: (allowFrom) => - formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(telegram|tg):/i }), - resolveDefaultTo: (account: ResolvedTelegramAccount) => account.config.defaultTo, -}); - -const telegramConfigBase = createScopedChannelConfigBase({ - sectionKey: "telegram", - listAccountIds: listTelegramAccountIds, - resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }), - inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }), - defaultAccountId: resolveDefaultTelegramAccountId, - clearBaseFields: ["botToken", "tokenFile", "name"], -}); - const resolveTelegramDmPolicy = createScopedDmSecurityResolver({ channelKey: "telegram", resolvePolicy: (account) => account.config.dmPolicy, @@ -378,12 +311,10 @@ function readTelegramAllowlistConfig(account: ResolvedTelegramAccount) { } export const telegramPlugin: ChannelPlugin = { - id: "telegram", - meta: { - ...meta, - quickstartAllowFrom: true, - }, - setupWizard: telegramSetupWizard, + ...createTelegramPluginBase({ + setupWizard: telegramSetupWizard, + setup: telegramSetupAdapter, + }), pairing: { idLabel: "telegramUserId", normalizeAllowEntry: (entry) => entry.replace(/^(telegram|tg):/i, ""), @@ -401,49 +332,6 @@ export const telegramPlugin: ChannelPlugin { - if (!account.token?.trim()) { - return false; - } - return !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); - }, - unconfiguredReason: (account, cfg) => { - if (!account.token?.trim()) { - return "not configured"; - } - const ownerAccountId = findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); - if (!ownerAccountId) { - return "not configured"; - } - return formatDuplicateTelegramTokenReason({ - accountId: account.accountId, - ownerAccountId, - }); - }, - describeAccount: (account, cfg) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: - Boolean(account.token?.trim()) && - !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }), - tokenSource: account.tokenSource, - }), - ...telegramConfigAccessors, - }, allowlist: { supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", readConfig: ({ cfg, accountId }) => @@ -599,7 +487,6 @@ export const telegramPlugin: ChannelPlugin listTelegramDirectoryGroupsFromConfig(params), }, actions: telegramMessageActions, - setup: telegramSetupAdapter, outbound: { deliveryMode: "direct", chunker: (text, limit) => getTelegramRuntime().channel.text.chunkMarkdownText(text, limit), diff --git a/extensions/telegram/src/group-access.ts b/extensions/telegram/src/group-access.ts index b5c30979dbb..e42646a7dcd 100644 --- a/extensions/telegram/src/group-access.ts +++ b/extensions/telegram/src/group-access.ts @@ -7,7 +7,7 @@ import type { TelegramGroupConfig, TelegramTopicConfig, } from "../../../src/config/types.js"; -import { evaluateMatchedGroupAccessForPolicy } from "../../../src/plugin-sdk/group-access.js"; +import { evaluateMatchedGroupAccessForPolicy } from "../../../src/plugin-sdk-internal/telegram.js"; import { isSenderAllowed, type NormalizedAllowFrom } from "./bot-access.js"; import { firstDefined } from "./bot-access.js"; diff --git a/extensions/telegram/src/plugin-shared.ts b/extensions/telegram/src/plugin-shared.ts new file mode 100644 index 00000000000..4d33a6ed6f8 --- /dev/null +++ b/extensions/telegram/src/plugin-shared.ts @@ -0,0 +1,68 @@ +import { + createScopedAccountConfigAccessors, + createScopedChannelConfigBase, + formatAllowFromLowercase, +} from "../../../src/plugin-sdk-internal/channel-config.js"; +import { + normalizeAccountId, + type OpenClawConfig, +} from "../../../src/plugin-sdk-internal/telegram.js"; +import { inspectTelegramAccount } from "./account-inspect.js"; +import { + listTelegramAccountIds, + resolveDefaultTelegramAccountId, + resolveTelegramAccount, + type ResolvedTelegramAccount, +} from "./accounts.js"; + +export function findTelegramTokenOwnerAccountId(params: { + cfg: OpenClawConfig; + accountId: string; +}): string | null { + const normalizedAccountId = normalizeAccountId(params.accountId); + const tokenOwners = new Map(); + for (const id of listTelegramAccountIds(params.cfg)) { + const account = inspectTelegramAccount({ cfg: params.cfg, accountId: id }); + const token = (account.token ?? "").trim(); + if (!token) { + continue; + } + const ownerAccountId = tokenOwners.get(token); + if (!ownerAccountId) { + tokenOwners.set(token, account.accountId); + continue; + } + if (account.accountId === normalizedAccountId) { + return ownerAccountId; + } + } + return null; +} + +export function formatDuplicateTelegramTokenReason(params: { + accountId: string; + ownerAccountId: string; +}): string { + return ( + `Duplicate Telegram bot token: account "${params.accountId}" shares a token with ` + + `account "${params.ownerAccountId}". Keep one owner account per bot token.` + ); +} + +export const telegramConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => + resolveTelegramAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(telegram|tg):/i }), + resolveDefaultTo: (account: ResolvedTelegramAccount) => account.config.defaultTo, +}); + +export const telegramConfigBase = createScopedChannelConfigBase({ + sectionKey: "telegram", + listAccountIds: listTelegramAccountIds, + resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultTelegramAccountId, + clearBaseFields: ["botToken", "tokenFile", "name"], +}); diff --git a/extensions/telegram/src/probe.ts b/extensions/telegram/src/probe.ts index dfa7707f144..cade90c5ad5 100644 --- a/extensions/telegram/src/probe.ts +++ b/extensions/telegram/src/probe.ts @@ -1,5 +1,5 @@ -import type { TelegramNetworkConfig } from "openclaw/plugin-sdk/telegram"; import type { BaseProbeResult } from "../../../src/channels/plugins/types.js"; +import type { TelegramNetworkConfig } from "../../../src/plugin-sdk-internal/telegram.js"; import { fetchWithTimeout } from "../../../src/utils/fetch-timeout.js"; import { resolveTelegramFetch } from "./fetch.js"; import { makeProxyFetch } from "./proxy.js"; diff --git a/extensions/telegram/src/runtime.ts b/extensions/telegram/src/runtime.ts index d4e15f463d9..768c15e28f5 100644 --- a/extensions/telegram/src/runtime.ts +++ b/extensions/telegram/src/runtime.ts @@ -1,5 +1,7 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; -import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import { + createPluginRuntimeStore, + type PluginRuntime, +} from "../../../src/plugin-sdk-internal/core.js"; const { setRuntime: setTelegramRuntime, getRuntime: getTelegramRuntime } = createPluginRuntimeStore("Telegram runtime not initialized"); diff --git a/extensions/telegram/src/setup-core.ts b/extensions/telegram/src/setup-core.ts index 6ef275ee8b2..33ce824d17d 100644 --- a/extensions/telegram/src/setup-core.ts +++ b/extensions/telegram/src/setup-core.ts @@ -1,12 +1,11 @@ +import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; import { - applyAccountNameToChannelSection, DEFAULT_ACCOUNT_ID, formatCliCommand, - formatDocsLink, - migrateBaseNameToDefaultAccount, - normalizeAccountId, patchChannelConfigForAccount, promptResolvedAllowFrom, + setSetupChannelEnabled, + setChannelDmPolicyWithAllowFrom, splitSetupEntries, type OpenClawConfig, type WizardPrompter, @@ -14,8 +13,15 @@ import { import type { ChannelSetupAdapter, ChannelSetupDmPolicy, + ChannelSetupWizard, } from "../../../src/plugin-sdk-internal/setup.js"; -import { resolveDefaultTelegramAccountId, resolveTelegramAccount } from "./accounts.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { inspectTelegramAccount } from "./account-inspect.js"; +import { + listTelegramAccountIds, + resolveDefaultTelegramAccountId, + resolveTelegramAccount, +} from "./accounts.js"; import { fetchTelegramChatId } from "./api-fetch.js"; const channel = "telegram" as const; @@ -112,15 +118,93 @@ export async function promptTelegramAllowFromForAccount(params: { }); } -export const telegramSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), +type TelegramSetupWizardHandlers = { + inspectToken: (params: { cfg: OpenClawConfig; accountId: string }) => { + accountConfigured: boolean; + hasConfiguredValue: boolean; + resolvedValue?: string; + envValue?: string; + }; +}; + +export function createTelegramSetupWizardBase( + handlers: TelegramSetupWizardHandlers, +): ChannelSetupWizard { + const dmPolicy: ChannelSetupDmPolicy = { + label: "Telegram", + channel, + policyKey: "channels.telegram.dmPolicy", + allowFromKey: "channels.telegram.allowFrom", + getCurrent: (cfg) => cfg.channels?.telegram?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => + setChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy: policy, + }), + promptAllowFrom: promptTelegramAllowFromForAccount, + }; + + return { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs token", + configuredHint: "recommended · configured", + unconfiguredHint: "recommended · newcomer-friendly", + configuredScore: 1, + unconfiguredScore: 10, + resolveConfigured: ({ cfg }) => + listTelegramAccountIds(cfg).some((accountId) => { + const account = inspectTelegramAccount({ cfg, accountId }); + return account.configured; + }), + }, + credentials: [ + { + inputKey: "token", + providerHint: channel, + credentialLabel: "Telegram bot token", + preferredEnvVar: "TELEGRAM_BOT_TOKEN", + helpTitle: "Telegram bot token", + helpLines: TELEGRAM_TOKEN_HELP_LINES, + envPrompt: "TELEGRAM_BOT_TOKEN detected. Use env var?", + keepPrompt: "Telegram token already configured. Keep it?", + inputPrompt: "Enter Telegram bot token", + allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }) => handlers.inspectToken({ cfg, accountId }), + }, + ], + allowFrom: { + helpTitle: "Telegram user id", + helpLines: TELEGRAM_USER_ID_HELP_LINES, + credentialInputKey: "token", + message: "Telegram allowFrom (numeric sender id; @username resolves to id)", + placeholder: "@username", + invalidWithoutCredentialNote: + "Telegram token missing; use numeric sender ids (usernames require a bot token).", + parseInputs: splitSetupEntries, + parseId: parseTelegramAllowFromId, + resolveEntries: async ({ credentialValues, entries }) => + resolveTelegramAllowFromEntries({ + credentialValue: credentialValues.token, + entries, + }), + apply: async ({ cfg, accountId, allowFrom }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { dmPolicy: "allowlist", allowFrom }, + }), + }, + dmPolicy, + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), + } satisfies ChannelSetupWizard; +} + +export const telegramSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ + channelKey: channel, validateInput: ({ accountId, input }) => { if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { return "TELEGRAM_BOT_TOKEN can only be used for the default account."; @@ -130,60 +214,12 @@ export const telegramSetupAdapter: ChannelSetupAdapter = { } return null; }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - telegram: { - ...next.channels?.telegram, - enabled: true, - ...(input.useEnv - ? {} - : input.tokenFile - ? { tokenFile: input.tokenFile } - : input.token - ? { botToken: input.token } - : {}), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - telegram: { - ...next.channels?.telegram, - enabled: true, - accounts: { - ...next.channels?.telegram?.accounts, - [accountId]: { - ...next.channels?.telegram?.accounts?.[accountId], - enabled: true, - ...(input.tokenFile - ? { tokenFile: input.tokenFile } - : input.token - ? { botToken: input.token } - : {}), - }, - }, - }, - }, - }; - }, -}; + buildPatch: (input) => + input.useEnv + ? {} + : input.tokenFile + ? { tokenFile: input.tokenFile } + : input.token + ? { botToken: input.token } + : {}, +}); diff --git a/extensions/telegram/src/setup-surface.ts b/extensions/telegram/src/setup-surface.ts index 7d95f40728b..4417fc1764a 100644 --- a/extensions/telegram/src/setup-surface.ts +++ b/extensions/telegram/src/setup-surface.ts @@ -1,113 +1,30 @@ import { DEFAULT_ACCOUNT_ID, hasConfiguredSecretInput, - type OpenClawConfig, - patchChannelConfigForAccount, - setChannelDmPolicyWithAllowFrom, - setSetupChannelEnabled, - splitSetupEntries, } from "../../../src/plugin-sdk-internal/setup.js"; -import type { - ChannelSetupDmPolicy, - ChannelSetupWizard, -} from "../../../src/plugin-sdk-internal/setup.js"; -import { inspectTelegramAccount } from "./account-inspect.js"; -import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js"; +import type { ChannelSetupWizard } from "../../../src/plugin-sdk-internal/setup.js"; +import { resolveTelegramAccount } from "./accounts.js"; import { + createTelegramSetupWizardBase, parseTelegramAllowFromId, - promptTelegramAllowFromForAccount, - resolveTelegramAllowFromEntries, - TELEGRAM_TOKEN_HELP_LINES, - TELEGRAM_USER_ID_HELP_LINES, telegramSetupAdapter, } from "./setup-core.js"; -const channel = "telegram" as const; - -const dmPolicy: ChannelSetupDmPolicy = { - label: "Telegram", - channel, - policyKey: "channels.telegram.dmPolicy", - allowFromKey: "channels.telegram.allowFrom", - getCurrent: (cfg) => cfg.channels?.telegram?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => - setChannelDmPolicyWithAllowFrom({ - cfg, - channel, - dmPolicy: policy, - }), - promptAllowFrom: promptTelegramAllowFromForAccount, -}; - -export const telegramSetupWizard: ChannelSetupWizard = { - channel, - status: { - configuredLabel: "configured", - unconfiguredLabel: "needs token", - configuredHint: "recommended · configured", - unconfiguredHint: "recommended · newcomer-friendly", - configuredScore: 1, - unconfiguredScore: 10, - resolveConfigured: ({ cfg }) => - listTelegramAccountIds(cfg).some((accountId) => { - const account = inspectTelegramAccount({ cfg, accountId }); - return account.configured; - }), +export const telegramSetupWizard: ChannelSetupWizard = createTelegramSetupWizardBase({ + inspectToken: ({ cfg, accountId }) => { + const resolved = resolveTelegramAccount({ cfg, accountId }); + const hasConfiguredBotToken = hasConfiguredSecretInput(resolved.config.botToken); + const hasConfiguredValue = hasConfiguredBotToken || Boolean(resolved.config.tokenFile?.trim()); + return { + accountConfigured: Boolean(resolved.token) || hasConfiguredValue, + hasConfiguredValue, + resolvedValue: resolved.token?.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env.TELEGRAM_BOT_TOKEN?.trim() || undefined + : undefined, + }; }, - credentials: [ - { - inputKey: "token", - providerHint: channel, - credentialLabel: "Telegram bot token", - preferredEnvVar: "TELEGRAM_BOT_TOKEN", - helpTitle: "Telegram bot token", - helpLines: TELEGRAM_TOKEN_HELP_LINES, - envPrompt: "TELEGRAM_BOT_TOKEN detected. Use env var?", - keepPrompt: "Telegram token already configured. Keep it?", - inputPrompt: "Enter Telegram bot token", - allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, - inspect: ({ cfg, accountId }) => { - const resolved = resolveTelegramAccount({ cfg, accountId }); - const hasConfiguredBotToken = hasConfiguredSecretInput(resolved.config.botToken); - const hasConfiguredValue = - hasConfiguredBotToken || Boolean(resolved.config.tokenFile?.trim()); - return { - accountConfigured: Boolean(resolved.token) || hasConfiguredValue, - hasConfiguredValue, - resolvedValue: resolved.token?.trim() || undefined, - envValue: - accountId === DEFAULT_ACCOUNT_ID - ? process.env.TELEGRAM_BOT_TOKEN?.trim() || undefined - : undefined, - }; - }, - }, - ], - allowFrom: { - helpTitle: "Telegram user id", - helpLines: TELEGRAM_USER_ID_HELP_LINES, - credentialInputKey: "token", - message: "Telegram allowFrom (numeric sender id; @username resolves to id)", - placeholder: "@username", - invalidWithoutCredentialNote: - "Telegram token missing; use numeric sender ids (usernames require a bot token).", - parseInputs: splitSetupEntries, - parseId: parseTelegramAllowFromId, - resolveEntries: async ({ credentialValues, entries }) => - resolveTelegramAllowFromEntries({ - credentialValue: credentialValues.token, - entries, - }), - apply: async ({ cfg, accountId, allowFrom }) => - patchChannelConfigForAccount({ - cfg, - channel, - accountId, - patch: { dmPolicy: "allowlist", allowFrom }, - }), - }, - dmPolicy, - disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), -}; +}); export { parseTelegramAllowFromId, telegramSetupAdapter }; diff --git a/extensions/telegram/src/shared.ts b/extensions/telegram/src/shared.ts new file mode 100644 index 00000000000..a1c7945520d --- /dev/null +++ b/extensions/telegram/src/shared.ts @@ -0,0 +1,137 @@ +import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat"; +import { + createScopedAccountConfigAccessors, + formatAllowFromLowercase, +} from "openclaw/plugin-sdk/compat"; +import { + buildChannelConfigSchema, + getChatChannelMeta, + normalizeAccountId, + TelegramConfigSchema, + type ChannelPlugin, + type OpenClawConfig, +} from "openclaw/plugin-sdk/telegram"; +import { inspectTelegramAccount } from "./account-inspect.js"; +import { + listTelegramAccountIds, + resolveDefaultTelegramAccountId, + resolveTelegramAccount, + type ResolvedTelegramAccount, +} from "./accounts.js"; + +export const TELEGRAM_CHANNEL = "telegram" as const; + +export function findTelegramTokenOwnerAccountId(params: { + cfg: OpenClawConfig; + accountId: string; +}): string | null { + const normalizedAccountId = normalizeAccountId(params.accountId); + const tokenOwners = new Map(); + for (const id of listTelegramAccountIds(params.cfg)) { + const account = inspectTelegramAccount({ cfg: params.cfg, accountId: id }); + const token = (account.token ?? "").trim(); + if (!token) { + continue; + } + const ownerAccountId = tokenOwners.get(token); + if (!ownerAccountId) { + tokenOwners.set(token, account.accountId); + continue; + } + if (account.accountId === normalizedAccountId) { + return ownerAccountId; + } + } + return null; +} + +export function formatDuplicateTelegramTokenReason(params: { + accountId: string; + ownerAccountId: string; +}): string { + return ( + `Duplicate Telegram bot token: account "${params.accountId}" shares a token with ` + + `account "${params.ownerAccountId}". Keep one owner account per bot token.` + ); +} + +export const telegramConfigAccessors = createScopedAccountConfigAccessors({ + resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }), + resolveAllowFrom: (account: ResolvedTelegramAccount) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(telegram|tg):/i }), + resolveDefaultTo: (account: ResolvedTelegramAccount) => account.config.defaultTo, +}); + +export const telegramConfigBase = createScopedChannelConfigBase({ + sectionKey: TELEGRAM_CHANNEL, + listAccountIds: listTelegramAccountIds, + resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }), + inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }), + defaultAccountId: resolveDefaultTelegramAccountId, + clearBaseFields: ["botToken", "tokenFile", "name"], +}); + +export function createTelegramPluginBase(params: { + setupWizard: NonNullable["setupWizard"]>; + setup: NonNullable["setup"]>; +}): Pick< + ChannelPlugin, + "id" | "meta" | "setupWizard" | "capabilities" | "reload" | "configSchema" | "config" | "setup" +> { + return { + id: TELEGRAM_CHANNEL, + meta: { + ...getChatChannelMeta(TELEGRAM_CHANNEL), + quickstartAllowFrom: true, + }, + setupWizard: params.setupWizard, + capabilities: { + chatTypes: ["direct", "group", "channel", "thread"], + reactions: true, + threads: true, + media: true, + polls: true, + nativeCommands: true, + blockStreaming: true, + }, + reload: { configPrefixes: ["channels.telegram"] }, + configSchema: buildChannelConfigSchema(TelegramConfigSchema), + config: { + ...telegramConfigBase, + isConfigured: (account, cfg) => { + if (!account.token?.trim()) { + return false; + } + return !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }); + }, + unconfiguredReason: (account, cfg) => { + if (!account.token?.trim()) { + return "not configured"; + } + const ownerAccountId = findTelegramTokenOwnerAccountId({ + cfg, + accountId: account.accountId, + }); + if (!ownerAccountId) { + return "not configured"; + } + return formatDuplicateTelegramTokenReason({ + accountId: account.accountId, + ownerAccountId, + }); + }, + describeAccount: (account, cfg) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: + Boolean(account.token?.trim()) && + !findTelegramTokenOwnerAccountId({ cfg, accountId: account.accountId }), + tokenSource: account.tokenSource, + }), + ...telegramConfigAccessors, + }, + setup: params.setup, + }; +} diff --git a/extensions/telegram/src/token.ts b/extensions/telegram/src/token.ts index e0009d6b76a..d26d9657ca1 100644 --- a/extensions/telegram/src/token.ts +++ b/extensions/telegram/src/token.ts @@ -1,8 +1,8 @@ -import type { TelegramAccountConfig } from "openclaw/plugin-sdk/telegram"; import type { BaseTokenResolution } from "../../../src/channels/plugins/types.core.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { normalizeResolvedSecretInputString } from "../../../src/config/types.secrets.js"; import { tryReadSecretFileSync } from "../../../src/infra/secret-file.js"; +import type { TelegramAccountConfig } from "../../../src/plugin-sdk-internal/telegram.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; export type TelegramTokenSource = "env" | "tokenFile" | "config" | "none"; diff --git a/extensions/test-utils/plugin-api.ts b/extensions/test-utils/plugin-api.ts index 281e151aeb7..82fe818fdec 100644 --- a/extensions/test-utils/plugin-api.ts +++ b/extensions/test-utils/plugin-api.ts @@ -16,6 +16,7 @@ export function createTestPluginApi(api: TestPluginApiInput): OpenClawPluginApi registerService() {}, registerProvider() {}, registerSpeechProvider() {}, + registerMediaUnderstandingProvider() {}, registerWebSearchProvider() {}, registerInteractiveHandler() {}, registerCommand() {}, diff --git a/extensions/test-utils/plugin-runtime-mock.ts b/extensions/test-utils/plugin-runtime-mock.ts index 19a17e0811a..b7ca386028b 100644 --- a/extensions/test-utils/plugin-runtime-mock.ts +++ b/extensions/test-utils/plugin-runtime-mock.ts @@ -102,7 +102,18 @@ export function createPluginRuntimeMock(overrides: DeepPartial = 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"], + describeVideoFile: + vi.fn() as unknown as PluginRuntime["mediaUnderstanding"]["describeVideoFile"], + transcribeAudioFile: + vi.fn() as unknown as PluginRuntime["mediaUnderstanding"]["transcribeAudioFile"], }, stt: { transcribeAudioFile: vi.fn() as unknown as PluginRuntime["stt"]["transcribeAudioFile"], diff --git a/extensions/tlon/src/setup-core.ts b/extensions/tlon/src/setup-core.ts index a237a813edf..08d72f2ab28 100644 --- a/extensions/tlon/src/setup-core.ts +++ b/extensions/tlon/src/setup-core.ts @@ -1,6 +1,6 @@ import { - applyAccountNameToChannelSection, patchScopedAccountConfig, + prepareScopedSetupConfig, } from "../../../src/channels/plugins/setup-helpers.js"; import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; @@ -29,7 +29,7 @@ export function applyTlonSetupConfig(params: { }): OpenClawConfig { const { cfg, accountId, input } = params; const useDefault = accountId === DEFAULT_ACCOUNT_ID; - const namedConfig = applyAccountNameToChannelSection({ + const namedConfig = prepareScopedSetupConfig({ cfg, channelKey: channel, accountId, @@ -69,7 +69,7 @@ export function applyTlonSetupConfig(params: { export const tlonSetupAdapter: ChannelSetupAdapter = { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ + prepareScopedSetupConfig({ cfg, channelKey: channel, accountId, diff --git a/extensions/tlon/src/setup-surface.ts b/extensions/tlon/src/setup-surface.ts index ec6258277bd..e3c1b43f0c1 100644 --- a/extensions/tlon/src/setup-surface.ts +++ b/extensions/tlon/src/setup-surface.ts @@ -1,6 +1,8 @@ -import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; +import { + DEFAULT_ACCOUNT_ID, + formatDocsLink, + type ChannelSetupWizard, +} from "openclaw/plugin-sdk/setup"; import { applyTlonSetupConfig, type TlonSetupInput, tlonSetupAdapter } from "./setup-core.js"; import { normalizeShip } from "./targets.js"; import { listTlonAccountIds, resolveTlonAccount, type TlonResolvedAccount } from "./types.js"; diff --git a/extensions/together/index.ts b/extensions/together/index.ts index cb4113b6009..a32031f0634 100644 --- a/extensions/together/index.ts +++ b/extensions/together/index.ts @@ -1,9 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { - applyTogetherConfig, - TOGETHER_DEFAULT_MODEL_REF, -} from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { applyTogetherConfig, TOGETHER_DEFAULT_MODEL_REF } from "./onboard.js"; +import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildTogetherProvider } from "./provider-catalog.js"; const PROVIDER_ID = "together"; @@ -43,18 +41,12 @@ const togetherPlugin = { ], catalog: { order: "simple", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - return { - provider: { - ...buildTogetherProvider(), - apiKey, - }, - }; - }, + run: (ctx) => + buildSingleProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProvider: buildTogetherProvider, + }), }, }); }, diff --git a/extensions/together/onboard.ts b/extensions/together/onboard.ts new file mode 100644 index 00000000000..a540401e01a --- /dev/null +++ b/extensions/together/onboard.ts @@ -0,0 +1,35 @@ +import { + buildTogetherModelDefinition, + TOGETHER_BASE_URL, + TOGETHER_MODEL_CATALOG, +} from "../../src/agents/together-models.js"; +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithModelCatalog, +} from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; + +export const TOGETHER_DEFAULT_MODEL_REF = "together/moonshotai/Kimi-K2.5"; + +export function applyTogetherProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[TOGETHER_DEFAULT_MODEL_REF] = { + ...models[TOGETHER_DEFAULT_MODEL_REF], + alias: models[TOGETHER_DEFAULT_MODEL_REF]?.alias ?? "Together AI", + }; + + return applyProviderConfigWithModelCatalog(cfg, { + agentModels: models, + providerId: "together", + api: "openai-completions", + baseUrl: TOGETHER_BASE_URL, + catalogModels: TOGETHER_MODEL_CATALOG.map(buildTogetherModelDefinition), + }); +} + +export function applyTogetherConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyTogetherProviderConfig(cfg), + TOGETHER_DEFAULT_MODEL_REF, + ); +} diff --git a/extensions/twitch/src/setup-surface.ts b/extensions/twitch/src/setup-surface.ts index 3113bfd9e3b..ec8a7e741b4 100644 --- a/extensions/twitch/src/setup-surface.ts +++ b/extensions/twitch/src/setup-surface.ts @@ -2,12 +2,14 @@ * Twitch setup wizard surface for CLI setup. */ -import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js"; -import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; -import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { + formatDocsLink, + type ChannelSetupAdapter, + type ChannelSetupDmPolicy, + type ChannelSetupWizard, + type OpenClawConfig, + type WizardPrompter, +} from "openclaw/plugin-sdk/setup"; import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js"; import type { TwitchAccountConfig, TwitchRole } from "./types.js"; import { isAccountConfigured } from "./utils/twitch.js"; diff --git a/extensions/venice/index.ts b/extensions/venice/index.ts index 8d3f377d130..92ff17e6df5 100644 --- a/extensions/venice/index.ts +++ b/extensions/venice/index.ts @@ -1,6 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { applyVeniceConfig, VENICE_DEFAULT_MODEL_REF } from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { applyVeniceConfig, VENICE_DEFAULT_MODEL_REF } from "./onboard.js"; +import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildVeniceProvider } from "./provider-catalog.js"; const PROVIDER_ID = "venice"; @@ -46,18 +47,12 @@ const venicePlugin = { ], catalog: { order: "simple", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - return { - provider: { - ...(await buildVeniceProvider()), - apiKey, - }, - }; - }, + run: (ctx) => + buildSingleProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProvider: buildVeniceProvider, + }), }, }); }, diff --git a/extensions/venice/onboard.ts b/extensions/venice/onboard.ts new file mode 100644 index 00000000000..fbd535d6264 --- /dev/null +++ b/extensions/venice/onboard.ts @@ -0,0 +1,33 @@ +import { + buildVeniceModelDefinition, + VENICE_BASE_URL, + VENICE_DEFAULT_MODEL_REF, + VENICE_MODEL_CATALOG, +} from "../../src/agents/venice-models.js"; +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithModelCatalog, +} from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; + +export { VENICE_DEFAULT_MODEL_REF }; + +export function applyVeniceProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[VENICE_DEFAULT_MODEL_REF] = { + ...models[VENICE_DEFAULT_MODEL_REF], + alias: models[VENICE_DEFAULT_MODEL_REF]?.alias ?? "Kimi K2.5", + }; + + return applyProviderConfigWithModelCatalog(cfg, { + agentModels: models, + providerId: "venice", + api: "openai-completions", + baseUrl: VENICE_BASE_URL, + catalogModels: VENICE_MODEL_CATALOG.map(buildVeniceModelDefinition), + }); +} + +export function applyVeniceConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary(applyVeniceProviderConfig(cfg), VENICE_DEFAULT_MODEL_REF); +} diff --git a/extensions/vercel-ai-gateway/index.ts b/extensions/vercel-ai-gateway/index.ts index 7946001981e..ea7c734f310 100644 --- a/extensions/vercel-ai-gateway/index.ts +++ b/extensions/vercel-ai-gateway/index.ts @@ -1,9 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { - applyVercelAiGatewayConfig, - VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, -} from "../../src/commands/onboard-auth.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { applyVercelAiGatewayConfig, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF } from "./onboard.js"; +import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildVercelAiGatewayProvider } from "./provider-catalog.js"; const PROVIDER_ID = "vercel-ai-gateway"; @@ -43,18 +41,12 @@ const vercelAiGatewayPlugin = { ], catalog: { order: "simple", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - return { - provider: { - ...(await buildVercelAiGatewayProvider()), - apiKey, - }, - }; - }, + run: (ctx) => + buildSingleProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProvider: buildVercelAiGatewayProvider, + }), }, }); }, diff --git a/extensions/vercel-ai-gateway/onboard.ts b/extensions/vercel-ai-gateway/onboard.ts new file mode 100644 index 00000000000..d65d7224781 --- /dev/null +++ b/extensions/vercel-ai-gateway/onboard.ts @@ -0,0 +1,30 @@ +import { applyAgentDefaultModelPrimary } from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; + +export const VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF = "vercel-ai-gateway/anthropic/claude-opus-4.6"; + +export function applyVercelAiGatewayProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF] = { + ...models[VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF], + alias: models[VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF]?.alias ?? "Vercel AI Gateway", + }; + + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models, + }, + }, + }; +} + +export function applyVercelAiGatewayConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyVercelAiGatewayProviderConfig(cfg), + VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, + ); +} diff --git a/extensions/volcengine/index.ts b/extensions/volcengine/index.ts index 4fadadb3608..f9e3fb72010 100644 --- a/extensions/volcengine/index.ts +++ b/extensions/volcengine/index.ts @@ -1,6 +1,7 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { ensureModelAllowlistEntry } from "../../src/commands/model-allowlist.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { buildPairedProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildDoubaoCodingProvider, buildDoubaoProvider } from "./provider-catalog.js"; const PROVIDER_ID = "volcengine"; @@ -45,18 +46,15 @@ const volcenginePlugin = { ], catalog: { order: "paired", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - return { - providers: { - volcengine: { ...buildDoubaoProvider(), apiKey }, - "volcengine-plan": { ...buildDoubaoCodingProvider(), apiKey }, - }, - }; - }, + run: (ctx) => + buildPairedProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProviders: () => ({ + volcengine: buildDoubaoProvider(), + "volcengine-plan": buildDoubaoCodingProvider(), + }), + }), }, }); }, diff --git a/extensions/whatsapp/src/accounts.ts b/extensions/whatsapp/src/accounts.ts index c607840dcd3..1d17404a6a2 100644 --- a/extensions/whatsapp/src/accounts.ts +++ b/extensions/whatsapp/src/accounts.ts @@ -1,6 +1,5 @@ import fs from "node:fs"; import path from "node:path"; -import type { DmPolicy, GroupPolicy, WhatsAppAccountConfig } from "openclaw/plugin-sdk/whatsapp"; import { resolveOAuthDir } from "../../../src/config/paths.js"; import { type OpenClawConfig, @@ -10,6 +9,11 @@ import { resolveAccountEntry, resolveUserPath, } from "../../../src/plugin-sdk-internal/accounts.js"; +import type { + DmPolicy, + GroupPolicy, + WhatsAppAccountConfig, +} from "../../../src/plugin-sdk-internal/whatsapp.js"; import { hasWebCredsSync } from "./auth-store.js"; export type ResolvedWhatsAppAccount = { diff --git a/extensions/whatsapp/src/channel.setup.ts b/extensions/whatsapp/src/channel.setup.ts index b352bd2ed73..919a75c1a8c 100644 --- a/extensions/whatsapp/src/channel.setup.ts +++ b/extensions/whatsapp/src/channel.setup.ts @@ -1,198 +1,21 @@ -import { - buildAccountScopedDmSecurityPolicy, - buildChannelConfigSchema, - collectAllowlistProviderGroupPolicyWarnings, - collectOpenGroupPolicyRouteAllowlistWarnings, - DEFAULT_ACCOUNT_ID, - formatWhatsAppConfigAllowFromEntries, - getChatChannelMeta, - normalizeE164, - resolveWhatsAppConfigAllowFrom, - resolveWhatsAppConfigDefaultTo, - resolveWhatsAppGroupIntroHint, - resolveWhatsAppGroupRequireMention, - resolveWhatsAppGroupToolPolicy, - WhatsAppConfigSchema, - type ChannelPlugin, -} from "openclaw/plugin-sdk/whatsapp"; -import { - listWhatsAppAccountIds, - resolveDefaultWhatsAppAccountId, - resolveWhatsAppAccount, - type ResolvedWhatsAppAccount, -} from "./accounts.js"; +import { type ChannelPlugin } from "openclaw/plugin-sdk/whatsapp"; +import { type ResolvedWhatsAppAccount } from "./accounts.js"; import { webAuthExists } from "./auth-store.js"; import { whatsappSetupAdapter } from "./setup-core.js"; +import { createWhatsAppPluginBase, createWhatsAppSetupWizardProxy } from "./shared.js"; async function loadWhatsAppChannelRuntime() { return await import("./channel.runtime.js"); } -const whatsappSetupWizardProxy = { - channel: "whatsapp", - status: { - configuredLabel: "linked", - unconfiguredLabel: "not linked", - configuredHint: "linked", - unconfiguredHint: "not linked", - configuredScore: 5, - unconfiguredScore: 4, - resolveConfigured: async ({ cfg }) => - await ( - await loadWhatsAppChannelRuntime() - ).whatsappSetupWizard.status.resolveConfigured({ - cfg, - }), - resolveStatusLines: async ({ cfg, configured }) => - (await ( - await loadWhatsAppChannelRuntime() - ).whatsappSetupWizard.status.resolveStatusLines?.({ - cfg, - configured, - })) ?? [], - }, - resolveShouldPromptAccountIds: (params) => - (params.shouldPromptAccountIds || params.options?.promptWhatsAppAccountId) ?? false, - credentials: [], - finalize: async (params) => - await ( - await loadWhatsAppChannelRuntime() - ).whatsappSetupWizard.finalize!(params), - disable: (cfg) => ({ - ...cfg, - channels: { - ...cfg.channels, - whatsapp: { - ...cfg.channels?.whatsapp, - enabled: false, - }, - }, - }), - onAccountRecorded: (accountId, options) => { - options?.onWhatsAppAccountId?.(accountId); - }, -} satisfies NonNullable["setupWizard"]>; +const whatsappSetupWizardProxy = createWhatsAppSetupWizardProxy(async () => ({ + whatsappSetupWizard: (await loadWhatsAppChannelRuntime()).whatsappSetupWizard, +})); export const whatsappSetupPlugin: ChannelPlugin = { - id: "whatsapp", - meta: { - ...getChatChannelMeta("whatsapp"), - showConfigured: false, - quickstartAllowFrom: true, - forceAccountBinding: true, - preferSessionLookupForAnnounceTarget: true, - }, - setupWizard: whatsappSetupWizardProxy, - capabilities: { - chatTypes: ["direct", "group"], - polls: true, - reactions: true, - media: true, - }, - reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] }, - gatewayMethods: ["web.login.start", "web.login.wait"], - configSchema: buildChannelConfigSchema(WhatsAppConfigSchema), - config: { - listAccountIds: (cfg) => listWhatsAppAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultWhatsAppAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => { - const accountKey = accountId || DEFAULT_ACCOUNT_ID; - const accounts = { ...cfg.channels?.whatsapp?.accounts }; - const existing = accounts[accountKey] ?? {}; - return { - ...cfg, - channels: { - ...cfg.channels, - whatsapp: { - ...cfg.channels?.whatsapp, - accounts: { - ...accounts, - [accountKey]: { - ...existing, - enabled, - }, - }, - }, - }, - }; - }, - deleteAccount: ({ cfg, accountId }) => { - const accountKey = accountId || DEFAULT_ACCOUNT_ID; - const accounts = { ...cfg.channels?.whatsapp?.accounts }; - delete accounts[accountKey]; - return { - ...cfg, - channels: { - ...cfg.channels, - whatsapp: { - ...cfg.channels?.whatsapp, - accounts: Object.keys(accounts).length ? accounts : undefined, - }, - }, - }; - }, - isEnabled: (account, cfg) => account.enabled && cfg.web?.enabled !== false, - disabledReason: () => "disabled", + ...createWhatsAppPluginBase({ + setupWizard: whatsappSetupWizardProxy, + setup: whatsappSetupAdapter, isConfigured: async (account) => await webAuthExists(account.authDir), - unconfiguredReason: () => "not linked", - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: Boolean(account.authDir), - linked: Boolean(account.authDir), - dmPolicy: account.dmPolicy, - allowFrom: account.allowFrom, - }), - resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppConfigAllowFrom({ cfg, accountId }), - formatAllowFrom: ({ allowFrom }) => formatWhatsAppConfigAllowFromEntries(allowFrom), - resolveDefaultTo: ({ cfg, accountId }) => resolveWhatsAppConfigDefaultTo({ cfg, accountId }), - }, - security: { - resolveDmPolicy: ({ cfg, accountId, account }) => - buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "whatsapp", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.dmPolicy, - allowFrom: account.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => normalizeE164(raw), - }), - collectWarnings: ({ account, cfg }) => { - const groupAllowlistConfigured = - Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0; - return collectAllowlistProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.whatsapp !== undefined, - configuredGroupPolicy: account.groupPolicy, - collect: (groupPolicy) => - collectOpenGroupPolicyRouteAllowlistWarnings({ - groupPolicy, - routeAllowlistConfigured: groupAllowlistConfigured, - restrictSenders: { - surface: "WhatsApp groups", - openScope: "any member in allowed groups", - groupPolicyPath: "channels.whatsapp.groupPolicy", - groupAllowFromPath: "channels.whatsapp.groupAllowFrom", - }, - noRouteAllowlist: { - surface: "WhatsApp groups", - routeAllowlistPath: "channels.whatsapp.groups", - routeScope: "group", - groupPolicyPath: "channels.whatsapp.groupPolicy", - groupAllowFromPath: "channels.whatsapp.groupAllowFrom", - }, - }), - }); - }, - }, - setup: whatsappSetupAdapter, - groups: { - resolveRequireMention: resolveWhatsAppGroupRequireMention, - resolveToolPolicy: resolveWhatsAppGroupToolPolicy, - resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, - }, + }), }; diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index d7f437d3204..6fe1663e55f 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -1,45 +1,31 @@ import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/compat"; import { - buildChannelConfigSchema, - buildAccountScopedDmSecurityPolicy, - collectAllowlistProviderGroupPolicyWarnings, - collectOpenGroupPolicyRouteAllowlistWarnings, createActionGate, createWhatsAppOutboundBase, DEFAULT_ACCOUNT_ID, - getChatChannelMeta, listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig, - normalizeE164, formatWhatsAppConfigAllowFromEntries, readStringParam, resolveWhatsAppOutboundTarget, - resolveWhatsAppConfigAllowFrom, - resolveWhatsAppConfigDefaultTo, - resolveWhatsAppGroupRequireMention, - resolveWhatsAppGroupIntroHint, - resolveWhatsAppGroupToolPolicy, resolveWhatsAppHeartbeatRecipients, resolveWhatsAppMentionStripRegexes, - WhatsAppConfigSchema, type ChannelMessageActionName, type ChannelPlugin, } from "openclaw/plugin-sdk/whatsapp"; import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../../src/whatsapp/normalize.js"; // WhatsApp-specific imports from local extension code (moved from src/web/ and src/channels/plugins/) -import { - listWhatsAppAccountIds, - resolveDefaultWhatsAppAccountId, - resolveWhatsAppAccount, - type ResolvedWhatsAppAccount, -} from "./accounts.js"; +import { resolveWhatsAppAccount, type ResolvedWhatsAppAccount } from "./accounts.js"; import { looksLikeWhatsAppTargetId, normalizeWhatsAppMessagingTarget } from "./normalize.js"; import { getWhatsAppRuntime } from "./runtime.js"; import { whatsappSetupAdapter } from "./setup-core.js"; +import { + createWhatsAppPluginBase, + createWhatsAppSetupWizardProxy, + WHATSAPP_CHANNEL, +} from "./shared.js"; import { collectWhatsAppStatusIssues } from "./status-issues.js"; -const meta = getChatChannelMeta("whatsapp"); - async function loadWhatsAppChannelRuntime() { return await import("./channel.runtime.js"); } @@ -59,132 +45,21 @@ function parseWhatsAppExplicitTarget(raw: string) { }; } -const whatsappSetupWizardProxy = { - channel: "whatsapp", - status: { - configuredLabel: "linked", - unconfiguredLabel: "not linked", - configuredHint: "linked", - unconfiguredHint: "not linked", - configuredScore: 5, - unconfiguredScore: 4, - resolveConfigured: async ({ cfg }) => - await ( - await loadWhatsAppChannelRuntime() - ).whatsappSetupWizard.status.resolveConfigured({ - cfg, - }), - resolveStatusLines: async ({ cfg, configured }) => - (await ( - await loadWhatsAppChannelRuntime() - ).whatsappSetupWizard.status.resolveStatusLines?.({ - cfg, - configured, - })) ?? [], - }, - resolveShouldPromptAccountIds: (params) => - (params.shouldPromptAccountIds || params.options?.promptWhatsAppAccountId) ?? false, - credentials: [], - finalize: async (params) => - await ( - await loadWhatsAppChannelRuntime() - ).whatsappSetupWizard.finalize!(params), - disable: (cfg) => ({ - ...cfg, - channels: { - ...cfg.channels, - whatsapp: { - ...cfg.channels?.whatsapp, - enabled: false, - }, - }, - }), - onAccountRecorded: (accountId, options) => { - options?.onWhatsAppAccountId?.(accountId); - }, -} satisfies NonNullable["setupWizard"]>; +const whatsappSetupWizardProxy = createWhatsAppSetupWizardProxy(async () => ({ + whatsappSetupWizard: (await loadWhatsAppChannelRuntime()).whatsappSetupWizard, +})); export const whatsappPlugin: ChannelPlugin = { - id: "whatsapp", - meta: { - ...meta, - showConfigured: false, - quickstartAllowFrom: true, - forceAccountBinding: true, - preferSessionLookupForAnnounceTarget: true, - }, - setupWizard: whatsappSetupWizardProxy, + ...createWhatsAppPluginBase({ + setupWizard: whatsappSetupWizardProxy, + setup: whatsappSetupAdapter, + isConfigured: async (account) => + await getWhatsAppRuntime().channel.whatsapp.webAuthExists(account.authDir), + }), agentTools: () => [getWhatsAppRuntime().channel.whatsapp.createLoginTool()], pairing: { idLabel: "whatsappSenderId", }, - capabilities: { - chatTypes: ["direct", "group"], - polls: true, - reactions: true, - media: true, - }, - reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] }, - gatewayMethods: ["web.login.start", "web.login.wait"], - configSchema: buildChannelConfigSchema(WhatsAppConfigSchema), - config: { - listAccountIds: (cfg) => listWhatsAppAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), - defaultAccountId: (cfg) => resolveDefaultWhatsAppAccountId(cfg), - setAccountEnabled: ({ cfg, accountId, enabled }) => { - const accountKey = accountId || DEFAULT_ACCOUNT_ID; - const accounts = { ...cfg.channels?.whatsapp?.accounts }; - const existing = accounts[accountKey] ?? {}; - return { - ...cfg, - channels: { - ...cfg.channels, - whatsapp: { - ...cfg.channels?.whatsapp, - accounts: { - ...accounts, - [accountKey]: { - ...existing, - enabled, - }, - }, - }, - }, - }; - }, - deleteAccount: ({ cfg, accountId }) => { - const accountKey = accountId || DEFAULT_ACCOUNT_ID; - const accounts = { ...cfg.channels?.whatsapp?.accounts }; - delete accounts[accountKey]; - return { - ...cfg, - channels: { - ...cfg.channels, - whatsapp: { - ...cfg.channels?.whatsapp, - accounts: Object.keys(accounts).length ? accounts : undefined, - }, - }, - }; - }, - isEnabled: (account, cfg) => account.enabled && cfg.web?.enabled !== false, - disabledReason: () => "disabled", - isConfigured: async (account) => - await getWhatsAppRuntime().channel.whatsapp.webAuthExists(account.authDir), - unconfiguredReason: () => "not linked", - describeAccount: (account) => ({ - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured: Boolean(account.authDir), - linked: Boolean(account.authDir), - dmPolicy: account.dmPolicy, - allowFrom: account.allowFrom, - }), - resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppConfigAllowFrom({ cfg, accountId }), - formatAllowFrom: ({ allowFrom }) => formatWhatsAppConfigAllowFromEntries(allowFrom), - resolveDefaultTo: ({ cfg, accountId }) => resolveWhatsAppConfigDefaultTo({ cfg, accountId }), - }, allowlist: { supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", readConfig: ({ cfg, accountId }) => { @@ -205,53 +80,6 @@ export const whatsappPlugin: ChannelPlugin = { }), }), }, - security: { - resolveDmPolicy: ({ cfg, accountId, account }) => { - return buildAccountScopedDmSecurityPolicy({ - cfg, - channelKey: "whatsapp", - accountId, - fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, - policy: account.dmPolicy, - allowFrom: account.allowFrom ?? [], - policyPathSuffix: "dmPolicy", - normalizeEntry: (raw) => normalizeE164(raw), - }); - }, - collectWarnings: ({ account, cfg }) => { - const groupAllowlistConfigured = - Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0; - return collectAllowlistProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.whatsapp !== undefined, - configuredGroupPolicy: account.groupPolicy, - collect: (groupPolicy) => - collectOpenGroupPolicyRouteAllowlistWarnings({ - groupPolicy, - routeAllowlistConfigured: groupAllowlistConfigured, - restrictSenders: { - surface: "WhatsApp groups", - openScope: "any member in allowed groups", - groupPolicyPath: "channels.whatsapp.groupPolicy", - groupAllowFromPath: "channels.whatsapp.groupAllowFrom", - }, - noRouteAllowlist: { - surface: "WhatsApp groups", - routeAllowlistPath: "channels.whatsapp.groups", - routeScope: "group", - groupPolicyPath: "channels.whatsapp.groupPolicy", - groupAllowFromPath: "channels.whatsapp.groupAllowFrom", - }, - }), - }); - }, - }, - setup: whatsappSetupAdapter, - groups: { - resolveRequireMention: resolveWhatsAppGroupRequireMention, - resolveToolPolicy: resolveWhatsAppGroupToolPolicy, - resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, - }, mentions: { stripRegexes: ({ ctx }) => resolveWhatsAppMentionStripRegexes(ctx), }, @@ -304,7 +132,7 @@ export const whatsappPlugin: ChannelPlugin = { supportsAction: ({ action }) => action === "react", handleAction: async ({ action, params, cfg, accountId }) => { if (action !== "react") { - throw new Error(`Action ${action} is not supported for provider ${meta.id}.`); + throw new Error(`Action ${action} is not supported for provider ${WHATSAPP_CHANNEL}.`); } const messageId = readStringParam(params, "messageId", { required: true, diff --git a/extensions/whatsapp/src/plugin-shared.ts b/extensions/whatsapp/src/plugin-shared.ts new file mode 100644 index 00000000000..1ab5d80220c --- /dev/null +++ b/extensions/whatsapp/src/plugin-shared.ts @@ -0,0 +1,51 @@ +import { type ChannelPlugin } from "../../../src/plugin-sdk-internal/whatsapp.js"; +import { type ResolvedWhatsAppAccount } from "./accounts.js"; + +async function loadWhatsAppChannelRuntime() { + return await import("./channel.runtime.js"); +} + +export const whatsappSetupWizardProxy = { + channel: "whatsapp", + status: { + configuredLabel: "linked", + unconfiguredLabel: "not linked", + configuredHint: "linked", + unconfiguredHint: "not linked", + configuredScore: 5, + unconfiguredScore: 4, + resolveConfigured: async ({ cfg }) => + await ( + await loadWhatsAppChannelRuntime() + ).whatsappSetupWizard.status.resolveConfigured({ + cfg, + }), + resolveStatusLines: async ({ cfg, configured }) => + (await ( + await loadWhatsAppChannelRuntime() + ).whatsappSetupWizard.status.resolveStatusLines?.({ + cfg, + configured, + })) ?? [], + }, + resolveShouldPromptAccountIds: (params) => + (params.shouldPromptAccountIds || params.options?.promptWhatsAppAccountId) ?? false, + credentials: [], + finalize: async (params) => + await ( + await loadWhatsAppChannelRuntime() + ).whatsappSetupWizard.finalize!(params), + disable: (cfg) => ({ + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + ...cfg.channels?.whatsapp, + enabled: false, + }, + }, + }), + onAccountRecorded: (accountId, options) => { + options?.onWhatsAppAccountId?.(accountId); + }, +} satisfies NonNullable["setupWizard"]>; diff --git a/extensions/whatsapp/src/runtime.ts b/extensions/whatsapp/src/runtime.ts index 07dd4e3d688..e103cc878f0 100644 --- a/extensions/whatsapp/src/runtime.ts +++ b/extensions/whatsapp/src/runtime.ts @@ -1,5 +1,7 @@ -import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat"; -import type { PluginRuntime } from "openclaw/plugin-sdk/core"; +import { + createPluginRuntimeStore, + type PluginRuntime, +} from "../../../src/plugin-sdk-internal/core.js"; const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } = createPluginRuntimeStore("WhatsApp runtime not initialized"); diff --git a/extensions/whatsapp/src/setup-core.ts b/extensions/whatsapp/src/setup-core.ts index a4471eb8188..346c9aa0e8d 100644 --- a/extensions/whatsapp/src/setup-core.ts +++ b/extensions/whatsapp/src/setup-core.ts @@ -1,52 +1,12 @@ -import { - applyAccountNameToChannelSection, - type ChannelSetupAdapter, - migrateBaseNameToDefaultAccount, - normalizeAccountId, -} from "../../../src/plugin-sdk-internal/setup.js"; +import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupAdapter } from "../../../src/plugin-sdk-internal/setup.js"; const channel = "whatsapp" as const; -export const whatsappSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - alwaysUseAccounts: true, - }), - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - alwaysUseAccounts: true, - }); - const next = migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - alwaysUseAccounts: true, - }); - const entry = { - ...next.channels?.whatsapp?.accounts?.[accountId], - ...(input.authDir ? { authDir: input.authDir } : {}), - enabled: true, - }; - return { - ...next, - channels: { - ...next.channels, - whatsapp: { - ...next.channels?.whatsapp, - accounts: { - ...next.channels?.whatsapp?.accounts, - [accountId]: entry, - }, - }, - }, - }; - }, -}; +export const whatsappSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ + channelKey: channel, + alwaysUseAccounts: true, + buildPatch: (input) => ({ + ...(input.authDir ? { authDir: input.authDir } : {}), + }), +}); diff --git a/extensions/whatsapp/src/setup-surface.ts b/extensions/whatsapp/src/setup-surface.ts index e2ec4149631..50a28d419cb 100644 --- a/extensions/whatsapp/src/setup-surface.ts +++ b/extensions/whatsapp/src/setup-surface.ts @@ -1,5 +1,4 @@ import path from "node:path"; -import type { DmPolicy } from "openclaw/plugin-sdk/whatsapp"; import { DEFAULT_ACCOUNT_ID, formatCliCommand, diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts new file mode 100644 index 00000000000..3a8f7412e7e --- /dev/null +++ b/extensions/whatsapp/src/shared.ts @@ -0,0 +1,212 @@ +import { + buildAccountScopedDmSecurityPolicy, + buildChannelConfigSchema, + collectAllowlistProviderGroupPolicyWarnings, + collectOpenGroupPolicyRouteAllowlistWarnings, + DEFAULT_ACCOUNT_ID, + formatWhatsAppConfigAllowFromEntries, + getChatChannelMeta, + normalizeE164, + resolveWhatsAppConfigAllowFrom, + resolveWhatsAppConfigDefaultTo, + resolveWhatsAppGroupIntroHint, + resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupToolPolicy, + WhatsAppConfigSchema, + type ChannelPlugin, +} from "openclaw/plugin-sdk/whatsapp"; +import { + listWhatsAppAccountIds, + resolveDefaultWhatsAppAccountId, + resolveWhatsAppAccount, + type ResolvedWhatsAppAccount, +} from "./accounts.js"; + +export const WHATSAPP_CHANNEL = "whatsapp" as const; + +export function createWhatsAppSetupWizardProxy( + loadWizard: () => Promise<{ + whatsappSetupWizard: NonNullable["setupWizard"]>; + }>, +): NonNullable["setupWizard"]> { + return { + channel: WHATSAPP_CHANNEL, + status: { + configuredLabel: "linked", + unconfiguredLabel: "not linked", + configuredHint: "linked", + unconfiguredHint: "not linked", + configuredScore: 5, + unconfiguredScore: 4, + resolveConfigured: async ({ cfg }) => + await (await loadWizard()).whatsappSetupWizard.status.resolveConfigured({ cfg }), + resolveStatusLines: async ({ cfg, configured }) => + (await ( + await loadWizard() + ).whatsappSetupWizard.status.resolveStatusLines?.({ + cfg, + configured, + })) ?? [], + }, + resolveShouldPromptAccountIds: (params) => + (params.shouldPromptAccountIds || params.options?.promptWhatsAppAccountId) ?? false, + credentials: [], + finalize: async (params) => await (await loadWizard()).whatsappSetupWizard.finalize!(params), + disable: (cfg) => ({ + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + ...cfg.channels?.whatsapp, + enabled: false, + }, + }, + }), + onAccountRecorded: (accountId, options) => { + options?.onWhatsAppAccountId?.(accountId); + }, + }; +} + +export function createWhatsAppPluginBase(params: { + setupWizard: NonNullable["setupWizard"]>; + setup: NonNullable["setup"]>; + isConfigured: NonNullable["config"]>["isConfigured"]; +}): Pick< + ChannelPlugin, + | "id" + | "meta" + | "setupWizard" + | "capabilities" + | "reload" + | "gatewayMethods" + | "configSchema" + | "config" + | "security" + | "setup" + | "groups" +> { + return { + id: WHATSAPP_CHANNEL, + meta: { + ...getChatChannelMeta(WHATSAPP_CHANNEL), + showConfigured: false, + quickstartAllowFrom: true, + forceAccountBinding: true, + preferSessionLookupForAnnounceTarget: true, + }, + setupWizard: params.setupWizard, + capabilities: { + chatTypes: ["direct", "group"], + polls: true, + reactions: true, + media: true, + }, + reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] }, + gatewayMethods: ["web.login.start", "web.login.wait"], + configSchema: buildChannelConfigSchema(WhatsAppConfigSchema), + config: { + listAccountIds: (cfg) => listWhatsAppAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultWhatsAppAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => { + const accountKey = accountId || DEFAULT_ACCOUNT_ID; + const accounts = { ...cfg.channels?.whatsapp?.accounts }; + const existing = accounts[accountKey] ?? {}; + return { + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + ...cfg.channels?.whatsapp, + accounts: { + ...accounts, + [accountKey]: { + ...existing, + enabled, + }, + }, + }, + }, + }; + }, + deleteAccount: ({ cfg, accountId }) => { + const accountKey = accountId || DEFAULT_ACCOUNT_ID; + const accounts = { ...cfg.channels?.whatsapp?.accounts }; + delete accounts[accountKey]; + return { + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + ...cfg.channels?.whatsapp, + accounts: Object.keys(accounts).length ? accounts : undefined, + }, + }, + }; + }, + isEnabled: (account, cfg) => account.enabled && cfg.web?.enabled !== false, + disabledReason: () => "disabled", + isConfigured: params.isConfigured, + unconfiguredReason: () => "not linked", + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.authDir), + linked: Boolean(account.authDir), + dmPolicy: account.dmPolicy, + allowFrom: account.allowFrom, + }), + resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppConfigAllowFrom({ cfg, accountId }), + formatAllowFrom: ({ allowFrom }) => formatWhatsAppConfigAllowFromEntries(allowFrom), + resolveDefaultTo: ({ cfg, accountId }) => resolveWhatsAppConfigDefaultTo({ cfg, accountId }), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => + buildAccountScopedDmSecurityPolicy({ + cfg, + channelKey: WHATSAPP_CHANNEL, + accountId, + fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID, + policy: account.dmPolicy, + allowFrom: account.allowFrom ?? [], + policyPathSuffix: "dmPolicy", + normalizeEntry: (raw) => normalizeE164(raw), + }), + collectWarnings: ({ account, cfg }) => { + const groupAllowlistConfigured = + Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0; + return collectAllowlistProviderGroupPolicyWarnings({ + cfg, + providerConfigPresent: cfg.channels?.whatsapp !== undefined, + configuredGroupPolicy: account.groupPolicy, + collect: (groupPolicy) => + collectOpenGroupPolicyRouteAllowlistWarnings({ + groupPolicy, + routeAllowlistConfigured: groupAllowlistConfigured, + restrictSenders: { + surface: "WhatsApp groups", + openScope: "any member in allowed groups", + groupPolicyPath: "channels.whatsapp.groupPolicy", + groupAllowFromPath: "channels.whatsapp.groupAllowFrom", + }, + noRouteAllowlist: { + surface: "WhatsApp groups", + routeAllowlistPath: "channels.whatsapp.groups", + routeScope: "group", + groupPolicyPath: "channels.whatsapp.groupPolicy", + groupAllowFromPath: "channels.whatsapp.groupAllowFrom", + }, + }), + }); + }, + }, + setup: params.setup, + groups: { + resolveRequireMention: resolveWhatsAppGroupRequireMention, + resolveToolPolicy: resolveWhatsAppGroupToolPolicy, + resolveGroupIntroHint: resolveWhatsAppGroupIntroHint, + }, + }; +} diff --git a/extensions/xai/index.ts b/extensions/xai/index.ts index c9f3bcdf4de..b5f6830fd2e 100644 --- a/extensions/xai/index.ts +++ b/extensions/xai/index.ts @@ -4,10 +4,10 @@ import { getScopedCredentialValue, setScopedCredentialValue, } from "../../src/agents/tools/web-search-plugin-factory.js"; -import { applyXaiConfig, XAI_DEFAULT_MODEL_REF } from "../../src/commands/onboard-auth.js"; import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; import type { OpenClawPluginApi } from "../../src/plugins/types.js"; +import { applyXaiConfig, XAI_DEFAULT_MODEL_REF } from "./onboard.js"; const PROVIDER_ID = "xai"; const XAI_MODERN_MODEL_PREFIXES = ["grok-4"] as const; diff --git a/extensions/xai/model-definitions.ts b/extensions/xai/model-definitions.ts new file mode 100644 index 00000000000..5d3383eff8e --- /dev/null +++ b/extensions/xai/model-definitions.ts @@ -0,0 +1,25 @@ +import type { ModelDefinitionConfig } from "../../src/config/types.models.js"; + +export const XAI_BASE_URL = "https://api.x.ai/v1"; +export const XAI_DEFAULT_MODEL_ID = "grok-4"; +export const XAI_DEFAULT_MODEL_REF = `xai/${XAI_DEFAULT_MODEL_ID}`; +export const XAI_DEFAULT_CONTEXT_WINDOW = 131072; +export const XAI_DEFAULT_MAX_TOKENS = 8192; +export const XAI_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +export function buildXaiModelDefinition(): ModelDefinitionConfig { + return { + id: XAI_DEFAULT_MODEL_ID, + name: "Grok 4", + reasoning: false, + input: ["text"], + cost: XAI_DEFAULT_COST, + contextWindow: XAI_DEFAULT_CONTEXT_WINDOW, + maxTokens: XAI_DEFAULT_MAX_TOKENS, + }; +} diff --git a/extensions/xai/onboard.ts b/extensions/xai/onboard.ts new file mode 100644 index 00000000000..ee5cfbc92cf --- /dev/null +++ b/extensions/xai/onboard.ts @@ -0,0 +1,34 @@ +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithDefaultModel, +} from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; +import { + buildXaiModelDefinition, + XAI_BASE_URL, + XAI_DEFAULT_MODEL_ID, + XAI_DEFAULT_MODEL_REF, +} from "./model-definitions.js"; + +export { XAI_DEFAULT_MODEL_REF }; + +export function applyXaiProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[XAI_DEFAULT_MODEL_REF] = { + ...models[XAI_DEFAULT_MODEL_REF], + alias: models[XAI_DEFAULT_MODEL_REF]?.alias ?? "Grok", + }; + + return applyProviderConfigWithDefaultModel(cfg, { + agentModels: models, + providerId: "xai", + api: "openai-completions", + baseUrl: XAI_BASE_URL, + defaultModel: buildXaiModelDefinition(), + defaultModelId: XAI_DEFAULT_MODEL_ID, + }); +} + +export function applyXaiConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary(applyXaiProviderConfig(cfg), XAI_DEFAULT_MODEL_REF); +} diff --git a/extensions/xiaomi/index.ts b/extensions/xiaomi/index.ts index 2b87dfee12a..33eb6e47bf9 100644 --- a/extensions/xiaomi/index.ts +++ b/extensions/xiaomi/index.ts @@ -1,7 +1,8 @@ import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core"; -import { applyXiaomiConfig, XIAOMI_DEFAULT_MODEL_REF } from "../../src/commands/onboard-auth.js"; import { PROVIDER_LABELS } from "../../src/infra/provider-usage.shared.js"; import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js"; +import { applyXiaomiConfig, XIAOMI_DEFAULT_MODEL_REF } from "./onboard.js"; +import { buildSingleProviderApiKeyCatalog } from "../../src/plugins/provider-catalog.js"; import { buildXiaomiProvider } from "./provider-catalog.js"; const PROVIDER_ID = "xiaomi"; @@ -41,18 +42,12 @@ const xiaomiPlugin = { ], catalog: { order: "simple", - run: async (ctx) => { - const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; - if (!apiKey) { - return null; - } - return { - provider: { - ...buildXiaomiProvider(), - apiKey, - }, - }; - }, + run: (ctx) => + buildSingleProviderApiKeyCatalog({ + ctx, + providerId: PROVIDER_ID, + buildProvider: buildXiaomiProvider, + }), }, resolveUsageAuth: async (ctx) => { const apiKey = ctx.resolveApiKeyFromConfigAndStore({ diff --git a/extensions/xiaomi/onboard.ts b/extensions/xiaomi/onboard.ts new file mode 100644 index 00000000000..3f3eef149c4 --- /dev/null +++ b/extensions/xiaomi/onboard.ts @@ -0,0 +1,30 @@ +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithDefaultModels, +} from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; +import { buildXiaomiProvider, XIAOMI_DEFAULT_MODEL_ID } from "./provider-catalog.js"; + +export const XIAOMI_DEFAULT_MODEL_REF = `xiaomi/${XIAOMI_DEFAULT_MODEL_ID}`; + +export function applyXiaomiProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[XIAOMI_DEFAULT_MODEL_REF] = { + ...models[XIAOMI_DEFAULT_MODEL_REF], + alias: models[XIAOMI_DEFAULT_MODEL_REF]?.alias ?? "Xiaomi", + }; + const defaultProvider = buildXiaomiProvider(); + const resolvedApi = defaultProvider.api ?? "openai-completions"; + return applyProviderConfigWithDefaultModels(cfg, { + agentModels: models, + providerId: "xiaomi", + api: resolvedApi, + baseUrl: defaultProvider.baseUrl, + defaultModels: defaultProvider.models ?? [], + defaultModelId: XIAOMI_DEFAULT_MODEL_ID, + }); +} + +export function applyXiaomiConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary(applyXiaomiProviderConfig(cfg), XIAOMI_DEFAULT_MODEL_REF); +} diff --git a/extensions/zai/index.ts b/extensions/zai/index.ts index 16f1c311ea3..21ddc902902 100644 --- a/extensions/zai/index.ts +++ b/extensions/zai/index.ts @@ -19,18 +19,15 @@ import { validateApiKeyInput, } from "../../src/commands/auth-choice.api-key.js"; import { ensureApiKeyFromOptionEnvOrPrompt } from "../../src/commands/auth-choice.apply-helpers.js"; -import { buildApiKeyCredential } from "../../src/commands/onboard-auth.credentials.js"; -import { - applyAuthProfileConfig, - applyZaiConfig, - applyZaiProviderConfig, - ZAI_DEFAULT_MODEL_REF, -} from "../../src/commands/onboard-auth.js"; +import { buildApiKeyCredential } from "../../src/commands/auth-credentials.js"; +import { applyAuthProfileConfig } from "../../src/commands/onboard-auth.js"; import type { SecretInput } from "../../src/config/types.secrets.js"; import { resolveRequiredHomeDir } from "../../src/infra/home-dir.js"; import { fetchZaiUsage } from "../../src/infra/provider-usage.fetch.js"; import { normalizeOptionalSecretInput } from "../../src/utils/normalize-secret-input.js"; import { detectZaiEndpoint, type ZaiEndpointId } from "./detect.js"; +import { zaiMediaUnderstandingProvider } from "./media-understanding-provider.js"; +import { applyZaiConfig, applyZaiProviderConfig, ZAI_DEFAULT_MODEL_REF } from "./onboard.js"; const PROVIDER_ID = "zai"; const GLM5_MODEL_ID = "glm-5"; @@ -338,6 +335,7 @@ const zaiPlugin = { fetchUsageSnapshot: async (ctx) => await fetchZaiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn), isCacheTtlEligible: () => true, }); + api.registerMediaUnderstandingProvider(zaiMediaUnderstandingProvider); }, }; diff --git a/extensions/zai/media-understanding-provider.ts b/extensions/zai/media-understanding-provider.ts new file mode 100644 index 00000000000..bbd8bcc59fc --- /dev/null +++ b/extensions/zai/media-understanding-provider.ts @@ -0,0 +1,8 @@ +import { describeImageWithModel } from "../../src/media-understanding/providers/image.js"; +import type { MediaUnderstandingProvider } from "../../src/media-understanding/types.js"; + +export const zaiMediaUnderstandingProvider: MediaUnderstandingProvider = { + id: "zai", + capabilities: ["image"], + describeImage: describeImageWithModel, +}; diff --git a/extensions/zai/model-definitions.ts b/extensions/zai/model-definitions.ts new file mode 100644 index 00000000000..2527ee53031 --- /dev/null +++ b/extensions/zai/model-definitions.ts @@ -0,0 +1,60 @@ +import type { ModelDefinitionConfig } from "../../src/config/types.models.js"; + +export const ZAI_CODING_GLOBAL_BASE_URL = "https://api.z.ai/api/coding/paas/v4"; +export const ZAI_CODING_CN_BASE_URL = "https://open.bigmodel.cn/api/coding/paas/v4"; +export const ZAI_GLOBAL_BASE_URL = "https://api.z.ai/api/paas/v4"; +export const ZAI_CN_BASE_URL = "https://open.bigmodel.cn/api/paas/v4"; +export const ZAI_DEFAULT_MODEL_ID = "glm-5"; +export const ZAI_DEFAULT_MODEL_REF = `zai/${ZAI_DEFAULT_MODEL_ID}`; + +export const ZAI_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +const ZAI_MODEL_CATALOG = { + "glm-5": { name: "GLM-5", reasoning: true }, + "glm-5-turbo": { name: "GLM-5 Turbo", reasoning: true }, + "glm-4.7": { name: "GLM-4.7", reasoning: true }, + "glm-4.7-flash": { name: "GLM-4.7 Flash", reasoning: true }, + "glm-4.7-flashx": { name: "GLM-4.7 FlashX", reasoning: true }, +} as const; + +type ZaiCatalogId = keyof typeof ZAI_MODEL_CATALOG; + +export function resolveZaiBaseUrl(endpoint?: string): string { + switch (endpoint) { + case "coding-cn": + return ZAI_CODING_CN_BASE_URL; + case "global": + return ZAI_GLOBAL_BASE_URL; + case "cn": + return ZAI_CN_BASE_URL; + case "coding-global": + return ZAI_CODING_GLOBAL_BASE_URL; + default: + return ZAI_GLOBAL_BASE_URL; + } +} + +export function buildZaiModelDefinition(params: { + id: string; + name?: string; + reasoning?: boolean; + cost?: ModelDefinitionConfig["cost"]; + contextWindow?: number; + maxTokens?: number; +}): ModelDefinitionConfig { + const catalog = ZAI_MODEL_CATALOG[params.id as ZaiCatalogId]; + return { + id: params.id, + name: params.name ?? catalog?.name ?? `GLM ${params.id}`, + reasoning: params.reasoning ?? catalog?.reasoning ?? true, + input: ["text"], + cost: params.cost ?? ZAI_DEFAULT_COST, + contextWindow: params.contextWindow ?? 204800, + maxTokens: params.maxTokens ?? 131072, + }; +} diff --git a/extensions/zai/onboard.ts b/extensions/zai/onboard.ts new file mode 100644 index 00000000000..a440387cf7b --- /dev/null +++ b/extensions/zai/onboard.ts @@ -0,0 +1,58 @@ +import { + applyAgentDefaultModelPrimary, + applyProviderConfigWithModelCatalog, +} from "../../src/commands/onboard-auth.config-shared.js"; +import type { OpenClawConfig } from "../../src/config/config.js"; +import { + buildZaiModelDefinition, + resolveZaiBaseUrl, + ZAI_DEFAULT_MODEL_ID, + ZAI_DEFAULT_MODEL_REF, +} from "./model-definitions.js"; + +export { ZAI_DEFAULT_MODEL_REF }; + +const ZAI_DEFAULT_MODELS = [ + buildZaiModelDefinition({ id: "glm-5" }), + buildZaiModelDefinition({ id: "glm-5-turbo" }), + buildZaiModelDefinition({ id: "glm-4.7" }), + buildZaiModelDefinition({ id: "glm-4.7-flash" }), + buildZaiModelDefinition({ id: "glm-4.7-flashx" }), +]; + +export function applyZaiProviderConfig( + cfg: OpenClawConfig, + params?: { endpoint?: string; modelId?: string }, +): OpenClawConfig { + const modelId = params?.modelId?.trim() || ZAI_DEFAULT_MODEL_ID; + const modelRef = `zai/${modelId}`; + const existingProvider = cfg.models?.providers?.zai; + const models = { ...cfg.agents?.defaults?.models }; + models[modelRef] = { + ...models[modelRef], + alias: models[modelRef]?.alias ?? "GLM", + }; + + const existingBaseUrl = + typeof existingProvider?.baseUrl === "string" ? existingProvider.baseUrl.trim() : ""; + const baseUrl = params?.endpoint + ? resolveZaiBaseUrl(params.endpoint) + : existingBaseUrl || resolveZaiBaseUrl(); + + return applyProviderConfigWithModelCatalog(cfg, { + agentModels: models, + providerId: "zai", + api: "openai-completions", + baseUrl, + catalogModels: ZAI_DEFAULT_MODELS, + }); +} + +export function applyZaiConfig( + cfg: OpenClawConfig, + params?: { endpoint?: string; modelId?: string }, +): OpenClawConfig { + const modelId = params?.modelId?.trim() || ZAI_DEFAULT_MODEL_ID; + const modelRef = modelId === ZAI_DEFAULT_MODEL_ID ? ZAI_DEFAULT_MODEL_REF : `zai/${modelId}`; + return applyAgentDefaultModelPrimary(applyZaiProviderConfig(cfg, params), modelRef); +} diff --git a/extensions/zalo/src/actions.runtime.ts b/extensions/zalo/src/actions.runtime.ts new file mode 100644 index 00000000000..a9616ce64a5 --- /dev/null +++ b/extensions/zalo/src/actions.runtime.ts @@ -0,0 +1 @@ +export { sendMessageZalo } from "./send.js"; diff --git a/extensions/zalo/src/actions.ts b/extensions/zalo/src/actions.ts index 4f6108fa892..6f8572b01cd 100644 --- a/extensions/zalo/src/actions.ts +++ b/extensions/zalo/src/actions.ts @@ -5,7 +5,13 @@ import type { } from "openclaw/plugin-sdk/zalo"; import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk/zalo"; import { listEnabledZaloAccounts } from "./accounts.js"; -import { sendMessageZalo } from "./send.js"; + +let zaloActionsRuntimePromise: Promise | null = null; + +async function loadZaloActionsRuntime() { + zaloActionsRuntimePromise ??= import("./actions.runtime.js"); + return zaloActionsRuntimePromise; +} const providerId = "zalo"; @@ -35,6 +41,7 @@ export const zaloMessageActions: ChannelMessageActionAdapter = { }); const mediaUrl = readStringParam(params, "media", { trim: false }); + const { sendMessageZalo } = await loadZaloActionsRuntime(); const result = await sendMessageZalo(to ?? "", content ?? "", { accountId: accountId ?? undefined, mediaUrl: mediaUrl ?? undefined, diff --git a/extensions/zalo/src/channel.runtime.ts b/extensions/zalo/src/channel.runtime.ts new file mode 100644 index 00000000000..fc4488b5be8 --- /dev/null +++ b/extensions/zalo/src/channel.runtime.ts @@ -0,0 +1,91 @@ +import { createAccountStatusSink } from "openclaw/plugin-sdk/compat"; +import { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/zalo"; +import { probeZalo } from "./probe.js"; +import { resolveZaloProxyFetch } from "./proxy.js"; +import { normalizeSecretInputString } from "./secret-input.js"; +import { sendMessageZalo } from "./send.js"; + +export async function notifyZaloPairingApproval(params: { + cfg: import("openclaw/plugin-sdk/zalo").OpenClawConfig; + id: string; +}) { + const { resolveZaloAccount } = await import("./accounts.js"); + const account = resolveZaloAccount({ cfg: params.cfg }); + if (!account.token) { + throw new Error("Zalo token not configured"); + } + await sendMessageZalo(params.id, PAIRING_APPROVED_MESSAGE, { + token: account.token, + }); +} + +export async function sendZaloText( + params: Parameters[2] & { + to: string; + text: string; + }, +) { + return await sendMessageZalo(params.to, params.text, params); +} + +export async function probeZaloAccount(params: { + account: import("./accounts.js").ResolvedZaloAccount; + timeoutMs?: number; +}) { + return await probeZalo( + params.account.token, + params.timeoutMs, + resolveZaloProxyFetch(params.account.config.proxy), + ); +} + +export async function startZaloGatewayAccount( + ctx: Parameters< + NonNullable["startAccount"] + >[0], +) { + const account = ctx.account; + const token = account.token.trim(); + const mode = account.config.webhookUrl ? "webhook" : "polling"; + let zaloBotLabel = ""; + const fetcher = resolveZaloProxyFetch(account.config.proxy); + try { + const probe = await probeZalo(token, 2500, fetcher); + const name = probe.ok ? probe.bot?.name?.trim() : null; + if (name) { + zaloBotLabel = ` (${name})`; + } + if (!probe.ok) { + ctx.log?.warn?.( + `[${account.accountId}] Zalo probe failed before provider start (${String(probe.elapsedMs)}ms): ${probe.error}`, + ); + } + ctx.setStatus({ + accountId: account.accountId, + bot: probe.bot, + }); + } catch (err) { + ctx.log?.warn?.( + `[${account.accountId}] Zalo probe threw before provider start: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`, + ); + } + const statusSink = createAccountStatusSink({ + accountId: ctx.accountId, + setStatus: ctx.setStatus, + }); + ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel} mode=${mode}`); + const { monitorZaloProvider } = await import("./monitor.js"); + return monitorZaloProvider({ + token, + account, + config: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + useWebhook: Boolean(account.config.webhookUrl), + webhookUrl: account.config.webhookUrl, + webhookSecret: normalizeSecretInputString(account.config.webhookSecret), + webhookPath: account.config.webhookPath, + fetcher, + statusSink, + }); +} diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 32ceeeff110..ed735bbd1c7 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -3,7 +3,6 @@ import { buildOpenGroupPolicyRestrictSendersWarning, buildOpenGroupPolicyWarning, collectOpenProviderGroupPolicyWarnings, - createAccountStatusSink, mapAllowFromEntries, } from "openclaw/plugin-sdk/compat"; import type { @@ -22,8 +21,6 @@ import { formatAllowFromLowercase, listDirectoryUserEntriesFromAllowFrom, isNumericTargetId, - PAIRING_APPROVED_MESSAGE, - resolveOutboundMediaUrls, sendPayloadWithChunkedTextAndMedia, setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk/zalo"; @@ -35,10 +32,6 @@ import { } from "./accounts.js"; import { zaloMessageActions } from "./actions.js"; import { ZaloConfigSchema } from "./config-schema.js"; -import { probeZalo } from "./probe.js"; -import { resolveZaloProxyFetch } from "./proxy.js"; -import { normalizeSecretInputString } from "./secret-input.js"; -import { sendMessageZalo } from "./send.js"; import { zaloSetupAdapter } from "./setup-core.js"; import { zaloSetupWizard } from "./setup-surface.js"; import { collectZaloStatusIssues } from "./status-issues.js"; @@ -63,6 +56,13 @@ function normalizeZaloMessagingTarget(raw: string): string | undefined { return trimmed.replace(/^(zalo|zl):/i, ""); } +let zaloChannelRuntimePromise: Promise | null = null; + +async function loadZaloChannelRuntime() { + zaloChannelRuntimePromise ??= import("./channel.runtime.js"); + return zaloChannelRuntimePromise; +} + export const zaloPlugin: ChannelPlugin = { id: "zalo", meta, @@ -190,13 +190,8 @@ export const zaloPlugin: ChannelPlugin = { pairing: { idLabel: "zaloUserId", normalizeAllowEntry: (entry) => entry.replace(/^(zalo|zl):/i, ""), - notifyApproval: async ({ cfg, id }) => { - const account = resolveZaloAccount({ cfg: cfg }); - if (!account.token) { - throw new Error("Zalo token not configured"); - } - await sendMessageZalo(id, PAIRING_APPROVED_MESSAGE, { token: account.token }); - }, + notifyApproval: async (params) => + await (await loadZaloChannelRuntime()).notifyZaloPairingApproval(params), }, outbound: { deliveryMode: "direct", @@ -213,14 +208,22 @@ export const zaloPlugin: ChannelPlugin = { emptyResult: { channel: "zalo", messageId: "" }, }), sendText: async ({ to, text, accountId, cfg }) => { - const result = await sendMessageZalo(to, text, { + const result = await ( + await loadZaloChannelRuntime() + ).sendZaloText({ + to, + text, accountId: accountId ?? undefined, cfg: cfg, }); return buildChannelSendResult("zalo", result); }, sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => { - const result = await sendMessageZalo(to, text, { + const result = await ( + await loadZaloChannelRuntime() + ).sendZaloText({ + to, + text, accountId: accountId ?? undefined, mediaUrl, cfg: cfg, @@ -239,7 +242,7 @@ export const zaloPlugin: ChannelPlugin = { collectStatusIssues: collectZaloStatusIssues, buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot), probeAccount: async ({ account, timeoutMs }) => - probeZalo(account.token, timeoutMs, resolveZaloProxyFetch(account.config.proxy)), + await (await loadZaloChannelRuntime()).probeZaloAccount({ account, timeoutMs }), buildAccountSnapshot: ({ account, runtime }) => { const configured = Boolean(account.token?.trim()); const base = buildBaseAccountStatusSnapshot({ @@ -260,51 +263,7 @@ export const zaloPlugin: ChannelPlugin = { }, }, gateway: { - startAccount: async (ctx) => { - const account = ctx.account; - const token = account.token.trim(); - const mode = account.config.webhookUrl ? "webhook" : "polling"; - let zaloBotLabel = ""; - const fetcher = resolveZaloProxyFetch(account.config.proxy); - try { - const probe = await probeZalo(token, 2500, fetcher); - const name = probe.ok ? probe.bot?.name?.trim() : null; - if (name) { - zaloBotLabel = ` (${name})`; - } - if (!probe.ok) { - ctx.log?.warn?.( - `[${account.accountId}] Zalo probe failed before provider start (${String(probe.elapsedMs)}ms): ${probe.error}`, - ); - } - ctx.setStatus({ - accountId: account.accountId, - bot: probe.bot, - }); - } catch (err) { - ctx.log?.warn?.( - `[${account.accountId}] Zalo probe threw before provider start: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`, - ); - } - const statusSink = createAccountStatusSink({ - accountId: ctx.accountId, - setStatus: ctx.setStatus, - }); - ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel} mode=${mode}`); - const { monitorZaloProvider } = await import("./monitor.js"); - return monitorZaloProvider({ - token, - account, - config: ctx.cfg, - runtime: ctx.runtime, - abortSignal: ctx.abortSignal, - useWebhook: Boolean(account.config.webhookUrl), - webhookUrl: account.config.webhookUrl, - webhookSecret: normalizeSecretInputString(account.config.webhookSecret), - webhookPath: account.config.webhookPath, - fetcher, - statusSink, - }); - }, + startAccount: async (ctx) => + await (await loadZaloChannelRuntime()).startZaloGatewayAccount(ctx), }, }; diff --git a/extensions/zalo/src/setup-core.ts b/extensions/zalo/src/setup-core.ts index 6e194a41652..3e54c5a86dc 100644 --- a/extensions/zalo/src/setup-core.ts +++ b/extensions/zalo/src/setup-core.ts @@ -1,22 +1,10 @@ -import { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; const channel = "zalo" as const; -export const zaloSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), +export const zaloSetupAdapter = createPatchedAccountSetupAdapter({ + channelKey: channel, validateInput: ({ accountId, input }) => { if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { return "ZALO_BOT_TOKEN can only be used for the default account."; @@ -26,32 +14,12 @@ export const zaloSetupAdapter: ChannelSetupAdapter = { } return null; }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - const patch = input.useEnv + buildPatch: (input) => + input.useEnv ? {} : input.tokenFile ? { tokenFile: input.tokenFile } : input.token ? { botToken: input.token } - : {}; - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: channel, - accountId, - patch, - }); - }, -}; + : {}, +}); diff --git a/extensions/zalo/src/setup-surface.ts b/extensions/zalo/src/setup-surface.ts index 6ae6a78be0f..50e6761b35a 100644 --- a/extensions/zalo/src/setup-surface.ts +++ b/extensions/zalo/src/setup-surface.ts @@ -1,17 +1,18 @@ import { buildSingleChannelSecretPromptState, + DEFAULT_ACCOUNT_ID, + formatDocsLink, + hasConfiguredSecretInput, mergeAllowFromEntries, + normalizeAccountId, promptSingleChannelSecretInput, runSingleChannelSecretStep, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js"; -import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { SecretInput } from "../../../src/config/types.secrets.js"; -import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; + type ChannelSetupDmPolicy, + type ChannelSetupWizard, + type OpenClawConfig, + type SecretInput, +} from "openclaw/plugin-sdk/setup"; import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js"; import { zaloSetupAdapter } from "./setup-core.js"; diff --git a/extensions/zalouser/src/setup-core.ts b/extensions/zalouser/src/setup-core.ts index 45f412ed9f6..f3215a16469 100644 --- a/extensions/zalouser/src/setup-core.ts +++ b/extensions/zalouser/src/setup-core.ts @@ -1,42 +1,9 @@ -import { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { createPatchedAccountSetupAdapter } from "../../../src/channels/plugins/setup-helpers.js"; const channel = "zalouser" as const; -export const zalouserSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), +export const zalouserSetupAdapter = createPatchedAccountSetupAdapter({ + channelKey: channel, validateInput: () => null, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: channel, - accountId, - patch: {}, - }); - }, -}; + buildPatch: () => ({}), +}); diff --git a/extensions/zalouser/src/setup-surface.ts b/extensions/zalouser/src/setup-surface.ts index 74f940e5077..f51b55ff068 100644 --- a/extensions/zalouser/src/setup-surface.ts +++ b/extensions/zalouser/src/setup-surface.ts @@ -1,14 +1,15 @@ -import { patchScopedAccountConfig } from "../../../src/channels/plugins/setup-helpers.js"; import { + DEFAULT_ACCOUNT_ID, + formatDocsLink, + formatResolvedUnresolvedNote, mergeAllowFromEntries, + normalizeAccountId, + patchScopedAccountConfig, setTopLevelChannelDmPolicyWithAllowFrom, -} from "../../../src/channels/plugins/setup-wizard-helpers.js"; -import type { ChannelSetupDmPolicy } from "../../../src/channels/plugins/setup-wizard-types.js"; -import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { formatResolvedUnresolvedNote } from "../../../src/plugin-sdk/resolution-notes.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; -import { formatDocsLink } from "../../../src/terminal/links.js"; + type ChannelSetupDmPolicy, + type ChannelSetupWizard, + type OpenClawConfig, +} from "openclaw/plugin-sdk/setup"; import { listZalouserAccountIds, resolveDefaultZalouserAccountId, diff --git a/package.json b/package.json index f0904418919..95763eb8a0f 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,10 @@ "types": "./dist/plugin-sdk/routing.d.ts", "default": "./dist/plugin-sdk/routing.js" }, + "./plugin-sdk/setup": { + "types": "./dist/plugin-sdk/setup.d.ts", + "default": "./dist/plugin-sdk/setup.js" + }, "./plugin-sdk/telegram": { "types": "./dist/plugin-sdk/telegram.d.ts", "default": "./dist/plugin-sdk/telegram.js" @@ -226,10 +230,34 @@ "types": "./dist/plugin-sdk/account-id.d.ts", "default": "./dist/plugin-sdk/account-id.js" }, + "./plugin-sdk/allow-from": { + "types": "./dist/plugin-sdk/allow-from.d.ts", + "default": "./dist/plugin-sdk/allow-from.js" + }, + "./plugin-sdk/boolean-param": { + "types": "./dist/plugin-sdk/boolean-param.d.ts", + "default": "./dist/plugin-sdk/boolean-param.js" + }, + "./plugin-sdk/channel-config-helpers": { + "types": "./dist/plugin-sdk/channel-config-helpers.d.ts", + "default": "./dist/plugin-sdk/channel-config-helpers.js" + }, + "./plugin-sdk/group-access": { + "types": "./dist/plugin-sdk/group-access.d.ts", + "default": "./dist/plugin-sdk/group-access.js" + }, + "./plugin-sdk/json-store": { + "types": "./dist/plugin-sdk/json-store.d.ts", + "default": "./dist/plugin-sdk/json-store.js" + }, "./plugin-sdk/keyed-async-queue": { "types": "./dist/plugin-sdk/keyed-async-queue.d.ts", "default": "./dist/plugin-sdk/keyed-async-queue.js" }, + "./plugin-sdk/request-url": { + "types": "./dist/plugin-sdk/request-url.d.ts", + "default": "./dist/plugin-sdk/request-url.js" + }, "./cli-entry": "./openclaw.mjs" }, "scripts": { @@ -401,7 +429,7 @@ "dotenv": "^17.3.1", "express": "^5.2.1", "file-type": "^21.3.2", - "gaxios": "^7.1.3", + "gaxios": "7.1.3", "grammy": "^1.41.1", "hono": "4.12.7", "https-proxy-agent": "^8.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90ebda912b0..e05340832b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,7 +126,7 @@ importers: specifier: 21.3.2 version: 21.3.2 gaxios: - specifier: ^7.1.3 + specifier: 7.1.3 version: 7.1.3 grammy: specifier: ^1.41.1 diff --git a/scripts/docker/cleanup-smoke/Dockerfile b/scripts/docker/cleanup-smoke/Dockerfile index 07a2334aa41..f214ffbabf4 100644 --- a/scripts/docker/cleanup-smoke/Dockerfile +++ b/scripts/docker/cleanup-smoke/Dockerfile @@ -2,6 +2,8 @@ FROM node:24-bookworm-slim@sha256:b4687aef2571c632a1953695ce4d61d6462a7eda471fe6e272eebf0418f276ba +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + RUN --mount=type=cache,id=openclaw-cleanup-smoke-apt-cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,id=openclaw-cleanup-smoke-apt-lists,target=/var/lib/apt,sharing=locked \ apt-get update \ diff --git a/scripts/docs-i18n/util_test.go b/scripts/docs-i18n/util_test.go index 77b5ca82a73..30dcb14a07d 100644 --- a/scripts/docs-i18n/util_test.go +++ b/scripts/docs-i18n/util_test.go @@ -31,6 +31,15 @@ func TestDocsPiModelUsesProviderDefault(t *testing.T) { } } +func TestDocsPiModelKeepsOpenAIDefaultAtGPT54(t *testing.T) { + t.Setenv(envDocsI18nProvider, "openai") + t.Setenv(envDocsI18nModel, "") + + if got := docsPiModel(); got != defaultOpenAIModel { + t.Fatalf("expected OpenAI default model %q, got %q", defaultOpenAIModel, got) + } +} + func TestDocsPiModelPrefersExplicitOverride(t *testing.T) { t.Setenv(envDocsI18nProvider, "openai") t.Setenv(envDocsI18nModel, "gpt-5.2") diff --git a/scripts/e2e/Dockerfile b/scripts/e2e/Dockerfile index 4669e762c4a..2c23c9ef1b8 100644 --- a/scripts/e2e/Dockerfile +++ b/scripts/e2e/Dockerfile @@ -20,7 +20,7 @@ WORKDIR /app COPY --chown=appuser:appuser package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY --chown=appuser:appuser ui/package.json ./ui/package.json -COPY --chown=appuser:appuser extensions/memory-core/package.json ./extensions/memory-core/package.json +COPY --chown=appuser:appuser extensions ./extensions COPY --chown=appuser:appuser patches ./patches RUN --mount=type=cache,id=openclaw-pnpm-store,target=/home/appuser/.local/share/pnpm/store,sharing=locked \ @@ -39,6 +39,9 @@ COPY --chown=appuser:appuser apps/shared/OpenClawKit/Sources/OpenClawKit/Resourc COPY --chown=appuser:appuser apps/shared/OpenClawKit/Tools/CanvasA2UI ./apps/shared/OpenClawKit/Tools/CanvasA2UI RUN pnpm build -RUN pnpm ui:build +# Onboard Docker E2E does not exercise the Control UI itself; it only needs the +# asset-existence check to pass so configure/onboard can continue. +RUN mkdir -p dist/control-ui \ + && printf '%s\n' 'OpenClaw Control UI' > dist/control-ui/index.html CMD ["bash"] diff --git a/scripts/e2e/doctor-install-switch-docker.sh b/scripts/e2e/doctor-install-switch-docker.sh index ca91619ef5a..4ca742a362b 100755 --- a/scripts/e2e/doctor-install-switch-docker.sh +++ b/scripts/e2e/doctor-install-switch-docker.sh @@ -75,7 +75,7 @@ LOGINCTL # Install the npm-global variant from the local /app source. # `npm pack` can emit script output; keep only the tarball name. - pkg_tgz="$(npm pack --silent /app | tail -n 1 | tr -d '\r')" + pkg_tgz="$(npm pack --ignore-scripts --silent /app | tail -n 1 | tr -d '\r')" if [ ! -f "/app/$pkg_tgz" ]; then echo "npm pack failed (expected /app/$pkg_tgz)" exit 1 diff --git a/scripts/e2e/onboard-docker.sh b/scripts/e2e/onboard-docker.sh index 49b08dcc2ca..70cbd6f0c51 100755 --- a/scripts/e2e/onboard-docker.sh +++ b/scripts/e2e/onboard-docker.sh @@ -74,8 +74,14 @@ TRASH try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); } // Clack/script output can include lots of control sequences; keep a larger tail and strip ANSI more robustly. if (text.length > 120000) text = text.slice(-120000); - const stripAnsi = (value) => + const normalizeScriptOutput = (value) => value + // util-linux script can emit each byte on its own CRLF-delimited line. + // Collapse those first so ANSI/control stripping works on real sequences. + .replace(/\\r?\\n/g, \"\") + .replace(/\\r/g, \"\"); + const stripAnsi = (value) => + normalizeScriptOutput(value) // OSC: ESC ] ... BEL or ESC \\ .replace(/\\x1b\\][^\\x07]*(?:\\x07|\\x1b\\\\)/g, \"\") // CSI: ESC [ ... cmd @@ -269,23 +275,24 @@ TRASH } send_channels_flow() { - # Configure channels via configure wizard. - # Prompts are interactive; notes are not. Use conservative delays to stay in sync. - # Where will the Gateway run? -> Local (default) - send $'"'"'\r'"'"' 1.2 - # Channels mode -> Configure/link (default) - send $'"'"'\r'"'"' 1.5 + # Configure channels via configure wizard. Sync on prompt text so + # keystrokes do not drift into the wrong screen when render timing changes. + wait_for_log "Where will the Gateway run?" 120 + send $'"'"'\r'"'"' 0.6 + wait_for_log "Channels" 120 + send $'"'"'\r'"'"' 0.6 # Select a channel -> Finished (last option; clack wraps on Up) - send $'"'"'\e[A\r'"'"' 2.0 + wait_for_log "Select a channel" 120 + send $'"'"'\e[A\r'"'"' 0.8 # Keep stdin open until wizard exits. - send "" 2.5 + send "" 2.0 } send_skills_flow() { - # configure --section skills still runs the configure wizard; the first prompt is gateway location. - # Avoid log-based synchronization here; clack output can fragment ANSI sequences and break matching. - send $'"'"'\r'"'"' 3.0 - wait_for_log "Configure skills now?" 120 true || true + # configure --section skills still runs the configure wizard. + wait_for_log "Where will the Gateway run?" 120 + send $'"'"'\r'"'"' 0.6 + wait_for_log "Configure skills now?" 120 send $'"'"'n\r'"'"' 0.8 send "" 2.0 } diff --git a/scripts/e2e/parallels-linux-smoke.sh b/scripts/e2e/parallels-linux-smoke.sh index a3e3f96bb56..f857dddcf55 100644 --- a/scripts/e2e/parallels-linux-smoke.sh +++ b/scripts/e2e/parallels-linux-smoke.sh @@ -14,6 +14,9 @@ INSTALL_VERSION="" TARGET_PACKAGE_SPEC="" JSON_OUTPUT=0 KEEP_SERVER=0 +SNAPSHOT_ID="" +SNAPSHOT_STATE="" +SNAPSHOT_NAME="" MAIN_TGZ_DIR="$(mktemp -d)" MAIN_TGZ_PATH="" @@ -163,7 +166,7 @@ esac OPENAI_API_KEY_VALUE="${!OPENAI_API_KEY_ENV:-}" [[ -n "$OPENAI_API_KEY_VALUE" ]] || die "$OPENAI_API_KEY_ENV is required" -resolve_snapshot_id() { +resolve_snapshot_info() { local json hint json="$(prlctl snapshot-list "$VM_NAME" --json)" hint="$SNAPSHOT_HINT" @@ -171,28 +174,54 @@ resolve_snapshot_id() { import difflib import json import os +import re import sys payload = json.loads(os.environ["SNAPSHOT_JSON"]) hint = os.environ["SNAPSHOT_HINT"].strip().lower() best_id = None +best_meta = None best_score = -1.0 + +def aliases(name: str) -> list[str]: + values = [name] + for pattern in ( + r"^(.*)-poweroff$", + r"^(.*)-poweroff-\d{4}-\d{2}-\d{2}$", + ): + match = re.match(pattern, name) + if match: + values.append(match.group(1)) + return values + for snapshot_id, meta in payload.items(): name = str(meta.get("name", "")).strip() lowered = name.lower() score = 0.0 - if lowered == hint: - score = 10.0 - elif hint and hint in lowered: - score = 5.0 + len(hint) / max(len(lowered), 1) - else: - score = difflib.SequenceMatcher(None, hint, lowered).ratio() + for alias in aliases(lowered): + if alias == hint: + score = max(score, 10.0) + elif hint and hint in alias: + score = max(score, 5.0 + len(hint) / max(len(alias), 1)) + else: + score = max(score, difflib.SequenceMatcher(None, hint, alias).ratio()) + if str(meta.get("state", "")).lower() == "poweroff": + score += 0.5 if score > best_score: best_score = score best_id = snapshot_id + best_meta = meta if not best_id: sys.exit("no snapshot matched") -print(best_id) +print( + "\t".join( + [ + best_id, + str(best_meta.get("state", "")).strip(), + str(best_meta.get("name", "")).strip(), + ] + ) +) PY } @@ -251,10 +280,42 @@ guest_exec() { prlctl exec "$VM_NAME" "$@" } +wait_for_vm_status() { + local expected="$1" + local deadline status + deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S)) + while (( SECONDS < deadline )); do + status="$(prlctl status "$VM_NAME" 2>/dev/null || true)" + if [[ "$status" == *" $expected" ]]; then + return 0 + fi + sleep 1 + done + return 1 +} + +wait_for_guest_ready() { + local deadline + deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S)) + while (( SECONDS < deadline )); do + if guest_exec /bin/true >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + return 1 +} + restore_snapshot() { local snapshot_id="$1" say "Restore snapshot $SNAPSHOT_HINT ($snapshot_id)" prlctl snapshot-switch "$VM_NAME" --id "$snapshot_id" >/dev/null + if [[ "$SNAPSHOT_STATE" == "poweroff" ]]; then + wait_for_vm_status "stopped" || die "restored poweroff snapshot did not reach stopped state in $VM_NAME" + say "Start restored poweroff snapshot $SNAPSHOT_NAME" + prlctl start "$VM_NAME" >/dev/null + fi + wait_for_guest_ready || die "guest did not become ready in $VM_NAME" } bootstrap_guest() { @@ -585,13 +646,16 @@ run_upgrade_lane() { UPGRADE_AGENT_STATUS="pass" } -SNAPSHOT_ID="$(resolve_snapshot_id)" +IFS=$'\t' read -r SNAPSHOT_ID SNAPSHOT_STATE SNAPSHOT_NAME <<<"$(resolve_snapshot_info)" +[[ -n "$SNAPSHOT_ID" ]] || die "failed to resolve snapshot id" +[[ -n "$SNAPSHOT_NAME" ]] || SNAPSHOT_NAME="$SNAPSHOT_HINT" LATEST_VERSION="$(resolve_latest_version)" HOST_IP="$(resolve_host_ip)" HOST_PORT="$(resolve_host_port)" say "VM: $VM_NAME" say "Snapshot hint: $SNAPSHOT_HINT" +say "Resolved snapshot: $SNAPSHOT_NAME [$SNAPSHOT_STATE]" say "Latest npm version: $LATEST_VERSION" say "Current head: $(git rev-parse --short HEAD)" say "Run logs: $RUN_DIR" diff --git a/scripts/e2e/parallels-macos-smoke.sh b/scripts/e2e/parallels-macos-smoke.sh index fcdb940161f..5c95235f798 100644 --- a/scripts/e2e/parallels-macos-smoke.sh +++ b/scripts/e2e/parallels-macos-smoke.sh @@ -21,6 +21,9 @@ DISCORD_TOKEN_ENV="" DISCORD_TOKEN_VALUE="" DISCORD_GUILD_ID="" DISCORD_CHANNEL_ID="" +SNAPSHOT_ID="" +SNAPSHOT_STATE="" +SNAPSHOT_NAME="" GUEST_OPENCLAW_BIN="/opt/homebrew/bin/openclaw" GUEST_OPENCLAW_ENTRY="/opt/homebrew/lib/node_modules/openclaw/openclaw.mjs" GUEST_NODE_BIN="/opt/homebrew/bin/node" @@ -291,7 +294,7 @@ cleanup_discord_smoke_messages() { discord_delete_message_id_file "$RUN_DIR/upgrade.discord-host-message-id" } -resolve_snapshot_id() { +resolve_snapshot_info() { local json hint json="$(prlctl snapshot-list "$VM_NAME" --json)" hint="$SNAPSHOT_HINT" @@ -299,28 +302,54 @@ resolve_snapshot_id() { import difflib import json import os +import re import sys payload = json.loads(os.environ["SNAPSHOT_JSON"]) hint = os.environ["SNAPSHOT_HINT"].strip().lower() best_id = None +best_meta = None best_score = -1.0 + +def aliases(name: str) -> list[str]: + values = [name] + for pattern in ( + r"^(.*)-poweroff$", + r"^(.*)-poweroff-\d{4}-\d{2}-\d{2}$", + ): + match = re.match(pattern, name) + if match: + values.append(match.group(1)) + return values + for snapshot_id, meta in payload.items(): name = str(meta.get("name", "")).strip() lowered = name.lower() score = 0.0 - if lowered == hint: - score = 10.0 - elif hint and hint in lowered: - score = 5.0 + len(hint) / max(len(lowered), 1) - else: - score = difflib.SequenceMatcher(None, hint, lowered).ratio() + for alias in aliases(lowered): + if alias == hint: + score = max(score, 10.0) + elif hint and hint in alias: + score = max(score, 5.0 + len(hint) / max(len(alias), 1)) + else: + score = max(score, difflib.SequenceMatcher(None, hint, alias).ratio()) + if str(meta.get("state", "")).lower() == "poweroff": + score += 0.5 if score > best_score: best_score = score best_id = snapshot_id + best_meta = meta if not best_id: sys.exit("no snapshot matched") -print(best_id) +print( + "\t".join( + [ + best_id, + str(best_meta.get("state", "")).strip(), + str(best_meta.get("name", "")).strip(), + ] + ) +) PY } @@ -377,6 +406,20 @@ resolve_host_port() { printf '%s\n' "$HOST_PORT" } +wait_for_vm_status() { + local expected="$1" + local deadline status + deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S)) + while (( SECONDS < deadline )); do + status="$(prlctl status "$VM_NAME" 2>/dev/null || true)" + if [[ "$status" == *" $expected" ]]; then + return 0 + fi + sleep 1 + done + return 1 +} + wait_for_current_user() { local deadline deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S)) @@ -458,6 +501,11 @@ restore_snapshot() { local snapshot_id="$1" say "Restore snapshot $SNAPSHOT_HINT ($snapshot_id)" prlctl snapshot-switch "$VM_NAME" --id "$snapshot_id" >/dev/null + if [[ "$SNAPSHOT_STATE" == "poweroff" ]]; then + wait_for_vm_status "stopped" || die "restored poweroff snapshot did not reach stopped state in $VM_NAME" + say "Start restored poweroff snapshot $SNAPSHOT_NAME" + prlctl start "$VM_NAME" >/dev/null + fi wait_for_current_user || die "desktop user did not become ready in $VM_NAME" } @@ -1017,13 +1065,16 @@ FRESH_MAIN_STATUS="skip" UPGRADE_STATUS="skip" UPGRADE_PRECHECK_STATUS="skip" -SNAPSHOT_ID="$(resolve_snapshot_id)" +IFS=$'\t' read -r SNAPSHOT_ID SNAPSHOT_STATE SNAPSHOT_NAME <<<"$(resolve_snapshot_info)" +[[ -n "$SNAPSHOT_ID" ]] || die "failed to resolve snapshot id" +[[ -n "$SNAPSHOT_NAME" ]] || SNAPSHOT_NAME="$SNAPSHOT_HINT" LATEST_VERSION="$(resolve_latest_version)" HOST_IP="$(resolve_host_ip)" HOST_PORT="$(resolve_host_port)" say "VM: $VM_NAME" say "Snapshot hint: $SNAPSHOT_HINT" +say "Resolved snapshot: $SNAPSHOT_NAME [$SNAPSHOT_STATE]" say "Latest npm version: $LATEST_VERSION" say "Current head: $(git rev-parse --short HEAD)" if discord_smoke_enabled; then diff --git a/scripts/e2e/parallels-windows-smoke.sh b/scripts/e2e/parallels-windows-smoke.sh index e7016d22062..615dae29fe1 100644 --- a/scripts/e2e/parallels-windows-smoke.sh +++ b/scripts/e2e/parallels-windows-smoke.sh @@ -15,6 +15,9 @@ TARGET_PACKAGE_SPEC="" JSON_OUTPUT=0 KEEP_SERVER=0 CHECK_LATEST_REF=1 +SNAPSHOT_ID="" +SNAPSHOT_STATE="" +SNAPSHOT_NAME="" MAIN_TGZ_DIR="$(mktemp -d)" MAIN_TGZ_PATH="" @@ -194,7 +197,7 @@ ps_array_literal() { printf '@(%s)' "$joined" } -resolve_snapshot_id() { +resolve_snapshot_info() { local json hint json="$(prlctl snapshot-list "$VM_NAME" --json)" hint="$SNAPSHOT_HINT" @@ -202,28 +205,54 @@ resolve_snapshot_id() { import difflib import json import os +import re import sys payload = json.loads(os.environ["SNAPSHOT_JSON"]) hint = os.environ["SNAPSHOT_HINT"].strip().lower() best_id = None +best_meta = None best_score = -1.0 + +def aliases(name: str) -> list[str]: + values = [name] + for pattern in ( + r"^(.*)-poweroff$", + r"^(.*)-poweroff-\d{4}-\d{2}-\d{2}$", + ): + match = re.match(pattern, name) + if match: + values.append(match.group(1)) + return values + for snapshot_id, meta in payload.items(): name = str(meta.get("name", "")).strip() lowered = name.lower() score = 0.0 - if lowered == hint: - score = 10.0 - elif hint and hint in lowered: - score = 5.0 + len(hint) / max(len(lowered), 1) - else: - score = difflib.SequenceMatcher(None, hint, lowered).ratio() + for alias in aliases(lowered): + if alias == hint: + score = max(score, 10.0) + elif hint and hint in alias: + score = max(score, 5.0 + len(hint) / max(len(alias), 1)) + else: + score = max(score, difflib.SequenceMatcher(None, hint, alias).ratio()) + if str(meta.get("state", "")).lower() == "poweroff": + score += 0.5 if score > best_score: best_score = score best_id = snapshot_id + best_meta = meta if not best_id: sys.exit("no snapshot matched") -print(best_id) +print( + "\t".join( + [ + best_id, + str(best_meta.get("state", "")).strip(), + str(best_meta.get("name", "")).strip(), + ] + ) +) PY } @@ -338,12 +367,31 @@ restore_snapshot() { local snapshot_id="$1" say "Restore snapshot $SNAPSHOT_HINT ($snapshot_id)" prlctl snapshot-switch "$VM_NAME" --id "$snapshot_id" >/dev/null + if [[ "$SNAPSHOT_STATE" == "poweroff" ]]; then + wait_for_vm_status "stopped" || die "restored poweroff snapshot did not reach stopped state in $VM_NAME" + say "Start restored poweroff snapshot $SNAPSHOT_NAME" + prlctl start "$VM_NAME" >/dev/null + fi } verify_windows_user_ready() { guest_exec cmd.exe /d /s /c "echo ready" } +wait_for_vm_status() { + local expected="$1" + local deadline status + deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S)) + while (( SECONDS < deadline )); do + status="$(prlctl status "$VM_NAME" 2>/dev/null || true)" + if [[ "$status" == *" $expected" ]]; then + return 0 + fi + sleep 1 + done + return 1 +} + wait_for_guest_ready() { local deadline deadline=$((SECONDS + TIMEOUT_SNAPSHOT_S)) @@ -830,13 +878,16 @@ run_upgrade_lane() { UPGRADE_AGENT_STATUS="pass" } -SNAPSHOT_ID="$(resolve_snapshot_id)" +IFS=$'\t' read -r SNAPSHOT_ID SNAPSHOT_STATE SNAPSHOT_NAME <<<"$(resolve_snapshot_info)" +[[ -n "$SNAPSHOT_ID" ]] || die "failed to resolve snapshot id" +[[ -n "$SNAPSHOT_NAME" ]] || SNAPSHOT_NAME="$SNAPSHOT_HINT" LATEST_VERSION="$(resolve_latest_version)" HOST_IP="$(resolve_host_ip)" HOST_PORT="$(resolve_host_port)" say "VM: $VM_NAME" say "Snapshot hint: $SNAPSHOT_HINT" +say "Resolved snapshot: $SNAPSHOT_NAME [$SNAPSHOT_STATE]" say "Latest npm version: $LATEST_VERSION" say "Current head: $(git rev-parse --short HEAD)" say "Run logs: $RUN_DIR" diff --git a/scripts/e2e/plugins-docker.sh b/scripts/e2e/plugins-docker.sh index 587840ec93a..632d6924099 100755 --- a/scripts/e2e/plugins-docker.sh +++ b/scripts/e2e/plugins-docker.sh @@ -8,7 +8,7 @@ echo "Building Docker image..." docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" echo "Running plugins Docker E2E..." -docker run --rm -i "$IMAGE_NAME" bash -s <<'EOF' +docker run --rm -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 -i "$IMAGE_NAME" bash -s <<'EOF' set -euo pipefail if [ -f dist/index.mjs ]; then diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index a6de3f4e24e..f99be019a69 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -7,6 +7,7 @@ "sandbox", "self-hosted-provider-setup", "routing", + "setup", "telegram", "discord", "slack", @@ -46,5 +47,11 @@ "zalo", "zalouser", "account-id", - "keyed-async-queue" + "allow-from", + "boolean-param", + "channel-config-helpers", + "group-access", + "json-store", + "keyed-async-queue", + "request-url" ] diff --git a/scripts/test-live-gateway-models-docker.sh b/scripts/test-live-gateway-models-docker.sh index f40e064910b..a3e1036171f 100755 --- a/scripts/test-live-gateway-models-docker.sh +++ b/scripts/test-live-gateway-models-docker.sh @@ -17,13 +17,20 @@ EXTERNAL_AUTH_MOUNTS=() for auth_dir in .claude .codex .minimax .qwen; do host_path="$HOME/$auth_dir" if [[ -d "$host_path" ]]; then - EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/home/node/"$auth_dir":ro) + EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/host-auth/"$auth_dir":ro) fi done read -r -d '' LIVE_TEST_CMD <<'EOF' || true set -euo pipefail [ -f "$HOME/.profile" ] && source "$HOME/.profile" || true +for auth_dir in .claude .codex .minimax .qwen; do + if [ -d "/host-auth/$auth_dir" ]; then + mkdir -p "$HOME/$auth_dir" + cp -R "/host-auth/$auth_dir/." "$HOME/$auth_dir" + chmod -R u+rwX "$HOME/$auth_dir" || true + fi +done tmp_dir="$(mktemp -d)" cleanup() { rm -rf "$tmp_dir" diff --git a/scripts/test-live-models-docker.sh b/scripts/test-live-models-docker.sh index 52257cd3230..c1cec5b2740 100755 --- a/scripts/test-live-models-docker.sh +++ b/scripts/test-live-models-docker.sh @@ -17,13 +17,20 @@ EXTERNAL_AUTH_MOUNTS=() for auth_dir in .claude .codex .minimax .qwen; do host_path="$HOME/$auth_dir" if [[ -d "$host_path" ]]; then - EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/home/node/"$auth_dir":ro) + EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/host-auth/"$auth_dir":ro) fi done read -r -d '' LIVE_TEST_CMD <<'EOF' || true set -euo pipefail [ -f "$HOME/.profile" ] && source "$HOME/.profile" || true +for auth_dir in .claude .codex .minimax .qwen; do + if [ -d "/host-auth/$auth_dir" ]; then + mkdir -p "$HOME/$auth_dir" + cp -R "/host-auth/$auth_dir/." "$HOME/$auth_dir" + chmod -R u+rwX "$HOME/$auth_dir" || true + fi +done tmp_dir="$(mktemp -d)" cleanup() { rm -rf "$tmp_dir" @@ -57,6 +64,9 @@ docker run --rm -t \ -e OPENCLAW_LIVE_MAX_MODELS="${OPENCLAW_LIVE_MAX_MODELS:-${CLAWDBOT_LIVE_MAX_MODELS:-48}}" \ -e OPENCLAW_LIVE_MODEL_TIMEOUT_MS="${OPENCLAW_LIVE_MODEL_TIMEOUT_MS:-${CLAWDBOT_LIVE_MODEL_TIMEOUT_MS:-}}" \ -e OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS="${OPENCLAW_LIVE_REQUIRE_PROFILE_KEYS:-${CLAWDBOT_LIVE_REQUIRE_PROFILE_KEYS:-}}" \ + -e OPENCLAW_LIVE_GATEWAY_MODELS="${OPENCLAW_LIVE_GATEWAY_MODELS:-${CLAWDBOT_LIVE_GATEWAY_MODELS:-}}" \ + -e OPENCLAW_LIVE_GATEWAY_PROVIDERS="${OPENCLAW_LIVE_GATEWAY_PROVIDERS:-${CLAWDBOT_LIVE_GATEWAY_PROVIDERS:-}}" \ + -e OPENCLAW_LIVE_GATEWAY_MAX_MODELS="${OPENCLAW_LIVE_GATEWAY_MAX_MODELS:-${CLAWDBOT_LIVE_GATEWAY_MAX_MODELS:-}}" \ -v "$ROOT_DIR":/src:ro \ -v "$CONFIG_DIR":/home/node/.openclaw \ -v "$WORKSPACE_DIR":/home/node/.openclaw/workspace \ diff --git a/src/agents/auth-profiles.external-cli-sync.test.ts b/src/agents/auth-profiles.external-cli-sync.test.ts index 303b85b72d2..eae0fab70af 100644 --- a/src/agents/auth-profiles.external-cli-sync.test.ts +++ b/src/agents/auth-profiles.external-cli-sync.test.ts @@ -51,4 +51,40 @@ describe("syncExternalCliCredentials", () => { }); expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeUndefined(); }); + + it("refreshes stored Codex expiry from external CLI even when the cached profile looks fresh", () => { + const staleExpiry = Date.now() + 30 * 60_000; + const freshExpiry = Date.now() + 5 * 24 * 60 * 60_000; + mocks.readCodexCliCredentialsCached.mockReturnValue({ + type: "oauth", + provider: "openai-codex", + access: "new-access-token", + refresh: "new-refresh-token", + expires: freshExpiry, + accountId: "acct_456", + }); + + const store: AuthProfileStore = { + version: 1, + profiles: { + [OPENAI_CODEX_DEFAULT_PROFILE_ID]: { + type: "oauth", + provider: "openai-codex", + access: "old-access-token", + refresh: "old-refresh-token", + expires: staleExpiry, + accountId: "acct_456", + }, + }, + }; + + const mutated = syncExternalCliCredentials(store); + + expect(mutated).toBe(true); + expect(store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({ + access: "new-access-token", + refresh: "new-refresh-token", + expires: freshExpiry, + }); + }); }); diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index 7e490c97c94..ff43b586b48 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -4,13 +4,12 @@ import { readMiniMaxCliCredentialsCached, } from "../cli-credentials.js"; import { - EXTERNAL_CLI_NEAR_EXPIRY_MS, EXTERNAL_CLI_SYNC_TTL_MS, QWEN_CLI_PROFILE_ID, MINIMAX_CLI_PROFILE_ID, log, } from "./constants.js"; -import type { AuthProfileCredential, AuthProfileStore, OAuthCredential } from "./types.js"; +import type { AuthProfileStore, OAuthCredential } from "./types.js"; const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default"; @@ -37,62 +36,33 @@ function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCr ); } -function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: number): boolean { - if (!cred) { - return false; - } - if (cred.type !== "oauth" && cred.type !== "token") { - return false; - } - if ( - cred.provider !== "qwen-portal" && - cred.provider !== "minimax-portal" && - cred.provider !== "openai-codex" - ) { - return false; - } - if (typeof cred.expires !== "number") { - return true; - } - return cred.expires > now + EXTERNAL_CLI_NEAR_EXPIRY_MS; -} - /** Sync external CLI credentials into the store for a given provider. */ function syncExternalCliCredentialsForProvider( store: AuthProfileStore, profileId: string, provider: string, readCredentials: () => OAuthCredential | null, - now: number, options: ExternalCliSyncOptions, ): boolean { const existing = store.profiles[profileId]; - const shouldSync = - !existing || existing.provider !== provider || !isExternalProfileFresh(existing, now); - const creds = shouldSync ? readCredentials() : null; + const creds = readCredentials(); if (!creds) { return false; } const existingOAuth = existing?.type === "oauth" ? existing : undefined; - const shouldUpdate = - !existingOAuth || - existingOAuth.provider !== provider || - existingOAuth.expires <= now || - creds.expires > existingOAuth.expires; - - if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, creds)) { - store.profiles[profileId] = creds; - if (options.log !== false) { - log.info(`synced ${provider} credentials from external cli`, { - profileId, - expires: new Date(creds.expires).toISOString(), - }); - } - return true; + if (shallowEqualOAuthCredentials(existingOAuth, creds)) { + return false; } - return false; + store.profiles[profileId] = creds; + if (options.log !== false) { + log.info(`synced ${provider} credentials from external cli`, { + profileId, + expires: new Date(creds.expires).toISOString(), + }); + } + return true; } /** @@ -106,46 +76,24 @@ export function syncExternalCliCredentials( options: ExternalCliSyncOptions = {}, ): boolean { let mutated = false; - const now = Date.now(); - // Sync from Qwen Code CLI - const existingQwen = store.profiles[QWEN_CLI_PROFILE_ID]; - const shouldSyncQwen = - !existingQwen || - existingQwen.provider !== "qwen-portal" || - !isExternalProfileFresh(existingQwen, now); - const qwenCreds = shouldSyncQwen - ? readQwenCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }) - : null; - if (qwenCreds) { - const existing = store.profiles[QWEN_CLI_PROFILE_ID]; - const existingOAuth = existing?.type === "oauth" ? existing : undefined; - const shouldUpdate = - !existingOAuth || - existingOAuth.provider !== "qwen-portal" || - existingOAuth.expires <= now || - qwenCreds.expires > existingOAuth.expires; - - if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, qwenCreds)) { - store.profiles[QWEN_CLI_PROFILE_ID] = qwenCreds; - mutated = true; - if (options.log !== false) { - log.info("synced qwen credentials from qwen cli", { - profileId: QWEN_CLI_PROFILE_ID, - expires: new Date(qwenCreds.expires).toISOString(), - }); - } - } + if ( + syncExternalCliCredentialsForProvider( + store, + QWEN_CLI_PROFILE_ID, + "qwen-portal", + () => readQwenCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), + options, + ) + ) { + mutated = true; } - - // Sync from MiniMax Portal CLI if ( syncExternalCliCredentialsForProvider( store, MINIMAX_CLI_PROFILE_ID, "minimax-portal", () => readMiniMaxCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), - now, options, ) ) { @@ -157,7 +105,6 @@ export function syncExternalCliCredentials( OPENAI_CODEX_DEFAULT_PROFILE_ID, "openai-codex", () => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), - now, options, ) ) { diff --git a/src/agents/cli-credentials.test.ts b/src/agents/cli-credentials.test.ts index fcfaf21450d..53be1581b13 100644 --- a/src/agents/cli-credentials.test.ts +++ b/src/agents/cli-credentials.test.ts @@ -46,6 +46,12 @@ async function readCachedClaudeCliCredentials(allowKeychainPrompt: boolean) { }); } +function createJwtWithExp(expSeconds: number): string { + const encode = (value: Record) => + Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${encode({ alg: "RS256", typ: "JWT" })}.${encode({ exp: expSeconds })}.signature`; +} + describe("cli credentials", () => { beforeAll(async () => { ({ @@ -229,6 +235,7 @@ describe("cli credentials", () => { it("reads Codex credentials from keychain when available", async () => { const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-")); process.env.CODEX_HOME = tempHome; + const expSeconds = Math.floor(Date.parse("2026-03-23T00:48:49Z") / 1000); const accountHash = "cli|"; @@ -238,7 +245,7 @@ describe("cli credentials", () => { expect(cmd).toContain(accountHash); return JSON.stringify({ tokens: { - access_token: "keychain-access", + access_token: createJwtWithExp(expSeconds), refresh_token: "keychain-refresh", }, last_refresh: "2026-01-01T00:00:00Z", @@ -248,15 +255,17 @@ describe("cli credentials", () => { const creds = readCodexCliCredentials({ platform: "darwin", execSync: execSyncMock }); expect(creds).toMatchObject({ - access: "keychain-access", + access: createJwtWithExp(expSeconds), refresh: "keychain-refresh", provider: "openai-codex", + expires: expSeconds * 1000, }); }); it("falls back to Codex auth.json when keychain is unavailable", async () => { const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-")); process.env.CODEX_HOME = tempHome; + const expSeconds = Math.floor(Date.parse("2026-03-24T12:34:56Z") / 1000); execSyncMock.mockImplementation(() => { throw new Error("not found"); }); @@ -267,7 +276,7 @@ describe("cli credentials", () => { authPath, JSON.stringify({ tokens: { - access_token: "file-access", + access_token: createJwtWithExp(expSeconds), refresh_token: "file-refresh", }, }), @@ -277,9 +286,10 @@ describe("cli credentials", () => { const creds = readCodexCliCredentials({ execSync: execSyncMock }); expect(creds).toMatchObject({ - access: "file-access", + access: createJwtWithExp(expSeconds), refresh: "file-refresh", provider: "openai-codex", + expires: expSeconds * 1000, }); }); }); diff --git a/src/agents/cli-credentials.ts b/src/agents/cli-credentials.ts index 0d6d7c28c84..8ded765346a 100644 --- a/src/agents/cli-credentials.ts +++ b/src/agents/cli-credentials.ts @@ -153,6 +153,22 @@ function computeCodexKeychainAccount(codexHome: string) { return `cli|${hash.slice(0, 16)}`; } +function decodeJwtExpiryMs(token: string): number | null { + const parts = token.split("."); + if (parts.length < 2) { + return null; + } + try { + const payloadRaw = Buffer.from(parts[1], "base64url").toString("utf8"); + const payload = JSON.parse(payloadRaw) as { exp?: unknown }; + return typeof payload.exp === "number" && Number.isFinite(payload.exp) && payload.exp > 0 + ? payload.exp * 1000 + : null; + } catch { + return null; + } +} + function readCodexKeychainCredentials(options?: { platform?: NodeJS.Platform; execSync?: ExecSyncFn; @@ -193,9 +209,10 @@ function readCodexKeychainCredentials(options?: { typeof lastRefreshRaw === "string" || typeof lastRefreshRaw === "number" ? new Date(lastRefreshRaw).getTime() : Date.now(); - const expires = Number.isFinite(lastRefresh) + const fallbackExpiry = Number.isFinite(lastRefresh) ? lastRefresh + 60 * 60 * 1000 : Date.now() + 60 * 60 * 1000; + const expires = decodeJwtExpiryMs(accessToken) ?? fallbackExpiry; const accountId = typeof tokens?.account_id === "string" ? tokens.account_id : undefined; log.info("read codex credentials from keychain", { @@ -483,13 +500,14 @@ export function readCodexCliCredentials(options?: { return null; } - let expires: number; + let fallbackExpiry: number; try { const stat = fs.statSync(authPath); - expires = stat.mtimeMs + 60 * 60 * 1000; + fallbackExpiry = stat.mtimeMs + 60 * 60 * 1000; } catch { - expires = Date.now() + 60 * 60 * 1000; + fallbackExpiry = Date.now() + 60 * 60 * 1000; } + const expires = decodeJwtExpiryMs(accessToken) ?? fallbackExpiry; return { type: "oauth", diff --git a/src/agents/cli-runner/bundle-mcp.ts b/src/agents/cli-runner/bundle-mcp.ts index 60e6149519c..96aeb867869 100644 --- a/src/agents/cli-runner/bundle-mcp.ts +++ b/src/agents/cli-runner/bundle-mcp.ts @@ -5,43 +5,20 @@ import type { OpenClawConfig } from "../../config/config.js"; import { applyMergePatch } from "../../config/merge-patch.js"; import type { CliBackendConfig } from "../../config/types.js"; import { + extractMcpServerMap, loadEnabledBundleMcpConfig, type BundleMcpConfig, - type BundleMcpServerConfig, } from "../../plugins/bundle-mcp.js"; -import { isRecord } from "../../utils.js"; type PreparedCliBundleMcpConfig = { backend: CliBackendConfig; cleanup?: () => Promise; }; -function extractServerMap(raw: unknown): Record { - if (!isRecord(raw)) { - return {}; - } - const nested = isRecord(raw.mcpServers) - ? raw.mcpServers - : isRecord(raw.servers) - ? raw.servers - : raw; - if (!isRecord(nested)) { - return {}; - } - const result: Record = {}; - for (const [serverName, serverRaw] of Object.entries(nested)) { - if (!isRecord(serverRaw)) { - continue; - } - result[serverName] = { ...serverRaw }; - } - return result; -} - async function readExternalMcpConfig(configPath: string): Promise { try { const raw = JSON.parse(await fs.readFile(configPath, "utf-8")) as unknown; - return { mcpServers: extractServerMap(raw) }; + return { mcpServers: extractMcpServerMap(raw) }; } catch { return { mcpServers: {} }; } diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 7fa8832e0e7..e7d583d106f 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -112,7 +112,8 @@ describe("model-selection", () => { expect(normalizeProviderId("z-ai")).toBe("zai"); expect(normalizeProviderId("OpenCode-Zen")).toBe("opencode"); expect(normalizeProviderId("qwen")).toBe("qwen-portal"); - expect(normalizeProviderId("kimi-code")).toBe("kimi-coding"); + expect(normalizeProviderId("kimi-code")).toBe("kimi"); + expect(normalizeProviderId("kimi-coding")).toBe("kimi"); expect(normalizeProviderId("bedrock")).toBe("amazon-bedrock"); expect(normalizeProviderId("aws-bedrock")).toBe("amazon-bedrock"); expect(normalizeProviderId("amazon-bedrock")).toBe("amazon-bedrock"); diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 7cdc52e641c..acc29a32bf9 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -1,4 +1,4 @@ -import { resolveThinkingDefaultForModel } from "../auto-reply/thinking.js"; +import { resolveThinkingDefaultForModel } from "../auto-reply/thinking.shared.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveAgentModelFallbackValues, diff --git a/src/agents/models-config.merge.test.ts b/src/agents/models-config.merge.test.ts index b84d4e363d6..17d2f9033fe 100644 --- a/src/agents/models-config.merge.test.ts +++ b/src/agents/models-config.merge.test.ts @@ -74,8 +74,8 @@ describe("models-config merge helpers", () => { headers: { "User-Agent": "claude-code/0.1.0" }, models: [ { - id: "k2p5", - name: "Kimi for Coding", + id: "kimi-code", + name: "Kimi Code", input: ["text", "image"], reasoning: true, }, @@ -87,8 +87,8 @@ describe("models-config merge helpers", () => { headers: { "X-Kimi-Tenant": "tenant-a" }, models: [ { - id: "k2p5", - name: "Kimi for Coding", + id: "kimi-code", + name: "Kimi Code", input: ["text", "image"], reasoning: true, }, diff --git a/src/agents/models-config.providers.kimi-coding.test.ts b/src/agents/models-config.providers.kimi-coding.test.ts index 91ca62f34e2..3da4986961a 100644 --- a/src/agents/models-config.providers.kimi-coding.test.ts +++ b/src/agents/models-config.providers.kimi-coding.test.ts @@ -6,46 +6,47 @@ import { captureEnv } from "../test-utils/env.js"; import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js"; import { buildKimiCodingProvider } from "./models-config.providers.js"; -describe("kimi-coding implicit provider (#22409)", () => { - it("should include kimi-coding when KIMI_API_KEY is configured", async () => { +describe("Kimi implicit provider (#22409)", () => { + it("should include Kimi when KIMI_API_KEY is configured", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); const envSnapshot = captureEnv(["KIMI_API_KEY"]); process.env.KIMI_API_KEY = "test-key"; // pragma: allowlist secret try { const providers = await resolveImplicitProvidersForTest({ agentDir }); - expect(providers?.["kimi-coding"]).toBeDefined(); - expect(providers?.["kimi-coding"]?.api).toBe("anthropic-messages"); - expect(providers?.["kimi-coding"]?.baseUrl).toBe("https://api.kimi.com/coding/"); + expect(providers?.kimi).toBeDefined(); + expect(providers?.kimi?.api).toBe("anthropic-messages"); + expect(providers?.kimi?.baseUrl).toBe("https://api.kimi.com/coding/"); } finally { envSnapshot.restore(); } }); - it("should build kimi-coding provider with anthropic-messages API", () => { + it("should build Kimi provider with anthropic-messages API", () => { const provider = buildKimiCodingProvider(); expect(provider.api).toBe("anthropic-messages"); expect(provider.baseUrl).toBe("https://api.kimi.com/coding/"); expect(provider.headers).toEqual({ "User-Agent": "claude-code/0.1.0" }); expect(provider.models).toBeDefined(); expect(provider.models.length).toBeGreaterThan(0); - expect(provider.models[0].id).toBe("k2p5"); + expect(provider.models[0].id).toBe("kimi-code"); + expect(provider.models.some((model) => model.id === "k2p5")).toBe(true); }); - it("should not include kimi-coding when no API key is configured", async () => { + it("should not include Kimi when no API key is configured", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); const envSnapshot = captureEnv(["KIMI_API_KEY"]); delete process.env.KIMI_API_KEY; try { const providers = await resolveImplicitProvidersForTest({ agentDir }); - expect(providers?.["kimi-coding"]).toBeUndefined(); + expect(providers?.kimi).toBeUndefined(); } finally { envSnapshot.restore(); } }); - it("uses explicit kimi-coding baseUrl when provided", async () => { + it("uses explicit legacy kimi-coding baseUrl when provided", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); const envSnapshot = captureEnv(["KIMI_API_KEY"]); process.env.KIMI_API_KEY = "test-key"; @@ -61,13 +62,13 @@ describe("kimi-coding implicit provider (#22409)", () => { }, }, }); - expect(providers?.["kimi-coding"]?.baseUrl).toBe("https://kimi.example.test/coding/"); + expect(providers?.kimi?.baseUrl).toBe("https://kimi.example.test/coding/"); } finally { envSnapshot.restore(); } }); - it("merges explicit kimi-coding headers on top of the built-in user agent", async () => { + it("merges explicit legacy kimi-coding headers on top of the built-in user agent", async () => { const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-")); const envSnapshot = captureEnv(["KIMI_API_KEY"]); process.env.KIMI_API_KEY = "test-key"; @@ -87,7 +88,7 @@ describe("kimi-coding implicit provider (#22409)", () => { }, }, }); - expect(providers?.["kimi-coding"]?.headers).toEqual({ + expect(providers?.kimi?.headers).toEqual({ "User-Agent": "custom-kimi-client/1.0", "X-Kimi-Tenant": "tenant-a", }); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 264cb402b47..19ce478b2f4 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -1,3 +1,8 @@ +import { + QIANFAN_BASE_URL, + QIANFAN_DEFAULT_MODEL_ID, +} from "../../extensions/qianfan/provider-catalog.js"; +import { XIAOMI_DEFAULT_MODEL_ID } from "../../extensions/xiaomi/provider-catalog.js"; import type { OpenClawConfig } from "../config/config.js"; import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js"; import { isRecord } from "../utils.js"; @@ -6,24 +11,23 @@ import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles import { discoverBedrockModels } from "./bedrock-discovery.js"; import { normalizeGoogleModelId } from "./model-id-normalization.js"; import { resolveOllamaApiBase } from "./models-config.providers.discovery.js"; -import { - QIANFAN_BASE_URL, - QIANFAN_DEFAULT_MODEL_ID, - XIAOMI_DEFAULT_MODEL_ID, -} from "./models-config.providers.static.js"; +export { buildKimiCodingProvider } from "../../extensions/kimi-coding/provider-catalog.js"; +export { buildKilocodeProvider } from "../../extensions/kilocode/provider-catalog.js"; export { - buildKimiCodingProvider, - buildKilocodeProvider, - buildNvidiaProvider, - buildModelStudioProvider, - buildQianfanProvider, - buildXiaomiProvider, MODELSTUDIO_BASE_URL, MODELSTUDIO_DEFAULT_MODEL_ID, + buildModelStudioProvider, +} from "../../extensions/modelstudio/provider-catalog.js"; +export { buildNvidiaProvider } from "../../extensions/nvidia/provider-catalog.js"; +export { QIANFAN_BASE_URL, QIANFAN_DEFAULT_MODEL_ID, + buildQianfanProvider, +} from "../../extensions/qianfan/provider-catalog.js"; +export { XIAOMI_DEFAULT_MODEL_ID, -} from "./models-config.providers.static.js"; + buildXiaomiProvider, +} from "../../extensions/xiaomi/provider-catalog.js"; import { groupPluginDiscoveryProvidersByOrder, normalizePluginDiscoveryResult, diff --git a/src/agents/models.profiles.live.test.ts b/src/agents/models.profiles.live.test.ts index 515d2b48ce6..87cbbb6a203 100644 --- a/src/agents/models.profiles.live.test.ts +++ b/src/agents/models.profiles.live.test.ts @@ -117,6 +117,10 @@ function isChatGPTUsageLimitErrorMessage(raw: string): boolean { return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in"); } +function isRefreshTokenReused(raw: string): boolean { + return /refresh_token_reused/i.test(raw); +} + function isInstructionsRequiredError(raw: string): boolean { return /instructions are required/i.test(raw); } @@ -643,6 +647,15 @@ describeLive("live models (profile keys)", () => { logProgress(`${progressLabel}: skip (rate limit)`); break; } + if ( + allowNotFoundSkip && + model.provider === "openai-codex" && + isRefreshTokenReused(message) + ) { + skipped.push({ model: id, reason: message }); + logProgress(`${progressLabel}: skip (codex refresh token reused)`); + break; + } if ( allowNotFoundSkip && model.provider === "openai-codex" && diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index c4790e37dba..25395ea4827 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -908,7 +908,7 @@ describe("applyExtraParamsToAgent", () => { }); }); - it("does not rewrite tool schema for kimi-coding (native Anthropic format)", () => { + it("does not rewrite tool schema for Kimi (native Anthropic format)", () => { const payloads: Record[] = []; const baseStreamFn: StreamFn = (_model, _context, options) => { const payload: Record = { @@ -931,12 +931,12 @@ describe("applyExtraParamsToAgent", () => { }; const agent = { streamFn: baseStreamFn }; - applyExtraParamsToAgent(agent, undefined, "kimi-coding", "k2p5", undefined, "low"); + applyExtraParamsToAgent(agent, undefined, "kimi", "kimi-code", undefined, "low"); const model = { api: "anthropic-messages", - provider: "kimi-coding", - id: "k2p5", + provider: "kimi", + id: "kimi-code", baseUrl: "https://api.kimi.com/coding/", } as Model<"anthropic-messages">; const context: Context = { messages: [] }; diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index 47da838cc6a..a66cb697cb4 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -1129,13 +1129,13 @@ describe("resolveModel", () => { it("lets provider config override registry-found kimi user agent headers", () => { mockDiscoveredModel({ - provider: "kimi-coding", - modelId: "k2p5", + provider: "kimi", + modelId: "kimi-code", templateModel: { ...buildForwardCompatTemplate({ - id: "k2p5", - name: "Kimi for Coding", - provider: "kimi-coding", + id: "kimi-code", + name: "Kimi Code", + provider: "kimi", api: "anthropic-messages", baseUrl: "https://api.kimi.com/coding/", }), @@ -1146,7 +1146,7 @@ describe("resolveModel", () => { const cfg = { models: { providers: { - "kimi-coding": { + kimi: { headers: { "User-Agent": "custom-kimi-client/1.0", "X-Kimi-Tenant": "tenant-a", @@ -1156,8 +1156,9 @@ describe("resolveModel", () => { }, } as unknown as OpenClawConfig; - const result = resolveModel("kimi-coding", "k2p5", "/tmp/agent", cfg); + const result = resolveModel("kimi", "kimi-code", "/tmp/agent", cfg); expect(result.error).toBeUndefined(); + expect(result.model?.id).toBe("kimi-for-coding"); expect((result.model as unknown as { headers?: Record }).headers).toEqual({ "User-Agent": "custom-kimi-client/1.0", "X-Kimi-Tenant": "tenant-a", diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 7acde5472f3..614b7c582f0 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -1010,7 +1010,7 @@ function wrapStreamRepairMalformedToolCallArguments( if (!loggedRepairIndices.has(event.contentIndex)) { loggedRepairIndices.add(event.contentIndex); log.warn( - `repairing kimi-coding tool call arguments after ${repair.trailingSuffix.length} trailing chars`, + `repairing Kimi tool call arguments after ${repair.trailingSuffix.length} trailing chars`, ); } } else { @@ -1065,7 +1065,7 @@ export function wrapStreamFnRepairMalformedToolCallArguments(baseFn: StreamFn): } function shouldRepairMalformedAnthropicToolCallArguments(provider?: string): boolean { - return normalizeProviderId(provider ?? "") === "kimi-coding"; + return normalizeProviderId(provider ?? "") === "kimi"; } // --------------------------------------------------------------------------- diff --git a/src/agents/provider-capabilities.test.ts b/src/agents/provider-capabilities.test.ts index 699cba9ffe5..fa3b12b8d4d 100644 --- a/src/agents/provider-capabilities.test.ts +++ b/src/agents/provider-capabilities.test.ts @@ -30,7 +30,7 @@ const resolveProviderCapabilitiesWithPluginMock = vi.fn((params: { provider: str geminiThoughtSignatureSanitization: true, geminiThoughtSignatureModelHints: ["gemini"], }; - case "kimi-coding": + case "kimi": return { preserveAnthropicThinkingSignatures: false, }; @@ -84,9 +84,7 @@ describe("resolveProviderCapabilities", () => { }); it("normalizes kimi aliases to the same capability set", () => { - expect(resolveProviderCapabilities("kimi-coding")).toEqual( - resolveProviderCapabilities("kimi-code"), - ); + expect(resolveProviderCapabilities("kimi")).toEqual(resolveProviderCapabilities("kimi-code")); expect(resolveProviderCapabilities("kimi-code")).toEqual({ anthropicToolSchemaMode: "native", anthropicToolChoiceMode: "native", @@ -131,7 +129,7 @@ describe("resolveProviderCapabilities", () => { }); it("treats kimi aliases as native anthropic tool payload providers", () => { - expect(requiresOpenAiCompatibleAnthropicToolPayload("kimi-coding")).toBe(false); + expect(requiresOpenAiCompatibleAnthropicToolPayload("kimi")).toBe(false); expect(requiresOpenAiCompatibleAnthropicToolPayload("kimi-code")).toBe(false); expect(requiresOpenAiCompatibleAnthropicToolPayload("anthropic")).toBe(false); }); diff --git a/src/agents/provider-id.ts b/src/agents/provider-id.ts index 354817e8a96..bd82c3c3edd 100644 --- a/src/agents/provider-id.ts +++ b/src/agents/provider-id.ts @@ -12,8 +12,8 @@ export function normalizeProviderId(provider: string): string { if (normalized === "qwen") { return "qwen-portal"; } - if (normalized === "kimi-code") { - return "kimi-coding"; + if (normalized === "kimi" || normalized === "kimi-code" || normalized === "kimi-coding") { + return "kimi"; } if (normalized === "bedrock" || normalized === "aws-bedrock") { return "amazon-bedrock"; diff --git a/src/agents/transcript-policy.test.ts b/src/agents/transcript-policy.test.ts index 3534bfad92b..7409e7a4b12 100644 --- a/src/agents/transcript-policy.test.ts +++ b/src/agents/transcript-policy.test.ts @@ -114,16 +114,16 @@ describe("resolveTranscriptPolicy", () => { preserveSignatures: false, }, { - title: "kimi-coding provider", - provider: "kimi-coding", - modelId: "k2p5", + title: "Kimi provider", + provider: "kimi", + modelId: "kimi-code", modelApi: "anthropic-messages" as const, preserveSignatures: false, }, { title: "kimi-code alias", provider: "kimi-code", - modelId: "k2p5", + modelId: "kimi-code", modelApi: "anthropic-messages" as const, preserveSignatures: false, }, diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index 5b90b34d4d5..e20084ed923 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -6,7 +6,7 @@ vi.mock("../../agents/model-catalog.js", () => ({ loadModelCatalog: vi.fn(async () => [ { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus 4.5" }, { provider: "inferencer", id: "deepseek-v3-4bit-mlx", name: "DeepSeek V3" }, - { provider: "kimi-coding", id: "k2p5", name: "Kimi K2.5" }, + { provider: "kimi", id: "kimi-code", name: "Kimi Code" }, { provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" }, { provider: "openai", id: "gpt-4o", name: "GPT-4o" }, ]), @@ -222,12 +222,12 @@ describe("createModelSelectionState respects session model override", () => { const state = await resolveState( makeEntry({ providerOverride: "kimi-coding", - modelOverride: "k2p5", + modelOverride: "kimi-code", }), ); - expect(state.provider).toBe("kimi-coding"); - expect(state.model).toBe("k2p5"); + expect(state.provider).toBe("kimi"); + expect(state.model).toBe("kimi-code"); }); it("falls back to default when no modelOverride is set", async () => { @@ -241,8 +241,8 @@ describe("createModelSelectionState respects session model override", () => { // From issue #14783: stored override should beat last-used fallback model. const state = await resolveState( makeEntry({ - model: "k2p5", - modelProvider: "kimi-coding", + model: "kimi-code", + modelProvider: "kimi", contextTokens: 262_000, providerOverride: "anthropic", modelOverride: "claude-opus-4-5", diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 5bf5f5c2cec..4c5dd7be889 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -92,6 +92,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => })), providers: [], speechProviders: [], + mediaUnderstandingProviders: [], webSearchProviders: [], gatewayHandlers: {}, httpRoutes: [], diff --git a/src/auto-reply/thinking.shared.ts b/src/auto-reply/thinking.shared.ts index bbde5b90ce5..7487928eac3 100644 --- a/src/auto-reply/thinking.shared.ts +++ b/src/auto-reply/thinking.shared.ts @@ -12,6 +12,8 @@ export type ThinkingCatalogEntry = { }; const BASE_THINKING_LEVELS: ThinkLevel[] = ["off", "minimal", "low", "medium", "high", "adaptive"]; +const ANTHROPIC_CLAUDE_46_MODEL_RE = /^claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i; +const AMAZON_BEDROCK_CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i; export function normalizeProviderId(provider?: string | null): string { if (!provider) { @@ -101,6 +103,14 @@ export function resolveThinkingDefaultForModel(params: { model: string; catalog?: ThinkingCatalogEntry[]; }): ThinkLevel { + const normalizedProvider = normalizeProviderId(params.provider); + const modelId = params.model.trim(); + if (normalizedProvider === "anthropic" && ANTHROPIC_CLAUDE_46_MODEL_RE.test(modelId)) { + return "adaptive"; + } + if (normalizedProvider === "amazon-bedrock" && AMAZON_BEDROCK_CLAUDE_46_MODEL_RE.test(modelId)) { + return "adaptive"; + } const candidate = params.catalog?.find( (entry) => entry.provider === params.provider && entry.id === params.model, ); diff --git a/src/channels/plugins/contracts/suites.ts b/src/channels/plugins/contracts/suites.ts index a45abc3ff0b..461be379261 100644 --- a/src/channels/plugins/contracts/suites.ts +++ b/src/channels/plugins/contracts/suites.ts @@ -402,7 +402,6 @@ export function installChannelDirectoryContractSuite(params: { if (params.invokeLookups === false) { return; } - const self = await directory?.self?.({ cfg: {} as OpenClawConfig, accountId: "default", diff --git a/src/channels/plugins/setup-helpers.test.ts b/src/channels/plugins/setup-helpers.test.ts index 10069c0b9f4..2040271f540 100644 --- a/src/channels/plugins/setup-helpers.test.ts +++ b/src/channels/plugins/setup-helpers.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; -import { applySetupAccountConfigPatch } from "./setup-helpers.js"; +import { + applySetupAccountConfigPatch, + createPatchedAccountSetupAdapter, + prepareScopedSetupConfig, +} from "./setup-helpers.js"; function asConfig(value: unknown): OpenClawConfig { return value as OpenClawConfig; @@ -79,3 +83,121 @@ describe("applySetupAccountConfigPatch", () => { }); }); }); + +describe("createPatchedAccountSetupAdapter", () => { + it("stores default-account patch at channel root", () => { + const adapter = createPatchedAccountSetupAdapter({ + channelKey: "zalo", + buildPatch: (input) => ({ botToken: input.token }), + }); + + const next = adapter.applyAccountConfig({ + cfg: asConfig({ channels: { zalo: { enabled: false } } }), + accountId: DEFAULT_ACCOUNT_ID, + input: { name: "Personal", token: "tok" }, + }); + + expect(next.channels?.zalo).toMatchObject({ + enabled: true, + name: "Personal", + botToken: "tok", + }); + }); + + it("migrates base name into the default account before patching a named account", () => { + const adapter = createPatchedAccountSetupAdapter({ + channelKey: "zalo", + buildPatch: (input) => ({ botToken: input.token }), + }); + + const next = adapter.applyAccountConfig({ + cfg: asConfig({ + channels: { + zalo: { + name: "Personal", + accounts: { + work: { botToken: "old" }, + }, + }, + }, + }), + accountId: "Work Team", + input: { name: "Work", token: "new" }, + }); + + expect(next.channels?.zalo).toMatchObject({ + accounts: { + default: { name: "Personal" }, + work: { botToken: "old" }, + "work-team": { enabled: true, name: "Work", botToken: "new" }, + }, + }); + expect(next.channels?.zalo).not.toHaveProperty("name"); + }); + + it("can store the default account in accounts.default", () => { + const adapter = createPatchedAccountSetupAdapter({ + channelKey: "whatsapp", + alwaysUseAccounts: true, + buildPatch: (input) => ({ authDir: input.authDir }), + }); + + const next = adapter.applyAccountConfig({ + cfg: asConfig({ channels: { whatsapp: {} } }), + accountId: DEFAULT_ACCOUNT_ID, + input: { name: "Phone", authDir: "/tmp/auth" }, + }); + + expect(next.channels?.whatsapp).toMatchObject({ + accounts: { + default: { + enabled: true, + name: "Phone", + authDir: "/tmp/auth", + }, + }, + }); + expect(next.channels?.whatsapp).not.toHaveProperty("enabled"); + expect(next.channels?.whatsapp).not.toHaveProperty("authDir"); + }); +}); + +describe("prepareScopedSetupConfig", () => { + it("stores the name and migrates it for named accounts when requested", () => { + const next = prepareScopedSetupConfig({ + cfg: asConfig({ + channels: { + bluebubbles: { + name: "Personal", + }, + }, + }), + channelKey: "bluebubbles", + accountId: "Work Team", + name: "Work", + migrateBaseName: true, + }); + + expect(next.channels?.bluebubbles).toMatchObject({ + accounts: { + default: { name: "Personal" }, + "work-team": { name: "Work" }, + }, + }); + expect(next.channels?.bluebubbles).not.toHaveProperty("name"); + }); + + it("keeps the base shape for the default account when migration is disabled", () => { + const next = prepareScopedSetupConfig({ + cfg: asConfig({ channels: { irc: { enabled: true } } }), + channelKey: "irc", + accountId: DEFAULT_ACCOUNT_ID, + name: "Libera", + }); + + expect(next.channels?.irc).toMatchObject({ + enabled: true, + name: "Libera", + }); + }); +}); diff --git a/src/channels/plugins/setup-helpers.ts b/src/channels/plugins/setup-helpers.ts index d592a56e475..cfbd58a8d4e 100644 --- a/src/channels/plugins/setup-helpers.ts +++ b/src/channels/plugins/setup-helpers.ts @@ -1,5 +1,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; +import type { ChannelSetupAdapter } from "./types.adapters.js"; +import type { ChannelSetupInput } from "./types.core.js"; type ChannelSectionBase = { name?: string; @@ -120,6 +122,31 @@ export function migrateBaseNameToDefaultAccount(params: { } as OpenClawConfig; } +export function prepareScopedSetupConfig(params: { + cfg: OpenClawConfig; + channelKey: string; + accountId: string; + name?: string; + alwaysUseAccounts?: boolean; + migrateBaseName?: boolean; +}): OpenClawConfig { + const namedConfig = applyAccountNameToChannelSection({ + cfg: params.cfg, + channelKey: params.channelKey, + accountId: params.accountId, + name: params.name, + alwaysUseAccounts: params.alwaysUseAccounts, + }); + if (!params.migrateBaseName || normalizeAccountId(params.accountId) === DEFAULT_ACCOUNT_ID) { + return namedConfig; + } + return migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: params.channelKey, + alwaysUseAccounts: params.alwaysUseAccounts, + }); +} + export function applySetupAccountConfigPatch(params: { cfg: OpenClawConfig; channelKey: string; @@ -134,6 +161,49 @@ export function applySetupAccountConfigPatch(params: { }); } +export function createPatchedAccountSetupAdapter(params: { + channelKey: string; + alwaysUseAccounts?: boolean; + ensureChannelEnabled?: boolean; + ensureAccountEnabled?: boolean; + validateInput?: ChannelSetupAdapter["validateInput"]; + buildPatch: (input: ChannelSetupInput) => Record; +}): ChannelSetupAdapter { + return { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + prepareScopedSetupConfig({ + cfg, + channelKey: params.channelKey, + accountId, + name, + alwaysUseAccounts: params.alwaysUseAccounts, + }), + validateInput: params.validateInput, + applyAccountConfig: ({ cfg, accountId, input }) => { + const next = prepareScopedSetupConfig({ + cfg, + channelKey: params.channelKey, + accountId, + name: input.name, + alwaysUseAccounts: params.alwaysUseAccounts, + migrateBaseName: !params.alwaysUseAccounts, + }); + const patch = params.buildPatch(input); + return patchScopedAccountConfig({ + cfg: next, + channelKey: params.channelKey, + accountId, + patch, + accountPatch: patch, + ensureChannelEnabled: params.ensureChannelEnabled ?? !params.alwaysUseAccounts, + ensureAccountEnabled: params.ensureAccountEnabled ?? true, + scopeDefaultToAccounts: params.alwaysUseAccounts, + }); + }, + }; +} + export function patchScopedAccountConfig(params: { cfg: OpenClawConfig; channelKey: string; @@ -142,6 +212,7 @@ export function patchScopedAccountConfig(params: { accountPatch?: Record; ensureChannelEnabled?: boolean; ensureAccountEnabled?: boolean; + scopeDefaultToAccounts?: boolean; }): OpenClawConfig { const accountId = normalizeAccountId(params.accountId); const channels = params.cfg.channels as Record | undefined; @@ -156,7 +227,7 @@ export function patchScopedAccountConfig(params: { const ensureAccountEnabled = params.ensureAccountEnabled ?? ensureChannelEnabled; const patch = params.patch; const accountPatch = params.accountPatch ?? patch; - if (accountId === DEFAULT_ACCOUNT_ID) { + if (accountId === DEFAULT_ACCOUNT_ID && !params.scopeDefaultToAccounts) { return { ...params.cfg, channels: { diff --git a/src/channels/plugins/slack.actions.ts b/src/channels/plugins/slack.actions.ts index 7e74af7058d..df53d1ff0e0 100644 --- a/src/channels/plugins/slack.actions.ts +++ b/src/channels/plugins/slack.actions.ts @@ -6,9 +6,20 @@ import { resolveSlackChannelId, } from "../../plugin-sdk-internal/slack.js"; import { handleSlackMessageAction } from "../../plugin-sdk/slack-message-actions.js"; -import type { ChannelMessageActionAdapter } from "./types.js"; +import type { ChannelMessageActionAdapter, ChannelMessageActionContext } from "./types.js"; -export function createSlackActions(providerId: string): ChannelMessageActionAdapter { +type SlackActionAdapterOptions = { + includeReadThreadId?: boolean; + invoke?: ( + ctx: ChannelMessageActionContext, + ) => Parameters[0]["invoke"]; + skipNormalizeChannelId?: boolean; +}; + +export function createSlackActions( + providerId: string, + options?: SlackActionAdapterOptions, +): ChannelMessageActionAdapter { return { listActions: ({ cfg }) => listSlackMessageActions(cfg), getCapabilities: ({ cfg }) => { @@ -23,16 +34,19 @@ export function createSlackActions(providerId: string): ChannelMessageActionAdap }, extractToolSend: ({ args }) => extractSlackToolSend(args), handleAction: async (ctx) => { - return await handleSlackMessageAction({ - providerId, - ctx, - normalizeChannelId: resolveSlackChannelId, - includeReadThreadId: true, - invoke: async (action, cfg, toolContext) => + const invoke = + options?.invoke?.(ctx) ?? + (async (action, cfg, toolContext) => await handleSlackAction(action, cfg, { ...(toolContext as SlackActionContext | undefined), mediaLocalRoots: ctx.mediaLocalRoots, - }), + })); + return await handleSlackMessageAction({ + providerId, + ctx, + normalizeChannelId: options?.skipNormalizeChannelId ? undefined : resolveSlackChannelId, + includeReadThreadId: options?.includeReadThreadId ?? true, + invoke, }); }, }; diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index f6ca9d29332..038c672ee14 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -520,9 +520,9 @@ describe("applyAuthChoice", () => { { tokenProvider: "KIMI-CODING", token: "sk-kimi-token-provider-test", - profileId: "kimi-coding:default", - provider: "kimi-coding", - expectedModelPrefix: "kimi-coding/", + profileId: "kimi:default", + provider: "kimi", + expectedModelPrefix: "kimi/", }, { tokenProvider: " GOOGLE ", @@ -600,9 +600,9 @@ describe("applyAuthChoice", () => { { authChoice: "kimi-code-api-key", tokenProvider: "kimi-code", - profileId: "kimi-coding:default", - provider: "kimi-coding", - modelPrefix: "kimi-coding/", + profileId: "kimi:default", + provider: "kimi", + modelPrefix: "kimi/", }, { authChoice: "xiaomi-api-key", diff --git a/src/commands/auth-credentials.ts b/src/commands/auth-credentials.ts new file mode 100644 index 00000000000..4ee69149a92 --- /dev/null +++ b/src/commands/auth-credentials.ts @@ -0,0 +1,189 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { OAuthCredentials } from "@mariozechner/pi-ai"; +import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; +import { upsertAuthProfile } from "../agents/auth-profiles.js"; +import { resolveStateDir } from "../config/paths.js"; +import { + coerceSecretRef, + DEFAULT_SECRET_PROVIDER_ALIAS, + type SecretInput, + type SecretRef, +} from "../config/types.secrets.js"; +import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js"; +import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; +import type { SecretInputMode } from "./onboard-types.js"; + +const ENV_REF_PATTERN = /^\$\{([A-Z][A-Z0-9_]*)\}$/; + +const resolveAuthAgentDir = (agentDir?: string) => agentDir ?? resolveOpenClawAgentDir(); + +export type ApiKeyStorageOptions = { + secretInputMode?: SecretInputMode; +}; + +export type WriteOAuthCredentialsOptions = { + syncSiblingAgents?: boolean; +}; + +function buildEnvSecretRef(id: string): SecretRef { + return { source: "env", provider: DEFAULT_SECRET_PROVIDER_ALIAS, id }; +} + +function parseEnvSecretRef(value: string): SecretRef | null { + const match = ENV_REF_PATTERN.exec(value); + if (!match) { + return null; + } + return buildEnvSecretRef(match[1]); +} + +function resolveProviderDefaultEnvSecretRef(provider: string): SecretRef { + const envVars = PROVIDER_ENV_VARS[provider]; + const envVar = envVars?.find((candidate) => candidate.trim().length > 0); + if (!envVar) { + throw new Error( + `Provider "${provider}" does not have a default env var mapping for secret-input-mode=ref.`, + ); + } + return buildEnvSecretRef(envVar); +} + +function resolveApiKeySecretInput( + provider: string, + input: SecretInput, + options?: ApiKeyStorageOptions, +): SecretInput { + const coercedRef = coerceSecretRef(input); + if (coercedRef) { + return coercedRef; + } + const normalized = normalizeSecretInput(input); + const inlineEnvRef = parseEnvSecretRef(normalized); + if (inlineEnvRef) { + return inlineEnvRef; + } + if (options?.secretInputMode === "ref") { + return resolveProviderDefaultEnvSecretRef(provider); + } + return normalized; +} + +export function buildApiKeyCredential( + provider: string, + input: SecretInput, + metadata?: Record, + options?: ApiKeyStorageOptions, +): { + type: "api_key"; + provider: string; + key?: string; + keyRef?: SecretRef; + metadata?: Record; +} { + const secretInput = resolveApiKeySecretInput(provider, input, options); + if (typeof secretInput === "string") { + return { + type: "api_key", + provider, + key: secretInput, + ...(metadata ? { metadata } : {}), + }; + } + return { + type: "api_key", + provider, + keyRef: secretInput, + ...(metadata ? { metadata } : {}), + }; +} + +/** Resolve real path, returning null if the target doesn't exist. */ +function safeRealpathSync(dir: string): string | null { + try { + return fs.realpathSync(path.resolve(dir)); + } catch { + return null; + } +} + +function resolveSiblingAgentDirs(primaryAgentDir: string): string[] { + const normalized = path.resolve(primaryAgentDir); + const parentOfAgent = path.dirname(normalized); + const candidateAgentsRoot = path.dirname(parentOfAgent); + const looksLikeStandardLayout = + path.basename(normalized) === "agent" && path.basename(candidateAgentsRoot) === "agents"; + + const agentsRoot = looksLikeStandardLayout + ? candidateAgentsRoot + : path.join(resolveStateDir(), "agents"); + + const entries = (() => { + try { + return fs.readdirSync(agentsRoot, { withFileTypes: true }); + } catch { + return []; + } + })(); + const discovered = entries + .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) + .map((entry) => path.join(agentsRoot, entry.name, "agent")); + + const seen = new Set(); + const result: string[] = []; + for (const dir of [normalized, ...discovered]) { + const real = safeRealpathSync(dir); + if (real && !seen.has(real)) { + seen.add(real); + result.push(real); + } + } + return result; +} + +export async function writeOAuthCredentials( + provider: string, + creds: OAuthCredentials, + agentDir?: string, + options?: WriteOAuthCredentialsOptions, +): Promise { + const email = + typeof creds.email === "string" && creds.email.trim() ? creds.email.trim() : "default"; + const profileId = `${provider}:${email}`; + const resolvedAgentDir = path.resolve(resolveAuthAgentDir(agentDir)); + const targetAgentDirs = options?.syncSiblingAgents + ? resolveSiblingAgentDirs(resolvedAgentDir) + : [resolvedAgentDir]; + + const credential = { + type: "oauth" as const, + provider, + ...creds, + }; + + upsertAuthProfile({ + profileId, + credential, + agentDir: resolvedAgentDir, + }); + + if (options?.syncSiblingAgents) { + const primaryReal = safeRealpathSync(resolvedAgentDir); + for (const targetAgentDir of targetAgentDirs) { + const targetReal = safeRealpathSync(targetAgentDir); + if (targetReal && primaryReal && targetReal === primaryReal) { + continue; + } + try { + upsertAuthProfile({ + profileId, + credential, + agentDir: targetAgentDir, + }); + } catch { + // Best-effort: sibling sync failure must not block primary setup. + } + } + } + return profileId; +} diff --git a/src/commands/auth-profile-config.ts b/src/commands/auth-profile-config.ts index 797135b87b2..90be398f5b0 100644 --- a/src/commands/auth-profile-config.ts +++ b/src/commands/auth-profile-config.ts @@ -1,3 +1,4 @@ +import { normalizeProviderIdForAuth } from "../agents/provider-id.js"; import type { OpenClawConfig } from "../config/config.js"; export function applyAuthProfileConfig( @@ -10,7 +11,7 @@ export function applyAuthProfileConfig( preferProfileFirst?: boolean; }, ): OpenClawConfig { - const normalizedProvider = params.provider.toLowerCase(); + const normalizedProvider = normalizeProviderIdForAuth(params.provider); const profiles = { ...cfg.auth?.profiles, [params.profileId]: { @@ -21,7 +22,7 @@ export function applyAuthProfileConfig( }; const configuredProviderProfiles = Object.entries(cfg.auth?.profiles ?? {}) - .filter(([, profile]) => profile.provider.toLowerCase() === normalizedProvider) + .filter(([, profile]) => normalizeProviderIdForAuth(profile.provider) === normalizedProvider) .map(([profileId, profile]) => ({ profileId, mode: profile.mode })); // Maintain `auth.order` when it already exists. Additionally, if we detect diff --git a/src/commands/channel-setup/plugin-install.test.ts b/src/commands/channel-setup/plugin-install.test.ts index 5ad6399fa4a..96ca60e2197 100644 --- a/src/commands/channel-setup/plugin-install.test.ts +++ b/src/commands/channel-setup/plugin-install.test.ts @@ -338,6 +338,7 @@ describe("ensureChannelSetupPluginInstalled", () => { channelIds: [], providerIds: [], speechProviderIds: [], + mediaUnderstandingProviderIds: [], webSearchProviderIds: [], gatewayMethods: [], cliCommands: [], diff --git a/src/commands/onboard-auth.config-core.ts b/src/commands/onboard-auth.config-core.ts index c939a2cb99d..7a78df71144 100644 --- a/src/commands/onboard-auth.config-core.ts +++ b/src/commands/onboard-auth.config-core.ts @@ -1,46 +1,3 @@ -import { - buildHuggingfaceModelDefinition, - HUGGINGFACE_BASE_URL, - HUGGINGFACE_MODEL_CATALOG, -} from "../agents/huggingface-models.js"; -import { - buildKilocodeProvider, - buildKimiCodingProvider, - buildQianfanProvider, - buildXiaomiProvider, - QIANFAN_DEFAULT_MODEL_ID, - XIAOMI_DEFAULT_MODEL_ID, -} from "../agents/models-config.providers.static.js"; -import { - buildSyntheticModelDefinition, - SYNTHETIC_BASE_URL, - SYNTHETIC_DEFAULT_MODEL_REF, - SYNTHETIC_MODEL_CATALOG, -} from "../agents/synthetic-models.js"; -import { - buildTogetherModelDefinition, - TOGETHER_BASE_URL, - TOGETHER_MODEL_CATALOG, -} from "../agents/together-models.js"; -import { - buildVeniceModelDefinition, - VENICE_BASE_URL, - VENICE_DEFAULT_MODEL_REF, - VENICE_MODEL_CATALOG, -} from "../agents/venice-models.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { ModelApi } from "../config/types.models.js"; -import { KILOCODE_BASE_URL } from "../providers/kilocode-shared.js"; -import { - HUGGINGFACE_DEFAULT_MODEL_REF, - KILOCODE_DEFAULT_MODEL_REF, - MISTRAL_DEFAULT_MODEL_REF, - OPENROUTER_DEFAULT_MODEL_REF, - TOGETHER_DEFAULT_MODEL_REF, - XIAOMI_DEFAULT_MODEL_REF, - ZAI_DEFAULT_MODEL_REF, - XAI_DEFAULT_MODEL_REF, -} from "./onboard-auth.credentials.js"; export { applyCloudflareAiGatewayConfig, applyCloudflareAiGatewayProviderConfig, @@ -53,550 +10,73 @@ export { LITELLM_BASE_URL, LITELLM_DEFAULT_MODEL_ID, } from "./onboard-auth.config-litellm.js"; -import { - applyAgentDefaultModelPrimary, - applyOnboardAuthAgentModelsAndProviders, - applyProviderConfigWithDefaultModel, - applyProviderConfigWithDefaultModels, - applyProviderConfigWithModelCatalog, -} from "./onboard-auth.config-shared.js"; -import { - buildMistralModelDefinition, - buildZaiModelDefinition, - buildMoonshotModelDefinition, - buildXaiModelDefinition, - buildModelStudioModelDefinition, - MISTRAL_BASE_URL, - MISTRAL_DEFAULT_MODEL_ID, - QIANFAN_BASE_URL, - QIANFAN_DEFAULT_MODEL_REF, - KIMI_CODING_MODEL_ID, - KIMI_CODING_MODEL_REF, - MOONSHOT_BASE_URL, - MOONSHOT_CN_BASE_URL, - MOONSHOT_DEFAULT_MODEL_ID, - MOONSHOT_DEFAULT_MODEL_REF, - ZAI_DEFAULT_MODEL_ID, - resolveZaiBaseUrl, - XAI_BASE_URL, - XAI_DEFAULT_MODEL_ID, - MODELSTUDIO_CN_BASE_URL, - MODELSTUDIO_GLOBAL_BASE_URL, - MODELSTUDIO_DEFAULT_MODEL_REF, -} from "./onboard-auth.models.js"; export { applyAuthProfileConfig } from "./auth-profile-config.js"; - -function mergeProviderModels( - existingProvider: Record | undefined, - defaultModels: T[], -): T[] { - const existingModels = Array.isArray(existingProvider?.models) - ? (existingProvider.models as T[]) - : []; - const mergedModels = [...existingModels]; - const seen = new Set(existingModels.map((model) => model.id)); - for (const model of defaultModels) { - if (!seen.has(model.id)) { - mergedModels.push(model); - seen.add(model.id); - } - } - return mergedModels; -} - -function getNormalizedProviderApiKey(existingProvider: Record | undefined) { - const { apiKey } = (existingProvider ?? {}) as { apiKey?: string }; - return typeof apiKey === "string" ? apiKey.trim() || undefined : undefined; -} - -export function applyZaiProviderConfig( - cfg: OpenClawConfig, - params?: { endpoint?: string; modelId?: string }, -): OpenClawConfig { - const modelId = params?.modelId?.trim() || ZAI_DEFAULT_MODEL_ID; - const modelRef = `zai/${modelId}`; - - const models = { ...cfg.agents?.defaults?.models }; - models[modelRef] = { - ...models[modelRef], - alias: models[modelRef]?.alias ?? "GLM", - }; - - const providers = { ...cfg.models?.providers }; - const existingProvider = providers.zai; - - const defaultModels = [ - buildZaiModelDefinition({ id: "glm-5" }), - buildZaiModelDefinition({ id: "glm-5-turbo" }), - buildZaiModelDefinition({ id: "glm-4.7" }), - buildZaiModelDefinition({ id: "glm-4.7-flash" }), - buildZaiModelDefinition({ id: "glm-4.7-flashx" }), - ]; - - const mergedModels = mergeProviderModels(existingProvider, defaultModels); - - const { apiKey: _existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record< - string, - unknown - > as { apiKey?: string }; - const normalizedApiKey = getNormalizedProviderApiKey(existingProvider); - - const baseUrl = params?.endpoint - ? resolveZaiBaseUrl(params.endpoint) - : (typeof existingProvider?.baseUrl === "string" ? existingProvider.baseUrl : "") || - resolveZaiBaseUrl(); - - providers.zai = { - ...existingProviderRest, - baseUrl, - api: "openai-completions", - ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), - models: mergedModels.length > 0 ? mergedModels : defaultModels, - }; - - return applyOnboardAuthAgentModelsAndProviders(cfg, { agentModels: models, providers }); -} - -export function applyZaiConfig( - cfg: OpenClawConfig, - params?: { endpoint?: string; modelId?: string }, -): OpenClawConfig { - const modelId = params?.modelId?.trim() || ZAI_DEFAULT_MODEL_ID; - const modelRef = modelId === ZAI_DEFAULT_MODEL_ID ? ZAI_DEFAULT_MODEL_REF : `zai/${modelId}`; - const next = applyZaiProviderConfig(cfg, params); - return applyAgentDefaultModelPrimary(next, modelRef); -} - -export function applyOpenrouterProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[OPENROUTER_DEFAULT_MODEL_REF] = { - ...models[OPENROUTER_DEFAULT_MODEL_REF], - alias: models[OPENROUTER_DEFAULT_MODEL_REF]?.alias ?? "OpenRouter", - }; - - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - models, - }, - }, - }; -} - -export function applyOpenrouterConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyOpenrouterProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, OPENROUTER_DEFAULT_MODEL_REF); -} - -export function applyMoonshotProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - return applyMoonshotProviderConfigWithBaseUrl(cfg, MOONSHOT_BASE_URL); -} - -export function applyMoonshotProviderConfigCn(cfg: OpenClawConfig): OpenClawConfig { - return applyMoonshotProviderConfigWithBaseUrl(cfg, MOONSHOT_CN_BASE_URL); -} - -function applyMoonshotProviderConfigWithBaseUrl( - cfg: OpenClawConfig, - baseUrl: string, -): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[MOONSHOT_DEFAULT_MODEL_REF] = { - ...models[MOONSHOT_DEFAULT_MODEL_REF], - alias: models[MOONSHOT_DEFAULT_MODEL_REF]?.alias ?? "Kimi", - }; - - const defaultModel = buildMoonshotModelDefinition(); - - return applyProviderConfigWithDefaultModel(cfg, { - agentModels: models, - providerId: "moonshot", - api: "openai-completions", - baseUrl, - defaultModel, - defaultModelId: MOONSHOT_DEFAULT_MODEL_ID, - }); -} - -export function applyMoonshotConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyMoonshotProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, MOONSHOT_DEFAULT_MODEL_REF); -} - -export function applyMoonshotConfigCn(cfg: OpenClawConfig): OpenClawConfig { - const next = applyMoonshotProviderConfigCn(cfg); - return applyAgentDefaultModelPrimary(next, MOONSHOT_DEFAULT_MODEL_REF); -} - -export function applyKimiCodeProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[KIMI_CODING_MODEL_REF] = { - ...models[KIMI_CODING_MODEL_REF], - alias: models[KIMI_CODING_MODEL_REF]?.alias ?? "Kimi for Coding", - }; - - const defaultModel = buildKimiCodingProvider().models[0]; - - return applyProviderConfigWithDefaultModel(cfg, { - agentModels: models, - providerId: "kimi-coding", - api: "anthropic-messages", - baseUrl: "https://api.kimi.com/coding/", - defaultModel, - defaultModelId: KIMI_CODING_MODEL_ID, - }); -} - -export function applyKimiCodeConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyKimiCodeProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, KIMI_CODING_MODEL_REF); -} - -export function applySyntheticProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[SYNTHETIC_DEFAULT_MODEL_REF] = { - ...models[SYNTHETIC_DEFAULT_MODEL_REF], - alias: models[SYNTHETIC_DEFAULT_MODEL_REF]?.alias ?? "MiniMax M2.5", - }; - - const providers = { ...cfg.models?.providers }; - const existingProvider = providers.synthetic; - const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : []; - const syntheticModels = SYNTHETIC_MODEL_CATALOG.map(buildSyntheticModelDefinition); - const mergedModels = [ - ...existingModels, - ...syntheticModels.filter( - (model) => !existingModels.some((existing) => existing.id === model.id), - ), - ]; - const { apiKey: _existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record< - string, - unknown - > as { apiKey?: string }; - const normalizedApiKey = getNormalizedProviderApiKey(existingProvider); - providers.synthetic = { - ...existingProviderRest, - baseUrl: SYNTHETIC_BASE_URL, - api: "anthropic-messages", - ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), - models: mergedModels.length > 0 ? mergedModels : syntheticModels, - }; - - return applyOnboardAuthAgentModelsAndProviders(cfg, { agentModels: models, providers }); -} - -export function applySyntheticConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applySyntheticProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, SYNTHETIC_DEFAULT_MODEL_REF); -} - -export function applyXiaomiProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[XIAOMI_DEFAULT_MODEL_REF] = { - ...models[XIAOMI_DEFAULT_MODEL_REF], - alias: models[XIAOMI_DEFAULT_MODEL_REF]?.alias ?? "Xiaomi", - }; - const defaultProvider = buildXiaomiProvider(); - const resolvedApi = defaultProvider.api ?? "openai-completions"; - return applyProviderConfigWithDefaultModels(cfg, { - agentModels: models, - providerId: "xiaomi", - api: resolvedApi, - baseUrl: defaultProvider.baseUrl, - defaultModels: defaultProvider.models ?? [], - defaultModelId: XIAOMI_DEFAULT_MODEL_ID, - }); -} - -export function applyXiaomiConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyXiaomiProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, XIAOMI_DEFAULT_MODEL_REF); -} - -/** - * Apply Venice provider configuration without changing the default model. - * Registers Venice models and sets up the provider, but preserves existing model selection. - */ -export function applyVeniceProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[VENICE_DEFAULT_MODEL_REF] = { - ...models[VENICE_DEFAULT_MODEL_REF], - alias: models[VENICE_DEFAULT_MODEL_REF]?.alias ?? "Kimi K2.5", - }; - - const veniceModels = VENICE_MODEL_CATALOG.map(buildVeniceModelDefinition); - return applyProviderConfigWithModelCatalog(cfg, { - agentModels: models, - providerId: "venice", - api: "openai-completions", - baseUrl: VENICE_BASE_URL, - catalogModels: veniceModels, - }); -} - -/** - * Apply Venice provider configuration AND set Venice as the default model. - * Use this when Venice is the primary provider choice during setup. - */ -export function applyVeniceConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyVeniceProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, VENICE_DEFAULT_MODEL_REF); -} - -/** - * Apply Together provider configuration without changing the default model. - * Registers Together models and sets up the provider, but preserves existing model selection. - */ -export function applyTogetherProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[TOGETHER_DEFAULT_MODEL_REF] = { - ...models[TOGETHER_DEFAULT_MODEL_REF], - alias: models[TOGETHER_DEFAULT_MODEL_REF]?.alias ?? "Together AI", - }; - - const togetherModels = TOGETHER_MODEL_CATALOG.map(buildTogetherModelDefinition); - return applyProviderConfigWithModelCatalog(cfg, { - agentModels: models, - providerId: "together", - api: "openai-completions", - baseUrl: TOGETHER_BASE_URL, - catalogModels: togetherModels, - }); -} - -/** - * Apply Together provider configuration AND set Together as the default model. - * Use this when Together is the primary provider choice during setup. - */ -export function applyTogetherConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyTogetherProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, TOGETHER_DEFAULT_MODEL_REF); -} - -/** - * Apply Hugging Face (Inference Providers) provider configuration without changing the default model. - */ -export function applyHuggingfaceProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[HUGGINGFACE_DEFAULT_MODEL_REF] = { - ...models[HUGGINGFACE_DEFAULT_MODEL_REF], - alias: models[HUGGINGFACE_DEFAULT_MODEL_REF]?.alias ?? "Hugging Face", - }; - - const hfModels = HUGGINGFACE_MODEL_CATALOG.map(buildHuggingfaceModelDefinition); - return applyProviderConfigWithModelCatalog(cfg, { - agentModels: models, - providerId: "huggingface", - api: "openai-completions", - baseUrl: HUGGINGFACE_BASE_URL, - catalogModels: hfModels, - }); -} - -/** - * Apply Hugging Face provider configuration AND set Hugging Face as the default model. - */ -export function applyHuggingfaceConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyHuggingfaceProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, HUGGINGFACE_DEFAULT_MODEL_REF); -} - -export function applyXaiProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[XAI_DEFAULT_MODEL_REF] = { - ...models[XAI_DEFAULT_MODEL_REF], - alias: models[XAI_DEFAULT_MODEL_REF]?.alias ?? "Grok", - }; - - const defaultModel = buildXaiModelDefinition(); - - return applyProviderConfigWithDefaultModel(cfg, { - agentModels: models, - providerId: "xai", - api: "openai-completions", - baseUrl: XAI_BASE_URL, - defaultModel, - defaultModelId: XAI_DEFAULT_MODEL_ID, - }); -} - -export function applyXaiConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyXaiProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, XAI_DEFAULT_MODEL_REF); -} - -export function applyMistralProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[MISTRAL_DEFAULT_MODEL_REF] = { - ...models[MISTRAL_DEFAULT_MODEL_REF], - alias: models[MISTRAL_DEFAULT_MODEL_REF]?.alias ?? "Mistral", - }; - - const defaultModel = buildMistralModelDefinition(); - - return applyProviderConfigWithDefaultModel(cfg, { - agentModels: models, - providerId: "mistral", - api: "openai-completions", - baseUrl: MISTRAL_BASE_URL, - defaultModel, - defaultModelId: MISTRAL_DEFAULT_MODEL_ID, - }); -} - -export function applyMistralConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyMistralProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, MISTRAL_DEFAULT_MODEL_REF); -} - -export { KILOCODE_BASE_URL }; - -/** - * Apply Kilo Gateway provider configuration without changing the default model. - * Registers Kilo Gateway and sets up the provider, but preserves existing model selection. - */ -export function applyKilocodeProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[KILOCODE_DEFAULT_MODEL_REF] = { - ...models[KILOCODE_DEFAULT_MODEL_REF], - alias: models[KILOCODE_DEFAULT_MODEL_REF]?.alias ?? "Kilo Gateway", - }; - - const kilocodeModels = buildKilocodeProvider().models ?? []; - - return applyProviderConfigWithModelCatalog(cfg, { - agentModels: models, - providerId: "kilocode", - api: "openai-completions", - baseUrl: KILOCODE_BASE_URL, - catalogModels: kilocodeModels, - }); -} - -/** - * Apply Kilo Gateway provider configuration AND set Kilo Gateway as the default model. - * Use this when Kilo Gateway is the primary provider choice during setup. - */ -export function applyKilocodeConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyKilocodeProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, KILOCODE_DEFAULT_MODEL_REF); -} - -export function applyQianfanProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[QIANFAN_DEFAULT_MODEL_REF] = { - ...models[QIANFAN_DEFAULT_MODEL_REF], - alias: models[QIANFAN_DEFAULT_MODEL_REF]?.alias ?? "QIANFAN", - }; - const defaultProvider = buildQianfanProvider(); - const existingProvider = cfg.models?.providers?.qianfan as - | { - baseUrl?: unknown; - api?: unknown; - } - | undefined; - const existingBaseUrl = - typeof existingProvider?.baseUrl === "string" ? existingProvider.baseUrl.trim() : ""; - const resolvedBaseUrl = existingBaseUrl || QIANFAN_BASE_URL; - const resolvedApi = - typeof existingProvider?.api === "string" - ? (existingProvider.api as ModelApi) - : "openai-completions"; - - return applyProviderConfigWithDefaultModels(cfg, { - agentModels: models, - providerId: "qianfan", - api: resolvedApi, - baseUrl: resolvedBaseUrl, - defaultModels: defaultProvider.models ?? [], - defaultModelId: QIANFAN_DEFAULT_MODEL_ID, - }); -} - -export function applyQianfanConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyQianfanProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, QIANFAN_DEFAULT_MODEL_REF); -} - -// Alibaba Cloud Model Studio Coding Plan - -function applyModelStudioProviderConfigWithBaseUrl( - cfg: OpenClawConfig, - baseUrl: string, -): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - - const modelStudioModelIds = [ - "qwen3.5-plus", - "qwen3-max-2026-01-23", - "qwen3-coder-next", - "qwen3-coder-plus", - "MiniMax-M2.5", - "glm-5", - "glm-4.7", - "kimi-k2.5", - ]; - for (const modelId of modelStudioModelIds) { - const modelRef = `modelstudio/${modelId}`; - if (!models[modelRef]) { - models[modelRef] = {}; - } - } - models[MODELSTUDIO_DEFAULT_MODEL_REF] = { - ...models[MODELSTUDIO_DEFAULT_MODEL_REF], - alias: models[MODELSTUDIO_DEFAULT_MODEL_REF]?.alias ?? "Qwen", - }; - - const providers = { ...cfg.models?.providers }; - const existingProvider = providers.modelstudio; - - const defaultModels = [ - buildModelStudioModelDefinition({ id: "qwen3.5-plus" }), - buildModelStudioModelDefinition({ id: "qwen3-max-2026-01-23" }), - buildModelStudioModelDefinition({ id: "qwen3-coder-next" }), - buildModelStudioModelDefinition({ id: "qwen3-coder-plus" }), - buildModelStudioModelDefinition({ id: "MiniMax-M2.5" }), - buildModelStudioModelDefinition({ id: "glm-5" }), - buildModelStudioModelDefinition({ id: "glm-4.7" }), - buildModelStudioModelDefinition({ id: "kimi-k2.5" }), - ]; - - const mergedModels = mergeProviderModels(existingProvider, defaultModels); - - const { apiKey: _existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record< - string, - unknown - > as { apiKey?: string }; - const normalizedApiKey = getNormalizedProviderApiKey(existingProvider); - - providers.modelstudio = { - ...existingProviderRest, - baseUrl, - api: "openai-completions", - ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), - models: mergedModels.length > 0 ? mergedModels : defaultModels, - }; - - return applyOnboardAuthAgentModelsAndProviders(cfg, { agentModels: models, providers }); -} - -export function applyModelStudioProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - return applyModelStudioProviderConfigWithBaseUrl(cfg, MODELSTUDIO_GLOBAL_BASE_URL); -} - -export function applyModelStudioProviderConfigCn(cfg: OpenClawConfig): OpenClawConfig { - return applyModelStudioProviderConfigWithBaseUrl(cfg, MODELSTUDIO_CN_BASE_URL); -} - -export function applyModelStudioConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyModelStudioProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, MODELSTUDIO_DEFAULT_MODEL_REF); -} - -export function applyModelStudioConfigCn(cfg: OpenClawConfig): OpenClawConfig { - const next = applyModelStudioProviderConfigCn(cfg); - return applyAgentDefaultModelPrimary(next, MODELSTUDIO_DEFAULT_MODEL_REF); -} +export { + applyHuggingfaceConfig, + applyHuggingfaceProviderConfig, + HUGGINGFACE_DEFAULT_MODEL_REF, +} from "../../extensions/huggingface/onboard.js"; +export { + applyKimiCodeConfig, + applyKimiCodeProviderConfig, +} from "../../extensions/kimi-coding/onboard.js"; +export { + applyKilocodeConfig, + applyKilocodeProviderConfig, + KILOCODE_BASE_URL, + KILOCODE_DEFAULT_MODEL_REF, +} from "../../extensions/kilocode/onboard.js"; +export { + applyMistralConfig, + applyMistralProviderConfig, + MISTRAL_DEFAULT_MODEL_REF, +} from "../../extensions/mistral/onboard.js"; +export { + applyModelStudioConfig, + applyModelStudioConfigCn, + applyModelStudioProviderConfig, + applyModelStudioProviderConfigCn, + MODELSTUDIO_CN_BASE_URL, + MODELSTUDIO_DEFAULT_MODEL_REF, + MODELSTUDIO_GLOBAL_BASE_URL, +} from "../../extensions/modelstudio/onboard.js"; +export { + applyMoonshotConfig, + applyMoonshotConfigCn, + applyMoonshotProviderConfig, + applyMoonshotProviderConfigCn, +} from "../../extensions/moonshot/onboard.js"; +export { + applyOpenrouterConfig, + applyOpenrouterProviderConfig, +} from "../../extensions/openrouter/onboard.js"; +export { + applyQianfanConfig, + applyQianfanProviderConfig, +} from "../../extensions/qianfan/onboard.js"; +export { + applySyntheticConfig, + applySyntheticProviderConfig, + SYNTHETIC_DEFAULT_MODEL_REF, +} from "../../extensions/synthetic/onboard.js"; +export { + applyTogetherConfig, + applyTogetherProviderConfig, + TOGETHER_DEFAULT_MODEL_REF, +} from "../../extensions/together/onboard.js"; +export { + applyVeniceConfig, + applyVeniceProviderConfig, + VENICE_DEFAULT_MODEL_REF, +} from "../../extensions/venice/onboard.js"; +export { + applyXaiConfig, + applyXaiProviderConfig, + XAI_DEFAULT_MODEL_REF, +} from "../../extensions/xai/onboard.js"; +export { applyXiaomiConfig, applyXiaomiProviderConfig } from "../../extensions/xiaomi/onboard.js"; +export { + applyZaiConfig, + applyZaiProviderConfig, + ZAI_DEFAULT_MODEL_REF, +} from "../../extensions/zai/onboard.js"; diff --git a/src/commands/onboard-auth.config-gateways.ts b/src/commands/onboard-auth.config-gateways.ts index a7a4d4246ce..4699481d79a 100644 --- a/src/commands/onboard-auth.config-gateways.ts +++ b/src/commands/onboard-auth.config-gateways.ts @@ -1,91 +1,10 @@ -import { - buildCloudflareAiGatewayModelDefinition, - resolveCloudflareAiGatewayBaseUrl, -} from "../agents/cloudflare-ai-gateway.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { - applyAgentDefaultModelPrimary, - applyProviderConfigWithDefaultModel, -} from "./onboard-auth.config-shared.js"; -import { +export { + applyCloudflareAiGatewayConfig, + applyCloudflareAiGatewayProviderConfig, CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, +} from "../../extensions/cloudflare-ai-gateway/onboard.js"; +export { + applyVercelAiGatewayConfig, + applyVercelAiGatewayProviderConfig, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, -} from "./onboard-auth.credentials.js"; - -export function applyVercelAiGatewayProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF] = { - ...models[VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF], - alias: models[VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF]?.alias ?? "Vercel AI Gateway", - }; - - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - models, - }, - }, - }; -} - -export function applyCloudflareAiGatewayProviderConfig( - cfg: OpenClawConfig, - params?: { accountId?: string; gatewayId?: string }, -): OpenClawConfig { - const models = { ...cfg.agents?.defaults?.models }; - models[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF] = { - ...models[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF], - alias: models[CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF]?.alias ?? "Cloudflare AI Gateway", - }; - - const defaultModel = buildCloudflareAiGatewayModelDefinition(); - const existingProvider = cfg.models?.providers?.["cloudflare-ai-gateway"] as - | { baseUrl?: unknown } - | undefined; - const baseUrl = - params?.accountId && params?.gatewayId - ? resolveCloudflareAiGatewayBaseUrl({ - accountId: params.accountId, - gatewayId: params.gatewayId, - }) - : typeof existingProvider?.baseUrl === "string" - ? existingProvider.baseUrl - : undefined; - - if (!baseUrl) { - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - models, - }, - }, - }; - } - - return applyProviderConfigWithDefaultModel(cfg, { - agentModels: models, - providerId: "cloudflare-ai-gateway", - api: "anthropic-messages", - baseUrl, - defaultModel, - }); -} - -export function applyVercelAiGatewayConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyVercelAiGatewayProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF); -} - -export function applyCloudflareAiGatewayConfig( - cfg: OpenClawConfig, - params?: { accountId?: string; gatewayId?: string }, -): OpenClawConfig { - const next = applyCloudflareAiGatewayProviderConfig(cfg, params); - return applyAgentDefaultModelPrimary(next, CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF); -} +} from "../../extensions/vercel-ai-gateway/onboard.js"; diff --git a/src/commands/onboard-auth.config-minimax.ts b/src/commands/onboard-auth.config-minimax.ts index 14ec734592b..8453154bb7f 100644 --- a/src/commands/onboard-auth.config-minimax.ts +++ b/src/commands/onboard-auth.config-minimax.ts @@ -1,106 +1,6 @@ -import type { OpenClawConfig } from "../config/config.js"; -import type { ModelProviderConfig } from "../config/types.models.js"; -import { - applyAgentDefaultModelPrimary, - applyOnboardAuthAgentModelsAndProviders, -} from "./onboard-auth.config-shared.js"; -import { - buildMinimaxApiModelDefinition, - MINIMAX_API_BASE_URL, - MINIMAX_CN_API_BASE_URL, -} from "./onboard-auth.models.js"; - -type MinimaxApiProviderConfigParams = { - providerId: string; - modelId: string; - baseUrl: string; -}; - -function applyMinimaxApiProviderConfigWithBaseUrl( - cfg: OpenClawConfig, - params: MinimaxApiProviderConfigParams, -): OpenClawConfig { - const providers = { ...cfg.models?.providers } as Record; - const existingProvider = providers[params.providerId]; - const existingModels = existingProvider?.models ?? []; - const apiModel = buildMinimaxApiModelDefinition(params.modelId); - const hasApiModel = existingModels.some((model) => model.id === params.modelId); - const mergedModels = hasApiModel ? existingModels : [...existingModels, apiModel]; - const { apiKey: existingApiKey, ...existingProviderRest } = existingProvider ?? { - baseUrl: params.baseUrl, - models: [], - }; - const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined; - const normalizedApiKey = resolvedApiKey?.trim() === "minimax" ? "" : resolvedApiKey; - providers[params.providerId] = { - ...existingProviderRest, - baseUrl: params.baseUrl, - api: "anthropic-messages", - authHeader: true, - ...(normalizedApiKey?.trim() ? { apiKey: normalizedApiKey } : {}), - models: mergedModels.length > 0 ? mergedModels : [apiModel], - }; - - const models = { ...cfg.agents?.defaults?.models }; - const modelRef = `${params.providerId}/${params.modelId}`; - models[modelRef] = { - ...models[modelRef], - alias: "Minimax", - }; - - return applyOnboardAuthAgentModelsAndProviders(cfg, { agentModels: models, providers }); -} - -function applyMinimaxApiConfigWithBaseUrl( - cfg: OpenClawConfig, - params: MinimaxApiProviderConfigParams, -): OpenClawConfig { - const next = applyMinimaxApiProviderConfigWithBaseUrl(cfg, params); - return applyAgentDefaultModelPrimary(next, `${params.providerId}/${params.modelId}`); -} - -// MiniMax Global API (platform.minimax.io/anthropic) -export function applyMinimaxApiProviderConfig( - cfg: OpenClawConfig, - modelId: string = "MiniMax-M2.5", -): OpenClawConfig { - return applyMinimaxApiProviderConfigWithBaseUrl(cfg, { - providerId: "minimax", - modelId, - baseUrl: MINIMAX_API_BASE_URL, - }); -} - -export function applyMinimaxApiConfig( - cfg: OpenClawConfig, - modelId: string = "MiniMax-M2.5", -): OpenClawConfig { - return applyMinimaxApiConfigWithBaseUrl(cfg, { - providerId: "minimax", - modelId, - baseUrl: MINIMAX_API_BASE_URL, - }); -} - -// MiniMax CN API (api.minimaxi.com/anthropic) — same provider id, different baseUrl -export function applyMinimaxApiProviderConfigCn( - cfg: OpenClawConfig, - modelId: string = "MiniMax-M2.5", -): OpenClawConfig { - return applyMinimaxApiProviderConfigWithBaseUrl(cfg, { - providerId: "minimax", - modelId, - baseUrl: MINIMAX_CN_API_BASE_URL, - }); -} - -export function applyMinimaxApiConfigCn( - cfg: OpenClawConfig, - modelId: string = "MiniMax-M2.5", -): OpenClawConfig { - return applyMinimaxApiConfigWithBaseUrl(cfg, { - providerId: "minimax", - modelId, - baseUrl: MINIMAX_CN_API_BASE_URL, - }); -} +export { + applyMinimaxApiConfig, + applyMinimaxApiConfigCn, + applyMinimaxApiProviderConfig, + applyMinimaxApiProviderConfigCn, +} from "../../extensions/minimax/onboard.js"; diff --git a/src/commands/onboard-auth.config-opencode-go.ts b/src/commands/onboard-auth.config-opencode-go.ts index 25be5ffa18f..eb31512e565 100644 --- a/src/commands/onboard-auth.config-opencode-go.ts +++ b/src/commands/onboard-auth.config-opencode-go.ts @@ -1,36 +1,5 @@ -import type { OpenClawConfig } from "../config/config.js"; -import { applyAgentDefaultModelPrimary } from "./onboard-auth.config-shared.js"; -import { OPENCODE_GO_DEFAULT_MODEL_REF } from "./opencode-go-model-default.js"; - -const OPENCODE_GO_ALIAS_DEFAULTS: Record = { - "opencode-go/kimi-k2.5": "Kimi", - "opencode-go/glm-5": "GLM", - "opencode-go/minimax-m2.5": "MiniMax", -}; - -export function applyOpencodeGoProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - // Use the built-in opencode-go provider from pi-ai; only seed allowlist aliases. - const models = { ...cfg.agents?.defaults?.models }; - for (const [modelRef, alias] of Object.entries(OPENCODE_GO_ALIAS_DEFAULTS)) { - models[modelRef] = { - ...models[modelRef], - alias: models[modelRef]?.alias ?? alias, - }; - } - - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - models, - }, - }, - }; -} - -export function applyOpencodeGoConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyOpencodeGoProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, OPENCODE_GO_DEFAULT_MODEL_REF); -} +export { + applyOpencodeGoConfig, + applyOpencodeGoProviderConfig, + OPENCODE_GO_DEFAULT_MODEL_REF, +} from "../../extensions/opencode-go/onboard.js"; diff --git a/src/commands/onboard-auth.config-opencode.ts b/src/commands/onboard-auth.config-opencode.ts index c9f1dd4725b..d9aa6f97436 100644 --- a/src/commands/onboard-auth.config-opencode.ts +++ b/src/commands/onboard-auth.config-opencode.ts @@ -1,28 +1,5 @@ -import { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../agents/opencode-zen-models.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { applyAgentDefaultModelPrimary } from "./onboard-auth.config-shared.js"; - -export function applyOpencodeZenProviderConfig(cfg: OpenClawConfig): OpenClawConfig { - // Use the built-in opencode provider from pi-ai; only seed the allowlist alias. - const models = { ...cfg.agents?.defaults?.models }; - models[OPENCODE_ZEN_DEFAULT_MODEL_REF] = { - ...models[OPENCODE_ZEN_DEFAULT_MODEL_REF], - alias: models[OPENCODE_ZEN_DEFAULT_MODEL_REF]?.alias ?? "Opus", - }; - - return { - ...cfg, - agents: { - ...cfg.agents, - defaults: { - ...cfg.agents?.defaults, - models, - }, - }, - }; -} - -export function applyOpencodeZenConfig(cfg: OpenClawConfig): OpenClawConfig { - const next = applyOpencodeZenProviderConfig(cfg); - return applyAgentDefaultModelPrimary(next, OPENCODE_ZEN_DEFAULT_MODEL_REF); -} +export { + applyOpencodeZenConfig, + applyOpencodeZenProviderConfig, + OPENCODE_ZEN_DEFAULT_MODEL_REF, +} from "../../extensions/opencode/onboard.js"; diff --git a/src/commands/onboard-auth.config-shared.ts b/src/commands/onboard-auth.config-shared.ts index a417b19c36e..9e70eaac192 100644 --- a/src/commands/onboard-auth.config-shared.ts +++ b/src/commands/onboard-auth.config-shared.ts @@ -1,3 +1,4 @@ +import { findNormalizedProviderKey } from "../agents/provider-id.js"; import type { OpenClawConfig } from "../config/config.js"; import type { AgentModelEntryConfig } from "../config/types.agent-defaults.js"; import type { @@ -159,10 +160,17 @@ function resolveProviderModelMergeState( providerId: string, ): ProviderModelMergeState { const providers = { ...cfg.models?.providers } as Record; - const existingProvider = providers[providerId] as ModelProviderConfig | undefined; + const existingProviderKey = findNormalizedProviderKey(providers, providerId); + const existingProvider = + existingProviderKey !== undefined + ? (providers[existingProviderKey] as ModelProviderConfig | undefined) + : undefined; const existingModels: ModelDefinitionConfig[] = Array.isArray(existingProvider?.models) ? existingProvider.models : []; + if (existingProviderKey && existingProviderKey !== providerId) { + delete providers[existingProviderKey]; + } return { providers, existingProvider, existingModels }; } diff --git a/src/commands/onboard-auth.credentials.ts b/src/commands/onboard-auth.credentials.ts index 2973667830b..4377a8b4de3 100644 --- a/src/commands/onboard-auth.credentials.ts +++ b/src/commands/onboard-auth.credentials.ts @@ -1,209 +1,27 @@ -import fs from "node:fs"; -import path from "node:path"; -import type { OAuthCredentials } from "@mariozechner/pi-ai"; import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; import { upsertAuthProfile } from "../agents/auth-profiles.js"; -import { resolveStateDir } from "../config/paths.js"; -import { - coerceSecretRef, - DEFAULT_SECRET_PROVIDER_ALIAS, - type SecretInput, - type SecretRef, -} from "../config/types.secrets.js"; +import type { SecretInput } from "../config/types.secrets.js"; import { KILOCODE_DEFAULT_MODEL_REF } from "../providers/kilocode-shared.js"; -import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js"; -import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; -import type { SecretInputMode } from "./onboard-types.js"; +import { + buildApiKeyCredential, + type ApiKeyStorageOptions, + writeOAuthCredentials, + type WriteOAuthCredentialsOptions, +} from "./auth-credentials.js"; export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF } from "../agents/cloudflare-ai-gateway.js"; -export { - MISTRAL_DEFAULT_MODEL_REF, - XAI_DEFAULT_MODEL_REF, - MODELSTUDIO_DEFAULT_MODEL_REF, -} from "./onboard-auth.models.js"; +export { MISTRAL_DEFAULT_MODEL_REF } from "../../extensions/mistral/onboard.js"; +export { MODELSTUDIO_DEFAULT_MODEL_REF } from "../../extensions/modelstudio/onboard.js"; +export { XAI_DEFAULT_MODEL_REF } from "../../extensions/xai/onboard.js"; export { KILOCODE_DEFAULT_MODEL_REF }; +export { + buildApiKeyCredential, + type ApiKeyStorageOptions, + writeOAuthCredentials, + type WriteOAuthCredentialsOptions, +}; const resolveAuthAgentDir = (agentDir?: string) => agentDir ?? resolveOpenClawAgentDir(); -const ENV_REF_PATTERN = /^\$\{([A-Z][A-Z0-9_]*)\}$/; - -export type ApiKeyStorageOptions = { - secretInputMode?: SecretInputMode; -}; - -function buildEnvSecretRef(id: string): SecretRef { - return { source: "env", provider: DEFAULT_SECRET_PROVIDER_ALIAS, id }; -} - -function parseEnvSecretRef(value: string): SecretRef | null { - const match = ENV_REF_PATTERN.exec(value); - if (!match) { - return null; - } - return buildEnvSecretRef(match[1]); -} - -function resolveProviderDefaultEnvSecretRef(provider: string): SecretRef { - const envVars = PROVIDER_ENV_VARS[provider]; - const envVar = envVars?.find((candidate) => candidate.trim().length > 0); - if (!envVar) { - throw new Error( - `Provider "${provider}" does not have a default env var mapping for secret-input-mode=ref.`, - ); - } - return buildEnvSecretRef(envVar); -} - -function resolveApiKeySecretInput( - provider: string, - input: SecretInput, - options?: ApiKeyStorageOptions, -): SecretInput { - const coercedRef = coerceSecretRef(input); - if (coercedRef) { - return coercedRef; - } - const normalized = normalizeSecretInput(input); - const inlineEnvRef = parseEnvSecretRef(normalized); - if (inlineEnvRef) { - return inlineEnvRef; - } - const useSecretRefMode = options?.secretInputMode === "ref"; // pragma: allowlist secret - if (useSecretRefMode) { - return resolveProviderDefaultEnvSecretRef(provider); - } - return normalized; -} - -export function buildApiKeyCredential( - provider: string, - input: SecretInput, - metadata?: Record, - options?: ApiKeyStorageOptions, -): { - type: "api_key"; - provider: string; - key?: string; - keyRef?: SecretRef; - metadata?: Record; -} { - const secretInput = resolveApiKeySecretInput(provider, input, options); - if (typeof secretInput === "string") { - return { - type: "api_key", - provider, - key: secretInput, - ...(metadata ? { metadata } : {}), - }; - } - return { - type: "api_key", - provider, - keyRef: secretInput, - ...(metadata ? { metadata } : {}), - }; -} - -export type WriteOAuthCredentialsOptions = { - syncSiblingAgents?: boolean; -}; - -/** Resolve real path, returning null if the target doesn't exist. */ -function safeRealpathSync(dir: string): string | null { - try { - return fs.realpathSync(path.resolve(dir)); - } catch { - return null; - } -} - -function resolveSiblingAgentDirs(primaryAgentDir: string): string[] { - const normalized = path.resolve(primaryAgentDir); - - // Derive agentsRoot from primaryAgentDir when it matches the standard - // layout (.../agents//agent). Falls back to global state dir. - const parentOfAgent = path.dirname(normalized); - const candidateAgentsRoot = path.dirname(parentOfAgent); - const looksLikeStandardLayout = - path.basename(normalized) === "agent" && path.basename(candidateAgentsRoot) === "agents"; - - const agentsRoot = looksLikeStandardLayout - ? candidateAgentsRoot - : path.join(resolveStateDir(), "agents"); - - const entries = (() => { - try { - return fs.readdirSync(agentsRoot, { withFileTypes: true }); - } catch { - return []; - } - })(); - // Include both directories and symlinks-to-directories. - const discovered = entries - .filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) - .map((entry) => path.join(agentsRoot, entry.name, "agent")); - - // Deduplicate via realpath to handle symlinks and path normalization. - const seen = new Set(); - const result: string[] = []; - for (const dir of [normalized, ...discovered]) { - const real = safeRealpathSync(dir); - if (real && !seen.has(real)) { - seen.add(real); - result.push(real); - } - } - return result; -} - -export async function writeOAuthCredentials( - provider: string, - creds: OAuthCredentials, - agentDir?: string, - options?: WriteOAuthCredentialsOptions, -): Promise { - const email = - typeof creds.email === "string" && creds.email.trim() ? creds.email.trim() : "default"; - const profileId = `${provider}:${email}`; - const resolvedAgentDir = path.resolve(resolveAuthAgentDir(agentDir)); - const targetAgentDirs = options?.syncSiblingAgents - ? resolveSiblingAgentDirs(resolvedAgentDir) - : [resolvedAgentDir]; - - const credential = { - type: "oauth" as const, - provider, - ...creds, - }; - - // Primary write must succeed — let it throw on failure. - upsertAuthProfile({ - profileId, - credential, - agentDir: resolvedAgentDir, - }); - - // Sibling sync is best-effort — log and ignore individual failures. - if (options?.syncSiblingAgents) { - const primaryReal = safeRealpathSync(resolvedAgentDir); - for (const targetAgentDir of targetAgentDirs) { - const targetReal = safeRealpathSync(targetAgentDir); - if (targetReal && primaryReal && targetReal === primaryReal) { - continue; - } - try { - upsertAuthProfile({ - profileId, - credential, - agentDir: targetAgentDir, - }); - } catch { - // Best-effort: sibling sync failure must not block primary setup. - } - } - } - return profileId; -} - export async function setAnthropicApiKey( key: SecretInput, agentDir?: string, @@ -277,8 +95,8 @@ export async function setKimiCodingApiKey( ) { // Write to resolved agent dir so gateway finds credentials on startup. upsertAuthProfile({ - profileId: "kimi-coding:default", - credential: buildApiKeyCredential("kimi-coding", key, undefined, options), + profileId: "kimi:default", + credential: buildApiKeyCredential("kimi", key, undefined, options), agentDir: resolveAuthAgentDir(agentDir), }); } diff --git a/src/commands/onboard-auth.models.ts b/src/commands/onboard-auth.models.ts index 383121b5700..5788d0ad2ca 100644 --- a/src/commands/onboard-auth.models.ts +++ b/src/commands/onboard-auth.models.ts @@ -1,8 +1,68 @@ +import { KIMI_CODING_MODEL_REF } from "../../extensions/kimi-coding/onboard.js"; +import { + KIMI_DEFAULT_MODEL_ID as KIMI_CODING_MODEL_ID, + KIMI_CODING_BASE_URL, +} from "../../extensions/kimi-coding/provider-catalog.js"; +import { + DEFAULT_MINIMAX_BASE_URL, + MINIMAX_API_BASE_URL, + MINIMAX_API_COST, + MINIMAX_CN_API_BASE_URL, + MINIMAX_HOSTED_COST, + MINIMAX_HOSTED_MODEL_ID, + MINIMAX_HOSTED_MODEL_REF, + MINIMAX_LM_STUDIO_COST, + buildMinimaxApiModelDefinition, + buildMinimaxModelDefinition, +} from "../../extensions/minimax/model-definitions.js"; +import { + buildMistralModelDefinition, + MISTRAL_BASE_URL, + MISTRAL_DEFAULT_COST, + MISTRAL_DEFAULT_MODEL_ID, + MISTRAL_DEFAULT_MODEL_REF, +} from "../../extensions/mistral/model-definitions.js"; +import { + MODELSTUDIO_CN_BASE_URL, + MODELSTUDIO_DEFAULT_COST, + MODELSTUDIO_DEFAULT_MODEL_ID, + MODELSTUDIO_DEFAULT_MODEL_REF, + MODELSTUDIO_GLOBAL_BASE_URL, + buildModelStudioDefaultModelDefinition, + buildModelStudioModelDefinition, +} from "../../extensions/modelstudio/model-definitions.js"; +import { + MOONSHOT_CN_BASE_URL, + MOONSHOT_DEFAULT_MODEL_REF, +} from "../../extensions/moonshot/onboard.js"; +import { + buildMoonshotProvider, + MOONSHOT_BASE_URL, + MOONSHOT_DEFAULT_MODEL_ID, +} from "../../extensions/moonshot/provider-catalog.js"; +import { QIANFAN_DEFAULT_MODEL_REF } from "../../extensions/qianfan/onboard.js"; import { QIANFAN_BASE_URL, QIANFAN_DEFAULT_MODEL_ID, -} from "../agents/models-config.providers.static.js"; -import type { ModelDefinitionConfig } from "../config/types.js"; +} from "../../extensions/qianfan/provider-catalog.js"; +import { + XAI_BASE_URL, + XAI_DEFAULT_COST, + XAI_DEFAULT_MODEL_ID, + XAI_DEFAULT_MODEL_REF, + buildXaiModelDefinition, +} from "../../extensions/xai/model-definitions.js"; +import { + buildZaiModelDefinition, + resolveZaiBaseUrl, + ZAI_CN_BASE_URL, + ZAI_CODING_CN_BASE_URL, + ZAI_CODING_GLOBAL_BASE_URL, + ZAI_DEFAULT_COST, + ZAI_DEFAULT_MODEL_ID, + ZAI_GLOBAL_BASE_URL, +} from "../../extensions/zai/model-definitions.js"; +import type { ModelDefinitionConfig } from "../config/types.models.js"; import { KILOCODE_DEFAULT_CONTEXT_WINDOW, KILOCODE_DEFAULT_COST, @@ -10,211 +70,61 @@ import { KILOCODE_DEFAULT_MODEL_ID, KILOCODE_DEFAULT_MODEL_NAME, } from "../providers/kilocode-shared.js"; + export { + DEFAULT_MINIMAX_BASE_URL, + MINIMAX_API_BASE_URL, + MINIMAX_API_COST, + MINIMAX_CN_API_BASE_URL, + MINIMAX_HOSTED_COST, + MINIMAX_HOSTED_MODEL_ID, + MINIMAX_HOSTED_MODEL_REF, + MINIMAX_LM_STUDIO_COST, + MISTRAL_BASE_URL, + MISTRAL_DEFAULT_COST, + MISTRAL_DEFAULT_MODEL_ID, + MISTRAL_DEFAULT_MODEL_REF, + MODELSTUDIO_CN_BASE_URL, + MODELSTUDIO_DEFAULT_COST, + MODELSTUDIO_DEFAULT_MODEL_ID, + MODELSTUDIO_DEFAULT_MODEL_REF, + MODELSTUDIO_GLOBAL_BASE_URL, + MOONSHOT_BASE_URL, + MOONSHOT_CN_BASE_URL, + MOONSHOT_DEFAULT_MODEL_ID, + MOONSHOT_DEFAULT_MODEL_REF, + QIANFAN_BASE_URL, + QIANFAN_DEFAULT_MODEL_ID, + QIANFAN_DEFAULT_MODEL_REF, + XAI_BASE_URL, + XAI_DEFAULT_COST, + XAI_DEFAULT_MODEL_ID, + XAI_DEFAULT_MODEL_REF, + ZAI_CN_BASE_URL, + ZAI_CODING_CN_BASE_URL, + ZAI_CODING_GLOBAL_BASE_URL, + ZAI_DEFAULT_COST, + ZAI_DEFAULT_MODEL_ID, + ZAI_GLOBAL_BASE_URL, + KIMI_CODING_BASE_URL, + KIMI_CODING_MODEL_ID, + KIMI_CODING_MODEL_REF, KILOCODE_DEFAULT_CONTEXT_WINDOW, KILOCODE_DEFAULT_COST, KILOCODE_DEFAULT_MAX_TOKENS, KILOCODE_DEFAULT_MODEL_ID, + buildMinimaxApiModelDefinition, + buildMinimaxModelDefinition, + buildMistralModelDefinition, + buildModelStudioDefaultModelDefinition, + buildModelStudioModelDefinition, + buildXaiModelDefinition, + buildZaiModelDefinition, + resolveZaiBaseUrl, }; -export const DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1"; -export const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic"; -export const MINIMAX_CN_API_BASE_URL = "https://api.minimaxi.com/anthropic"; -export const MINIMAX_HOSTED_MODEL_ID = "MiniMax-M2.5"; -export const MINIMAX_HOSTED_MODEL_REF = `minimax/${MINIMAX_HOSTED_MODEL_ID}`; -export const DEFAULT_MINIMAX_CONTEXT_WINDOW = 200000; -export const DEFAULT_MINIMAX_MAX_TOKENS = 8192; - -export const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"; -export const MOONSHOT_CN_BASE_URL = "https://api.moonshot.cn/v1"; -export const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2.5"; -export const MOONSHOT_DEFAULT_MODEL_REF = `moonshot/${MOONSHOT_DEFAULT_MODEL_ID}`; -export const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000; -export const MOONSHOT_DEFAULT_MAX_TOKENS = 8192; -export const KIMI_CODING_MODEL_ID = "k2p5"; -export const KIMI_CODING_MODEL_REF = `kimi-coding/${KIMI_CODING_MODEL_ID}`; - -export { QIANFAN_BASE_URL, QIANFAN_DEFAULT_MODEL_ID }; -export const QIANFAN_DEFAULT_MODEL_REF = `qianfan/${QIANFAN_DEFAULT_MODEL_ID}`; - -export const ZAI_CODING_GLOBAL_BASE_URL = "https://api.z.ai/api/coding/paas/v4"; -export const ZAI_CODING_CN_BASE_URL = "https://open.bigmodel.cn/api/coding/paas/v4"; -export const ZAI_GLOBAL_BASE_URL = "https://api.z.ai/api/paas/v4"; -export const ZAI_CN_BASE_URL = "https://open.bigmodel.cn/api/paas/v4"; -export const ZAI_DEFAULT_MODEL_ID = "glm-5"; - -export function resolveZaiBaseUrl(endpoint?: string): string { - switch (endpoint) { - case "coding-cn": - return ZAI_CODING_CN_BASE_URL; - case "global": - return ZAI_GLOBAL_BASE_URL; - case "cn": - return ZAI_CN_BASE_URL; - case "coding-global": - return ZAI_CODING_GLOBAL_BASE_URL; - default: - return ZAI_GLOBAL_BASE_URL; - } -} - -// Pricing per 1M tokens (USD) — https://platform.minimaxi.com/document/Price -export const MINIMAX_API_COST = { - input: 0.3, - output: 1.2, - cacheRead: 0.03, - cacheWrite: 0.12, -}; -export const MINIMAX_HOSTED_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; -export const MINIMAX_LM_STUDIO_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; -export const MOONSHOT_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -export const ZAI_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -const MINIMAX_MODEL_CATALOG = { - "MiniMax-M2.5": { name: "MiniMax M2.5", reasoning: true }, - "MiniMax-M2.5-highspeed": { name: "MiniMax M2.5 Highspeed", reasoning: true }, -} as const; - -type MinimaxCatalogId = keyof typeof MINIMAX_MODEL_CATALOG; - -const ZAI_MODEL_CATALOG = { - "glm-5": { name: "GLM-5", reasoning: true }, - "glm-5-turbo": { name: "GLM-5 Turbo", reasoning: true }, - "glm-4.7": { name: "GLM-4.7", reasoning: true }, - "glm-4.7-flash": { name: "GLM-4.7 Flash", reasoning: true }, - "glm-4.7-flashx": { name: "GLM-4.7 FlashX", reasoning: true }, -} as const; - -type ZaiCatalogId = keyof typeof ZAI_MODEL_CATALOG; - -export function buildMinimaxModelDefinition(params: { - id: string; - name?: string; - reasoning?: boolean; - cost: ModelDefinitionConfig["cost"]; - contextWindow: number; - maxTokens: number; -}): ModelDefinitionConfig { - const catalog = MINIMAX_MODEL_CATALOG[params.id as MinimaxCatalogId]; - return { - id: params.id, - name: params.name ?? catalog?.name ?? `MiniMax ${params.id}`, - reasoning: params.reasoning ?? catalog?.reasoning ?? false, - input: ["text"], - cost: params.cost, - contextWindow: params.contextWindow, - maxTokens: params.maxTokens, - }; -} - -export function buildMinimaxApiModelDefinition(modelId: string): ModelDefinitionConfig { - return buildMinimaxModelDefinition({ - id: modelId, - cost: MINIMAX_API_COST, - contextWindow: DEFAULT_MINIMAX_CONTEXT_WINDOW, - maxTokens: DEFAULT_MINIMAX_MAX_TOKENS, - }); -} - export function buildMoonshotModelDefinition(): ModelDefinitionConfig { - return { - id: MOONSHOT_DEFAULT_MODEL_ID, - name: "Kimi K2.5", - reasoning: false, - input: ["text", "image"], - cost: MOONSHOT_DEFAULT_COST, - contextWindow: MOONSHOT_DEFAULT_CONTEXT_WINDOW, - maxTokens: MOONSHOT_DEFAULT_MAX_TOKENS, - }; -} - -export const MISTRAL_BASE_URL = "https://api.mistral.ai/v1"; -export const MISTRAL_DEFAULT_MODEL_ID = "mistral-large-latest"; -export const MISTRAL_DEFAULT_MODEL_REF = `mistral/${MISTRAL_DEFAULT_MODEL_ID}`; -export const MISTRAL_DEFAULT_CONTEXT_WINDOW = 262144; -export const MISTRAL_DEFAULT_MAX_TOKENS = 262144; -export const MISTRAL_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -export function buildMistralModelDefinition(): ModelDefinitionConfig { - return { - id: MISTRAL_DEFAULT_MODEL_ID, - name: "Mistral Large", - reasoning: false, - input: ["text", "image"], - cost: MISTRAL_DEFAULT_COST, - contextWindow: MISTRAL_DEFAULT_CONTEXT_WINDOW, - maxTokens: MISTRAL_DEFAULT_MAX_TOKENS, - }; -} - -export function buildZaiModelDefinition(params: { - id: string; - name?: string; - reasoning?: boolean; - cost?: ModelDefinitionConfig["cost"]; - contextWindow?: number; - maxTokens?: number; -}): ModelDefinitionConfig { - const catalog = ZAI_MODEL_CATALOG[params.id as ZaiCatalogId]; - return { - id: params.id, - name: params.name ?? catalog?.name ?? `GLM ${params.id}`, - reasoning: params.reasoning ?? catalog?.reasoning ?? true, - input: ["text"], - cost: params.cost ?? ZAI_DEFAULT_COST, - contextWindow: params.contextWindow ?? 204800, - maxTokens: params.maxTokens ?? 131072, - }; -} - -export const XAI_BASE_URL = "https://api.x.ai/v1"; -export const XAI_DEFAULT_MODEL_ID = "grok-4"; -export const XAI_DEFAULT_MODEL_REF = `xai/${XAI_DEFAULT_MODEL_ID}`; -export const XAI_DEFAULT_CONTEXT_WINDOW = 131072; -export const XAI_DEFAULT_MAX_TOKENS = 8192; -export const XAI_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -export function buildXaiModelDefinition(): ModelDefinitionConfig { - return { - id: XAI_DEFAULT_MODEL_ID, - name: "Grok 4", - reasoning: false, - input: ["text"], - cost: XAI_DEFAULT_COST, - contextWindow: XAI_DEFAULT_CONTEXT_WINDOW, - maxTokens: XAI_DEFAULT_MAX_TOKENS, - }; + return buildMoonshotProvider().models[0]; } export function buildKilocodeModelDefinition(): ModelDefinitionConfig { @@ -228,105 +138,3 @@ export function buildKilocodeModelDefinition(): ModelDefinitionConfig { maxTokens: KILOCODE_DEFAULT_MAX_TOKENS, }; } - -// Alibaba Cloud Model Studio Coding Plan -export const MODELSTUDIO_CN_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1"; -export const MODELSTUDIO_GLOBAL_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1"; -export const MODELSTUDIO_DEFAULT_MODEL_ID = "qwen3.5-plus"; -export const MODELSTUDIO_DEFAULT_MODEL_REF = `modelstudio/${MODELSTUDIO_DEFAULT_MODEL_ID}`; -export const MODELSTUDIO_DEFAULT_COST = { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, -}; - -const MODELSTUDIO_MODEL_CATALOG = { - "qwen3.5-plus": { - name: "qwen3.5-plus", - reasoning: false, - input: ["text", "image"], - contextWindow: 1000000, - maxTokens: 65536, - }, - "qwen3-max-2026-01-23": { - name: "qwen3-max-2026-01-23", - reasoning: false, - input: ["text"], - contextWindow: 262144, - maxTokens: 65536, - }, - "qwen3-coder-next": { - name: "qwen3-coder-next", - reasoning: false, - input: ["text"], - contextWindow: 262144, - maxTokens: 65536, - }, - "qwen3-coder-plus": { - name: "qwen3-coder-plus", - reasoning: false, - input: ["text"], - contextWindow: 1000000, - maxTokens: 65536, - }, - "MiniMax-M2.5": { - name: "MiniMax-M2.5", - reasoning: false, - input: ["text"], - contextWindow: 1000000, - maxTokens: 65536, - }, - "glm-5": { - name: "glm-5", - reasoning: false, - input: ["text"], - contextWindow: 202752, - maxTokens: 16384, - }, - "glm-4.7": { - name: "glm-4.7", - reasoning: false, - input: ["text"], - contextWindow: 202752, - maxTokens: 16384, - }, - "kimi-k2.5": { - name: "kimi-k2.5", - reasoning: false, - input: ["text", "image"], - contextWindow: 262144, - maxTokens: 32768, - }, -} as const; - -type ModelStudioCatalogId = keyof typeof MODELSTUDIO_MODEL_CATALOG; - -export function buildModelStudioModelDefinition(params: { - id: string; - name?: string; - reasoning?: boolean; - input?: string[]; - cost?: ModelDefinitionConfig["cost"]; - contextWindow?: number; - maxTokens?: number; -}): ModelDefinitionConfig { - const catalog = MODELSTUDIO_MODEL_CATALOG[params.id as ModelStudioCatalogId]; - return { - id: params.id, - name: params.name ?? catalog?.name ?? params.id, - reasoning: params.reasoning ?? catalog?.reasoning ?? false, - input: - (params.input as ("text" | "image")[]) ?? - ([...(catalog?.input ?? ["text"])] as ("text" | "image")[]), - cost: params.cost ?? MODELSTUDIO_DEFAULT_COST, - contextWindow: params.contextWindow ?? catalog?.contextWindow ?? 262144, - maxTokens: params.maxTokens ?? catalog?.maxTokens ?? 65536, - }; -} - -export function buildModelStudioDefaultModelDefinition(): ModelDefinitionConfig { - return buildModelStudioModelDefinition({ - id: MODELSTUDIO_DEFAULT_MODEL_ID, - }); -} diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index f51e61a8cee..9a67a69a287 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -1,8 +1,5 @@ -export { - SYNTHETIC_DEFAULT_MODEL_ID, - SYNTHETIC_DEFAULT_MODEL_REF, -} from "../agents/synthetic-models.js"; -export { VENICE_DEFAULT_MODEL_ID, VENICE_DEFAULT_MODEL_REF } from "../agents/venice-models.js"; +export { SYNTHETIC_DEFAULT_MODEL_ID } from "../agents/synthetic-models.js"; +export { VENICE_DEFAULT_MODEL_ID } from "../agents/venice-models.js"; export { applyAuthProfileConfig, applyCloudflareAiGatewayConfig, @@ -61,8 +58,6 @@ export { applyOpencodeGoProviderConfig, } from "./onboard-auth.config-opencode-go.js"; export { - CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF, - KILOCODE_DEFAULT_MODEL_REF, LITELLM_DEFAULT_MODEL_REF, OPENROUTER_DEFAULT_MODEL_REF, setOpenaiApiKey, @@ -91,43 +86,64 @@ export { setXaiApiKey, setModelStudioApiKey, writeOAuthCredentials, - HUGGINGFACE_DEFAULT_MODEL_REF, - VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF, XIAOMI_DEFAULT_MODEL_REF, - ZAI_DEFAULT_MODEL_REF, - TOGETHER_DEFAULT_MODEL_REF, - MISTRAL_DEFAULT_MODEL_REF, - XAI_DEFAULT_MODEL_REF, - MODELSTUDIO_DEFAULT_MODEL_REF, } from "./onboard-auth.credentials.js"; +export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF } from "../../extensions/cloudflare-ai-gateway/onboard.js"; +export { HUGGINGFACE_DEFAULT_MODEL_REF } from "../../extensions/huggingface/onboard.js"; +export { KILOCODE_DEFAULT_MODEL_REF } from "../../extensions/kilocode/onboard.js"; +export { MISTRAL_DEFAULT_MODEL_REF } from "../../extensions/mistral/onboard.js"; +export { MODELSTUDIO_DEFAULT_MODEL_REF } from "../../extensions/modelstudio/onboard.js"; +export { SYNTHETIC_DEFAULT_MODEL_REF } from "../../extensions/synthetic/onboard.js"; +export { TOGETHER_DEFAULT_MODEL_REF } from "../../extensions/together/onboard.js"; +export { VENICE_DEFAULT_MODEL_REF } from "../../extensions/venice/onboard.js"; +export { VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF } from "../../extensions/vercel-ai-gateway/onboard.js"; +export { XAI_DEFAULT_MODEL_REF } from "../../extensions/xai/onboard.js"; +export { ZAI_DEFAULT_MODEL_REF } from "../../extensions/zai/onboard.js"; export { - buildKilocodeModelDefinition, buildMinimaxApiModelDefinition, buildMinimaxModelDefinition, - buildMistralModelDefinition, - buildMoonshotModelDefinition, - buildZaiModelDefinition, DEFAULT_MINIMAX_BASE_URL, - KILOCODE_DEFAULT_MODEL_ID, - MOONSHOT_CN_BASE_URL, - QIANFAN_BASE_URL, - QIANFAN_DEFAULT_MODEL_ID, - QIANFAN_DEFAULT_MODEL_REF, - KIMI_CODING_MODEL_ID, - KIMI_CODING_MODEL_REF, MINIMAX_API_BASE_URL, MINIMAX_CN_API_BASE_URL, MINIMAX_HOSTED_MODEL_ID, MINIMAX_HOSTED_MODEL_REF, - MOONSHOT_BASE_URL, - MOONSHOT_DEFAULT_MODEL_ID, - MOONSHOT_DEFAULT_MODEL_REF, +} from "../../extensions/minimax/model-definitions.js"; +export { KIMI_DEFAULT_MODEL_ID as KIMI_CODING_MODEL_ID } from "../../extensions/kimi-coding/provider-catalog.js"; +export { KIMI_CODING_MODEL_REF } from "../../extensions/kimi-coding/onboard.js"; +export { + buildMistralModelDefinition, MISTRAL_BASE_URL, MISTRAL_DEFAULT_MODEL_ID, +} from "../../extensions/mistral/model-definitions.js"; +export { + MOONSHOT_BASE_URL, + MOONSHOT_DEFAULT_MODEL_ID, +} from "../../extensions/moonshot/provider-catalog.js"; +export { + MOONSHOT_CN_BASE_URL, + MOONSHOT_DEFAULT_MODEL_REF, +} from "../../extensions/moonshot/onboard.js"; +export { + QIANFAN_BASE_URL, + QIANFAN_DEFAULT_MODEL_ID, +} from "../../extensions/qianfan/provider-catalog.js"; +export { QIANFAN_DEFAULT_MODEL_REF } from "../../extensions/qianfan/onboard.js"; +export { + buildXaiModelDefinition, + XAI_BASE_URL, + XAI_DEFAULT_MODEL_ID, +} from "../../extensions/xai/model-definitions.js"; +export { + buildZaiModelDefinition, resolveZaiBaseUrl, - ZAI_CODING_CN_BASE_URL, - ZAI_DEFAULT_MODEL_ID, - ZAI_CODING_GLOBAL_BASE_URL, ZAI_CN_BASE_URL, + ZAI_CODING_CN_BASE_URL, + ZAI_CODING_GLOBAL_BASE_URL, + ZAI_DEFAULT_MODEL_ID, ZAI_GLOBAL_BASE_URL, +} from "../../extensions/zai/model-definitions.js"; +export { + buildKilocodeModelDefinition, + buildMoonshotModelDefinition, + KILOCODE_DEFAULT_MODEL_ID, } from "./onboard-auth.models.js"; diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index abf8362d694..66050fe6f62 100644 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -22,6 +22,7 @@ type OnboardEnv = { configPath: string; runtime: NonInteractiveRuntime; }; +type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; const ensureWorkspaceAndSessionsMock = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => {})); @@ -61,7 +62,7 @@ type ProviderAuthConfigSnapshot = { }; }; -function createZaiFetchMock(responses: Record): typeof fetch { +function createZaiFetchMock(responses: Record): FetchLike { return vi.fn(async (input, init) => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : ""; const parsedBody = @@ -77,12 +78,12 @@ function createZaiFetchMock(responses: Record): typeof fetch { headers: { "content-type": "application/json" }, }, ); - }) as typeof fetch; + }); } async function withZaiProbeFetch( responses: Record, - run: (fetchMock: typeof fetch) => Promise, + run: (fetchMock: FetchLike) => Promise, ): Promise { const originalVitest = process.env.VITEST; delete process.env.VITEST; diff --git a/src/commands/zai-endpoint-detect.ts b/src/commands/zai-endpoint-detect.ts index b0799088559..4426b1065fe 100644 --- a/src/commands/zai-endpoint-detect.ts +++ b/src/commands/zai-endpoint-detect.ts @@ -1,10 +1,10 @@ -import { fetchWithTimeout } from "../utils/fetch-timeout.js"; import { ZAI_CN_BASE_URL, ZAI_CODING_CN_BASE_URL, ZAI_CODING_GLOBAL_BASE_URL, ZAI_GLOBAL_BASE_URL, -} from "./onboard-auth.models.js"; +} from "../../extensions/zai/model-definitions.js"; +import { fetchWithTimeout } from "../utils/fetch-timeout.js"; export type ZaiEndpointId = "global" | "cn" | "coding-global" | "coding-cn"; diff --git a/src/cron/isolated-agent/session.test.ts b/src/cron/isolated-agent/session.test.ts index fc75ed100f6..8310276d75a 100644 --- a/src/cron/isolated-agent/session.test.ts +++ b/src/cron/isolated-agent/session.test.ts @@ -63,7 +63,7 @@ describe("resolveCronSession", () => { modelOverride: "deepseek-v3-4bit-mlx", providerOverride: "inferencer", thinkingLevel: "high", - model: "k2p5", + model: "kimi-code", }, }); @@ -71,7 +71,7 @@ describe("resolveCronSession", () => { expect(result.sessionEntry.providerOverride).toBe("inferencer"); expect(result.sessionEntry.thinkingLevel).toBe("high"); // The model field (last-used model) should also be preserved - expect(result.sessionEntry.model).toBe("k2p5"); + expect(result.sessionEntry.model).toBe("kimi-code"); }); it("handles missing modelOverride gracefully", () => { diff --git a/src/gateway/gateway-cli-backend.live.test.ts b/src/gateway/gateway-cli-backend.live.test.ts index b0426c59175..d0d313cc455 100644 --- a/src/gateway/gateway-cli-backend.live.test.ts +++ b/src/gateway/gateway-cli-backend.live.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { parseModelRef } from "../agents/model-selection.js"; -import { loadConfig } from "../config/config.js"; +import { clearRuntimeConfigSnapshot, loadConfig } from "../config/config.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { getFreePortBlockWithPermissionFallback } from "../test-utils/ports.js"; import { GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; @@ -166,6 +166,7 @@ async function connectClient(params: { url: string; token: string }) { describeLive("gateway live (cli backend)", () => { it("runs the agent pipeline against the local CLI backend", async () => { + clearRuntimeConfigSnapshot(); const previous = { configPath: process.env.OPENCLAW_CONFIG_PATH, token: process.env.OPENCLAW_GATEWAY_TOKEN, @@ -384,6 +385,7 @@ describeLive("gateway live (cli backend)", () => { } } } finally { + clearRuntimeConfigSnapshot(); client.stop(); await server.close(); await fs.rm(tempDir, { recursive: true, force: true }); diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index 6a74c98da3b..973cf952d16 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -24,7 +24,7 @@ import { shouldSuppressBuiltInModel } from "../agents/model-suppression.js"; import { ensureOpenClawModelsJson } from "../agents/models-config.js"; import { isRateLimitErrorMessage } from "../agents/pi-embedded-helpers/errors.js"; import { discoverAuthStorage, discoverModels } from "../agents/pi-model-discovery.js"; -import { loadConfig } from "../config/config.js"; +import { clearRuntimeConfigSnapshot, loadConfig } from "../config/config.js"; import type { ModelsConfig, OpenClawConfig, ModelProviderConfig } from "../config/types.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; @@ -38,7 +38,7 @@ import { shouldRetryToolReadProbe, } from "./live-tool-probe-utils.js"; import { startGatewayServer } from "./server.js"; -import { extractPayloadText } from "./test-helpers.agent-results.js"; +import { loadSessionEntry, readSessionMessages } from "./session-utils.js"; const LIVE = isTruthyEnvValue(process.env.LIVE) || isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST); const GATEWAY_LIVE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_GATEWAY); @@ -171,6 +171,32 @@ function logProgress(message: string): void { console.log(`[live] ${message}`); } +function enterProductionEnvForLiveRun() { + const previous = { + vitest: process.env.VITEST, + nodeEnv: process.env.NODE_ENV, + }; + delete process.env.VITEST; + process.env.NODE_ENV = "production"; + return previous; +} + +function restoreProductionEnvForLiveRun(previous: { + vitest: string | undefined; + nodeEnv: string | undefined; +}) { + if (previous.vitest === undefined) { + delete process.env.VITEST; + } else { + process.env.VITEST = previous.vitest; + } + if (previous.nodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = previous.nodeEnv; + } +} + function formatFailurePreview( failures: Array<{ model: string; error: string }>, maxItems: number, @@ -319,25 +345,14 @@ async function runAnthropicRefusalProbe(params: { }): Promise { logProgress(`${params.label}: refusal-probe`); const magic = buildAnthropicRefusalToken(); - const runId = randomUUID(); - const probe = await withGatewayLiveProbeTimeout( - params.client.request( - "agent", - { - sessionKey: params.sessionKey, - idempotencyKey: `idem-${runId}-refusal`, - message: `Reply with the single word ok. Test token: ${magic}`, - thinking: params.thinkingLevel, - deliver: false, - }, - { expectFinal: true }, - ), - `${params.label}: refusal-probe`, - ); - if (probe?.status !== "ok") { - throw new Error(`refusal probe failed: status=${String(probe?.status)}`); - } - const probeText = extractPayloadText(probe?.result); + const probeText = await requestGatewayAgentText({ + client: params.client, + sessionKey: params.sessionKey, + idempotencyKey: `idem-${randomUUID()}-refusal`, + message: `Reply with the single word ok. Test token: ${magic}`, + thinkingLevel: params.thinkingLevel, + context: `${params.label}: refusal-probe`, + }); assertNoReasoningTags({ text: probeText, model: params.modelKey, @@ -348,25 +363,14 @@ async function runAnthropicRefusalProbe(params: { throw new Error(`refusal probe missing ok: ${probeText}`); } - const followupId = randomUUID(); - const followup = await withGatewayLiveProbeTimeout( - params.client.request( - "agent", - { - sessionKey: params.sessionKey, - idempotencyKey: `idem-${followupId}-refusal-followup`, - message: "Now reply with exactly: still ok.", - thinking: params.thinkingLevel, - deliver: false, - }, - { expectFinal: true }, - ), - `${params.label}: refusal-followup`, - ); - if (followup?.status !== "ok") { - throw new Error(`refusal followup failed: status=${String(followup?.status)}`); - } - const followupText = extractPayloadText(followup?.result); + const followupText = await requestGatewayAgentText({ + client: params.client, + sessionKey: params.sessionKey, + idempotencyKey: `idem-${randomUUID()}-refusal-followup`, + message: "Now reply with exactly: still ok.", + thinkingLevel: params.thinkingLevel, + context: `${params.label}: refusal-followup`, + }); assertNoReasoningTags({ text: followupText, model: params.modelKey, @@ -475,11 +479,6 @@ async function getFreeGatewayPort(): Promise { throw new Error("failed to acquire a free gateway port block"); } -type AgentFinalPayload = { - status?: unknown; - result?: unknown; -}; - async function connectClient(params: { url: string; token: string }) { return await new Promise((resolve, reject) => { let settled = false; @@ -513,6 +512,115 @@ async function connectClient(params: { url: string; token: string }) { }); } +function extractTranscriptMessageText(message: unknown): string { + if (!message || typeof message !== "object") { + return ""; + } + const record = message as { + text?: unknown; + content?: unknown; + }; + if (typeof record.text === "string" && record.text.trim()) { + return record.text.trim(); + } + if (typeof record.content === "string" && record.content.trim()) { + return record.content.trim(); + } + if (!Array.isArray(record.content)) { + return ""; + } + return record.content + .map((entry) => { + if (!entry || typeof entry !== "object") { + return ""; + } + const text = (entry as { text?: unknown }).text; + return typeof text === "string" && text.trim() ? text.trim() : ""; + }) + .filter(Boolean) + .join("\n") + .trim(); +} + +function readSessionAssistantTexts(sessionKey: string): string[] { + const { storePath, entry } = loadSessionEntry(sessionKey); + if (!entry?.sessionId) { + return []; + } + const messages = readSessionMessages(entry.sessionId, storePath, entry.sessionFile); + const assistantTexts: string[] = []; + for (const message of messages) { + if (!message || typeof message !== "object") { + continue; + } + const role = (message as { role?: unknown }).role; + if (role !== "assistant") { + continue; + } + assistantTexts.push(extractTranscriptMessageText(message)); + } + return assistantTexts; +} + +async function waitForSessionAssistantText(params: { + sessionKey: string; + baselineAssistantCount: number; + context: string; +}) { + const startedAt = Date.now(); + let delayMs = 50; + while (Date.now() - startedAt < GATEWAY_LIVE_PROBE_TIMEOUT_MS) { + const assistantTexts = readSessionAssistantTexts(params.sessionKey); + if (assistantTexts.length > params.baselineAssistantCount) { + const freshText = assistantTexts + .slice(params.baselineAssistantCount) + .map((text) => text.trim()) + .findLast((text) => text.length > 0); + if (freshText) { + return freshText; + } + } + await new Promise((resolve) => setTimeout(resolve, delayMs)); + delayMs = Math.min(delayMs * 2, 250); + } + throw new Error(`probe timeout after ${GATEWAY_LIVE_PROBE_TIMEOUT_MS}ms (${params.context})`); +} + +async function requestGatewayAgentText(params: { + client: GatewayClient; + sessionKey: string; + message: string; + thinkingLevel: string; + context: string; + idempotencyKey: string; + attachments?: Array<{ + mimeType: string; + fileName: string; + content: string; + }>; +}) { + const baselineAssistantCount = readSessionAssistantTexts(params.sessionKey).length; + const accepted = await withGatewayLiveProbeTimeout( + params.client.request<{ runId?: unknown; status?: unknown }>("agent", { + sessionKey: params.sessionKey, + idempotencyKey: params.idempotencyKey, + message: params.message, + thinking: params.thinkingLevel, + deliver: false, + attachments: params.attachments, + }), + `${params.context}: agent-accept`, + ); + if (accepted?.status !== "accepted") { + throw new Error(`agent status=${String(accepted?.status)}`); + } + return await waitForSessionAssistantText({ + sessionKey: params.sessionKey, + baselineAssistantCount, + context: `${params.context}: transcript-final`, + }); +} + type GatewayModelSuiteParams = { label: string; cfg: OpenClawConfig; @@ -636,6 +744,8 @@ function buildMinimaxProviderOverride(params: { } async function runGatewayModelSuite(params: GatewayModelSuiteParams) { + clearRuntimeConfigSnapshot(); + const runtimeEnv = enterProductionEnvForLiveRun(); const previous = { configPath: process.env.OPENCLAW_CONFIG_PATH, token: process.env.OPENCLAW_GATEWAY_TOKEN, @@ -793,48 +903,26 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { ); logProgress(`${progressLabel}: prompt`); - const runId = randomUUID(); - const payload = await withGatewayLiveProbeTimeout( - client.request( - "agent", - { - sessionKey, - idempotencyKey: `idem-${runId}`, - message: - "Explain in 2-3 sentences how the JavaScript event loop handles microtasks vs macrotasks. Must mention both words: microtask and macrotask.", - thinking: params.thinkingLevel, - deliver: false, - }, - { expectFinal: true }, - ), - `${progressLabel}: prompt`, - ); - - if (payload?.status !== "ok") { - throw new Error(`agent status=${String(payload?.status)}`); - } - let text = extractPayloadText(payload?.result); + let text = await requestGatewayAgentText({ + client, + sessionKey, + idempotencyKey: `idem-${randomUUID()}`, + message: + "Explain in 2-3 sentences how the JavaScript event loop handles microtasks vs macrotasks. Must mention both words: microtask and macrotask.", + thinkingLevel: params.thinkingLevel, + context: `${progressLabel}: prompt`, + }); if (!text) { logProgress(`${progressLabel}: empty response, retrying`); - const retry = await withGatewayLiveProbeTimeout( - client.request( - "agent", - { - sessionKey, - idempotencyKey: `idem-${randomUUID()}-retry`, - message: - "Explain in 2-3 sentences how the JavaScript event loop handles microtasks vs macrotasks. Must mention both words: microtask and macrotask.", - thinking: params.thinkingLevel, - deliver: false, - }, - { expectFinal: true }, - ), - `${progressLabel}: prompt-retry`, - ); - if (retry?.status !== "ok") { - throw new Error(`agent status=${String(retry?.status)}`); - } - text = extractPayloadText(retry?.result); + text = await requestGatewayAgentText({ + client, + sessionKey, + idempotencyKey: `idem-${randomUUID()}-retry`, + message: + "Explain in 2-3 sentences how the JavaScript event loop handles microtasks vs macrotasks. Must mention both words: microtask and macrotask.", + thinkingLevel: params.thinkingLevel, + context: `${progressLabel}: prompt-retry`, + }); } if (!text && isGoogleishProvider(model.provider)) { logProgress(`${progressLabel}: skip (google empty response)`); @@ -881,36 +969,20 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { toolReadAttempt += 1 ) { const strictReply = toolReadAttempt > 0; - const toolProbe = await withGatewayLiveProbeTimeout( - client.request( - "agent", - { - sessionKey, - idempotencyKey: `idem-${runIdTool}-tool-${toolReadAttempt + 1}`, - message: strictReply - ? "OpenClaw live tool probe (local, safe): " + - `use the tool named \`read\` (or \`Read\`) with JSON arguments {"path":"${toolProbePath}"}. ` + - `Then reply with exactly: ${nonceA} ${nonceB}. No extra text.` - : "OpenClaw live tool probe (local, safe): " + - `use the tool named \`read\` (or \`Read\`) with JSON arguments {"path":"${toolProbePath}"}. ` + - "Then reply with the two nonce values you read (include both).", - thinking: params.thinkingLevel, - deliver: false, - }, - { expectFinal: true }, - ), - `${progressLabel}: tool-read`, - ); - if (toolProbe?.status !== "ok") { - if (toolReadAttempt + 1 < maxToolReadAttempts) { - logProgress( - `${progressLabel}: tool-read retry (${toolReadAttempt + 2}/${maxToolReadAttempts}) status=${String(toolProbe?.status)}`, - ); - continue; - } - throw new Error(`tool probe failed: status=${String(toolProbe?.status)}`); - } - toolText = extractPayloadText(toolProbe?.result); + toolText = await requestGatewayAgentText({ + client, + sessionKey, + idempotencyKey: `idem-${runIdTool}-tool-${toolReadAttempt + 1}`, + message: strictReply + ? "OpenClaw live tool probe (local, safe): " + + `use the tool named \`read\` (or \`Read\`) with JSON arguments {"path":"${toolProbePath}"}. ` + + `Then reply with exactly: ${nonceA} ${nonceB}. No extra text.` + : "OpenClaw live tool probe (local, safe): " + + `use the tool named \`read\` (or \`Read\`) with JSON arguments {"path":"${toolProbePath}"}. ` + + "Then reply with the two nonce values you read (include both).", + thinkingLevel: params.thinkingLevel, + context: `${progressLabel}: tool-read`, + }); if ( isEmptyStreamText(toolText) && (model.provider === "minimax" || model.provider === "openai-codex") @@ -960,40 +1032,24 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { execReadAttempt += 1 ) { const strictReply = execReadAttempt > 0; - const execReadProbe = await withGatewayLiveProbeTimeout( - client.request( - "agent", - { - sessionKey, - idempotencyKey: `idem-${runIdTool}-exec-read-${execReadAttempt + 1}`, - message: strictReply - ? "OpenClaw live tool probe (local, safe): " + - "use the tool named `exec` (or `Exec`) to run this command: " + - `mkdir -p "${tempDir}" && printf '%s' '${nonceC}' > "${toolWritePath}". ` + - `Then use the tool named \`read\` (or \`Read\`) with JSON arguments {"path":"${toolWritePath}"}. ` + - `Then reply with exactly: ${nonceC}. No extra text.` - : "OpenClaw live tool probe (local, safe): " + - "use the tool named `exec` (or `Exec`) to run this command: " + - `mkdir -p "${tempDir}" && printf '%s' '${nonceC}' > "${toolWritePath}". ` + - `Then use the tool named \`read\` (or \`Read\`) with JSON arguments {"path":"${toolWritePath}"}. ` + - "Finally reply including the nonce text you read back.", - thinking: params.thinkingLevel, - deliver: false, - }, - { expectFinal: true }, - ), - `${progressLabel}: tool-exec`, - ); - if (execReadProbe?.status !== "ok") { - if (execReadAttempt + 1 < maxExecReadAttempts) { - logProgress( - `${progressLabel}: tool-exec retry (${execReadAttempt + 2}/${maxExecReadAttempts}) status=${String(execReadProbe?.status)}`, - ); - continue; - } - throw new Error(`exec+read probe failed: status=${String(execReadProbe?.status)}`); - } - execReadText = extractPayloadText(execReadProbe?.result); + execReadText = await requestGatewayAgentText({ + client, + sessionKey, + idempotencyKey: `idem-${runIdTool}-exec-read-${execReadAttempt + 1}`, + message: strictReply + ? "OpenClaw live tool probe (local, safe): " + + "use the tool named `exec` (or `Exec`) to run this command: " + + `mkdir -p "${tempDir}" && printf '%s' '${nonceC}' > "${toolWritePath}". ` + + `Then use the tool named \`read\` (or \`Read\`) with JSON arguments {"path":"${toolWritePath}"}. ` + + `Then reply with exactly: ${nonceC}. No extra text.` + : "OpenClaw live tool probe (local, safe): " + + "use the tool named `exec` (or `Exec`) to run this command: " + + `mkdir -p "${tempDir}" && printf '%s' '${nonceC}' > "${toolWritePath}". ` + + `Then use the tool named \`read\` (or \`Read\`) with JSON arguments {"path":"${toolWritePath}"}. ` + + "Finally reply including the nonce text you read back.", + thinkingLevel: params.thinkingLevel, + context: `${progressLabel}: tool-exec`, + }); if ( isEmptyStreamText(execReadText) && (model.provider === "minimax" || model.provider === "openai-codex") @@ -1040,62 +1096,51 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { const imageBase64 = renderCatNoncePngBase64(imageCode); const runIdImage = randomUUID(); - const imageProbe = await withGatewayLiveProbeTimeout( - client.request( - "agent", + const imageText = await requestGatewayAgentText({ + client, + sessionKey, + idempotencyKey: `idem-${runIdImage}-image`, + message: + "Look at the attached image. Reply with exactly two tokens separated by a single space: " + + "(1) the animal shown or written in the image, lowercase; " + + "(2) the code printed in the image, uppercase. No extra text.", + attachments: [ { - sessionKey, - idempotencyKey: `idem-${runIdImage}-image`, - message: - "Look at the attached image. Reply with exactly two tokens separated by a single space: " + - "(1) the animal shown or written in the image, lowercase; " + - "(2) the code printed in the image, uppercase. No extra text.", - attachments: [ - { - mimeType: "image/png", - fileName: `probe-${runIdImage}.png`, - content: imageBase64, - }, - ], - thinking: params.thinkingLevel, - deliver: false, + mimeType: "image/png", + fileName: `probe-${runIdImage}.png`, + content: imageBase64, }, - { expectFinal: true }, - ), - `${progressLabel}: image`, - ); + ], + thinkingLevel: params.thinkingLevel, + context: `${progressLabel}: image`, + }); // Best-effort: do not fail the whole live suite on flaky image handling. // (We still keep prompt + tool probes as hard checks.) - if (imageProbe?.status !== "ok") { - logProgress(`${progressLabel}: image skip (status=${String(imageProbe?.status)})`); + if ( + isEmptyStreamText(imageText) && + (model.provider === "minimax" || model.provider === "openai-codex") + ) { + logProgress(`${progressLabel}: image skip (${model.provider} empty response)`); } else { - const imageText = extractPayloadText(imageProbe?.result); - if ( - isEmptyStreamText(imageText) && - (model.provider === "minimax" || model.provider === "openai-codex") - ) { - logProgress(`${progressLabel}: image skip (${model.provider} empty response)`); + assertNoReasoningTags({ + text: imageText, + model: modelKey, + phase: "image", + label: params.label, + }); + if (!/\bcat\b/i.test(imageText)) { + logProgress(`${progressLabel}: image skip (missing 'cat')`); } else { - assertNoReasoningTags({ - text: imageText, - model: modelKey, - phase: "image", - label: params.label, - }); - if (!/\bcat\b/i.test(imageText)) { - logProgress(`${progressLabel}: image skip (missing 'cat')`); - } else { - const candidates = imageText.toUpperCase().match(/[A-Z0-9]{6,20}/g) ?? []; - const bestDistance = candidates.reduce((best, cand) => { - if (Math.abs(cand.length - imageCode.length) > 2) { - return best; - } - return Math.min(best, editDistance(cand, imageCode)); - }, Number.POSITIVE_INFINITY); - // OCR / image-read flake: allow a small edit distance, but still require the "cat" token above. - if (!(bestDistance <= 3)) { - logProgress(`${progressLabel}: image skip (code mismatch)`); + const candidates = imageText.toUpperCase().match(/[A-Z0-9]{6,20}/g) ?? []; + const bestDistance = candidates.reduce((best, cand) => { + if (Math.abs(cand.length - imageCode.length) > 2) { + return best; } + return Math.min(best, editDistance(cand, imageCode)); + }, Number.POSITIVE_INFINITY); + // OCR / image-read flake: allow a small edit distance, but still require the "cat" token above. + if (!(bestDistance <= 3)) { + logProgress(`${progressLabel}: image skip (code mismatch)`); } } } @@ -1108,24 +1153,14 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { ) { logProgress(`${progressLabel}: tool-only regression`); const runId2 = randomUUID(); - const first = await withGatewayLiveProbeTimeout( - client.request( - "agent", - { - sessionKey, - idempotencyKey: `idem-${runId2}-1`, - message: `Call the tool named \`read\` (or \`Read\`) on "${toolProbePath}". Do not write any other text.`, - thinking: params.thinkingLevel, - deliver: false, - }, - { expectFinal: true }, - ), - `${progressLabel}: tool-only-regression-first`, - ); - if (first?.status !== "ok") { - throw new Error(`tool-only turn failed: status=${String(first?.status)}`); - } - const firstText = extractPayloadText(first?.result); + const firstText = await requestGatewayAgentText({ + client, + sessionKey, + idempotencyKey: `idem-${runId2}-1`, + message: `Call the tool named \`read\` (or \`Read\`) on "${toolProbePath}". Do not write any other text.`, + thinkingLevel: params.thinkingLevel, + context: `${progressLabel}: tool-only-regression-first`, + }); assertNoReasoningTags({ text: firstText, model: modelKey, @@ -1133,24 +1168,14 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { label: params.label, }); - const second = await withGatewayLiveProbeTimeout( - client.request( - "agent", - { - sessionKey, - idempotencyKey: `idem-${runId2}-2`, - message: `Now answer: what are the values of nonceA and nonceB in "${toolProbePath}"? Reply with exactly: ${nonceA} ${nonceB}.`, - thinking: params.thinkingLevel, - deliver: false, - }, - { expectFinal: true }, - ), - `${progressLabel}: tool-only-regression-second`, - ); - if (second?.status !== "ok") { - throw new Error(`post-tool message failed: status=${String(second?.status)}`); - } - const reply = extractPayloadText(second?.result); + const reply = await requestGatewayAgentText({ + client, + sessionKey, + idempotencyKey: `idem-${runId2}-2`, + message: `Now answer: what are the values of nonceA and nonceB in "${toolProbePath}"? Reply with exactly: ${nonceA} ${nonceB}.`, + thinkingLevel: params.thinkingLevel, + context: `${progressLabel}: tool-only-regression-second`, + }); assertNoReasoningTags({ text: reply, model: modelKey, @@ -1290,6 +1315,8 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { logProgress(`[${params.label}] skipped all models (missing profiles)`); } } finally { + clearRuntimeConfigSnapshot(); + restoreProductionEnvForLiveRun(runtimeEnv); client.stop(); await server.close({ reason: "live test complete" }); await fs.rm(toolProbePath, { force: true }); @@ -1317,6 +1344,7 @@ describeLive("gateway live (dev agent, profile keys)", () => { it( "runs meaningful prompts across models with available keys", async () => { + clearRuntimeConfigSnapshot(); const cfg = loadConfig(); await ensureOpenClawModelsJson(cfg); @@ -1422,6 +1450,8 @@ describeLive("gateway live (dev agent, profile keys)", () => { if (!ZAI_FALLBACK) { return; } + clearRuntimeConfigSnapshot(); + const runtimeEnv = enterProductionEnvForLiveRun(); const previous = { configPath: process.env.OPENCLAW_CONFIG_PATH, token: process.env.OPENCLAW_GATEWAY_TOKEN, @@ -1520,27 +1550,16 @@ describeLive("gateway live (dev agent, profile keys)", () => { "zai-fallback: sessions-reset", ); - const runId = randomUUID(); - const toolProbe = await withGatewayLiveProbeTimeout( - client.request( - "agent", - { - sessionKey, - idempotencyKey: `idem-${runId}-tool`, - message: - `Call the tool named \`read\` (or \`Read\` if \`read\` is unavailable) with JSON arguments {"path":"${toolProbePath}"}. ` + - `Then reply with exactly: ${nonceA} ${nonceB}. No extra text.`, - thinking: THINKING_LEVEL, - deliver: false, - }, - { expectFinal: true }, - ), - "zai-fallback: tool-probe", - ); - if (toolProbe?.status !== "ok") { - throw new Error(`anthropic tool probe failed: status=${String(toolProbe?.status)}`); - } - const toolText = extractPayloadText(toolProbe?.result); + const toolText = await requestGatewayAgentText({ + client, + sessionKey, + idempotencyKey: `idem-${randomUUID()}-tool`, + message: + `Call the tool named \`read\` (or \`Read\` if \`read\` is unavailable) with JSON arguments {"path":"${toolProbePath}"}. ` + + `Then reply with exactly: ${nonceA} ${nonceB}. No extra text.`, + thinkingLevel: THINKING_LEVEL, + context: "zai-fallback: tool-probe", + }); assertNoReasoningTags({ text: toolText, model: "anthropic/claude-opus-4-5", @@ -1559,27 +1578,16 @@ describeLive("gateway live (dev agent, profile keys)", () => { "zai-fallback: sessions-patch-zai", ); - const followupId = randomUUID(); - const followup = await withGatewayLiveProbeTimeout( - client.request( - "agent", - { - sessionKey, - idempotencyKey: `idem-${followupId}-followup`, - message: - `What are the values of nonceA and nonceB in "${toolProbePath}"? ` + - `Reply with exactly: ${nonceA} ${nonceB}.`, - thinking: THINKING_LEVEL, - deliver: false, - }, - { expectFinal: true }, - ), - "zai-fallback: followup", - ); - if (followup?.status !== "ok") { - throw new Error(`zai followup failed: status=${String(followup?.status)}`); - } - const followupText = extractPayloadText(followup?.result); + const followupText = await requestGatewayAgentText({ + client, + sessionKey, + idempotencyKey: `idem-${randomUUID()}-followup`, + message: + `What are the values of nonceA and nonceB in "${toolProbePath}"? ` + + `Reply with exactly: ${nonceA} ${nonceB}.`, + thinkingLevel: THINKING_LEVEL, + context: "zai-fallback: followup", + }); assertNoReasoningTags({ text: followupText, model: "zai/glm-4.7", @@ -1590,6 +1598,8 @@ describeLive("gateway live (dev agent, profile keys)", () => { throw new Error(`zai followup missing nonce: ${followupText}`); } } finally { + clearRuntimeConfigSnapshot(); + restoreProductionEnvForLiveRun(runtimeEnv); client.stop(); await server.close({ reason: "live test complete" }); await fs.rm(toolProbePath, { force: true }); diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index 58f5c9da4eb..184cb706762 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -30,6 +30,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ commands: [], providers: [], speechProviders: [], + mediaUnderstandingProviders: [], webSearchProviders: [], gatewayHandlers: {}, httpRoutes: [], diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index e05fcc85320..3617bc896bd 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -147,6 +147,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({ channelSetups: [], providers: [], speechProviders: [], + mediaUnderstandingProviders: [], webSearchProviders: [], gatewayHandlers: {}, httpRoutes: [], diff --git a/src/infra/gaxios-fetch-compat.test.ts b/src/infra/gaxios-fetch-compat.test.ts index b3cbf68a1ab..7d4c0dd402a 100644 --- a/src/infra/gaxios-fetch-compat.test.ts +++ b/src/infra/gaxios-fetch-compat.test.ts @@ -3,6 +3,7 @@ import { ProxyAgent } from "undici"; import { afterEach, describe, expect, it, vi } from "vitest"; const TEST_GAXIOS_CONSTRUCTOR_OVERRIDE = "__OPENCLAW_TEST_GAXIOS_CONSTRUCTOR__"; +type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; describe("gaxios fetch compat", () => { afterEach(() => { @@ -14,14 +15,14 @@ describe("gaxios fetch compat", () => { it("uses native fetch without defining window or importing node-fetch", async () => { type MockRequestConfig = RequestInit & { - fetchImplementation?: typeof fetch; + fetchImplementation?: FetchLike; responseType?: string; url: string; }; let MockGaxiosCtor!: new () => { request(config: MockRequestConfig): Promise<{ data: string } & object>; }; - const fetchMock = vi.fn(async () => { + const fetchMock = vi.fn(async () => { return new Response("ok", { headers: { "content-type": "text/plain" }, status: 200, @@ -64,14 +65,14 @@ describe("gaxios fetch compat", () => { it("falls back to a legacy window fetch shim when gaxios is unavailable", async () => { const originalWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, "window"); - vi.stubGlobal("fetch", vi.fn()); + vi.stubGlobal("fetch", vi.fn()); Reflect.deleteProperty(globalThis as object, "window"); (globalThis as Record)[TEST_GAXIOS_CONSTRUCTOR_OVERRIDE] = null; const { installGaxiosFetchCompat } = await import("./gaxios-fetch-compat.js"); try { await expect(installGaxiosFetchCompat()).resolves.toBeUndefined(); - expect((globalThis as { window?: { fetch?: typeof fetch } }).window?.fetch).toBe(fetch); + expect((globalThis as { window?: { fetch?: FetchLike } }).window?.fetch).toBe(fetch); await expect(installGaxiosFetchCompat()).resolves.toBeUndefined(); } finally { Reflect.deleteProperty(globalThis as object, "window"); @@ -82,7 +83,7 @@ describe("gaxios fetch compat", () => { }); it("translates proxy agents into undici dispatchers for native fetch", async () => { - const fetchMock = vi.fn(async () => { + const fetchMock = vi.fn(async () => { return new Response("ok", { headers: { "content-type": "text/plain" }, status: 200, diff --git a/src/infra/gaxios-fetch-compat.ts b/src/infra/gaxios-fetch-compat.ts index 6f9d34bf7af..0d5c0684090 100644 --- a/src/infra/gaxios-fetch-compat.ts +++ b/src/infra/gaxios-fetch-compat.ts @@ -7,12 +7,13 @@ import { Agent as UndiciAgent, ProxyAgent } from "undici"; type ProxyRule = RegExp | URL | string; type TlsCert = ConnectionOptions["cert"]; type TlsKey = ConnectionOptions["key"]; +type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; type GaxiosFetchRequestInit = RequestInit & { agent?: unknown; cert?: TlsCert; dispatcher?: Dispatcher; - fetchImplementation?: typeof fetch; + fetchImplementation?: FetchLike; key?: TlsKey; noProxy?: ProxyRule[]; proxy?: string | URL; @@ -240,7 +241,9 @@ function installLegacyWindowFetchShim(): void { (globalThis as Record).window = { fetch: globalThis.fetch }; } -export function createGaxiosCompatFetch(baseFetch: typeof fetch = globalThis.fetch): typeof fetch { +export function createGaxiosCompatFetch( + baseFetch: FetchLike = globalThis.fetch.bind(globalThis), +): FetchLike { return async (input: RequestInfo | URL, init?: RequestInit): Promise => { const gaxiosInit = (init ?? {}) as GaxiosFetchRequestInit; const requestUrl = diff --git a/src/infra/outbound/outbound-session.ts b/src/infra/outbound/outbound-session.ts index c8da99c5f66..a65e2da313e 100644 --- a/src/infra/outbound/outbound-session.ts +++ b/src/infra/outbound/outbound-session.ts @@ -8,6 +8,7 @@ import { buildAgentSessionKey, type RoutePeer } from "../../routing/resolve-rout import { resolveThreadSessionKeys } from "../../routing/session-key.js"; import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; import type { ResolvedMessagingTarget } from "./target-resolver.js"; +import { normalizeOutboundThreadId } from "./thread-id.js"; export type OutboundSessionRoute = { sessionKey: string; @@ -30,20 +31,6 @@ export type ResolveOutboundSessionRouteParams = { threadId?: string | number | null; }; -function normalizeThreadId(value?: string | number | null): string | undefined { - if (value == null) { - return undefined; - } - if (typeof value === "number") { - if (!Number.isFinite(value)) { - return undefined; - } - return String(Math.trunc(value)); - } - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; -} - function stripProviderPrefix(raw: string, channel: string): string { const trimmed = raw.trim(); const lower = trimmed.toLowerCase(); @@ -240,7 +227,7 @@ function resolveMattermostSession( channel: "mattermost", peer: { kind: isUser ? "direct" : "channel", id: rawId }, }); - const threadId = normalizeThreadId(params.replyToId ?? params.threadId); + const threadId = normalizeOutboundThreadId(params.replyToId ?? params.threadId); const threadKeys = resolveThreadSessionKeys({ baseSessionKey, threadId, diff --git a/src/infra/outbound/thread-id.test.ts b/src/infra/outbound/thread-id.test.ts new file mode 100644 index 00000000000..a872c0d78d7 --- /dev/null +++ b/src/infra/outbound/thread-id.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { normalizeOutboundThreadId } from "./thread-id.js"; + +describe("normalizeOutboundThreadId", () => { + it("returns undefined for missing values", () => { + expect(normalizeOutboundThreadId()).toBeUndefined(); + expect(normalizeOutboundThreadId(null)).toBeUndefined(); + expect(normalizeOutboundThreadId(" ")).toBeUndefined(); + }); + + it("normalizes numbers and trims strings", () => { + expect(normalizeOutboundThreadId(123.9)).toBe("123"); + expect(normalizeOutboundThreadId(" 456 ")).toBe("456"); + }); + + it("drops non-finite numeric values", () => { + expect(normalizeOutboundThreadId(Number.NaN)).toBeUndefined(); + expect(normalizeOutboundThreadId(Number.POSITIVE_INFINITY)).toBeUndefined(); + }); +}); diff --git a/src/infra/outbound/thread-id.ts b/src/infra/outbound/thread-id.ts new file mode 100644 index 00000000000..287ce99d34a --- /dev/null +++ b/src/infra/outbound/thread-id.ts @@ -0,0 +1,13 @@ +export function normalizeOutboundThreadId(value?: string | number | null): string | undefined { + if (value == null) { + return undefined; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + return undefined; + } + return String(Math.trunc(value)); + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} diff --git a/src/media-understanding/providers/anthropic/index.ts b/src/media-understanding/providers/anthropic/index.ts deleted file mode 100644 index 35ae04a921e..00000000000 --- a/src/media-understanding/providers/anthropic/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { MediaUnderstandingProvider } from "../../types.js"; -import { describeImageWithModel } from "../image.js"; - -export const anthropicProvider: MediaUnderstandingProvider = { - id: "anthropic", - capabilities: ["image"], - describeImage: describeImageWithModel, -}; diff --git a/src/media-understanding/providers/google/audio.ts b/src/media-understanding/providers/google/audio.ts deleted file mode 100644 index 5173ad3f093..00000000000 --- a/src/media-understanding/providers/google/audio.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { AudioTranscriptionRequest, AudioTranscriptionResult } from "../../types.js"; -import { generateGeminiInlineDataText } from "./inline-data.js"; - -export const DEFAULT_GOOGLE_AUDIO_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; -const DEFAULT_GOOGLE_AUDIO_MODEL = "gemini-3-flash-preview"; -const DEFAULT_GOOGLE_AUDIO_PROMPT = "Transcribe the audio."; - -export async function transcribeGeminiAudio( - params: AudioTranscriptionRequest, -): Promise { - const { text, model } = await generateGeminiInlineDataText({ - ...params, - defaultBaseUrl: DEFAULT_GOOGLE_AUDIO_BASE_URL, - defaultModel: DEFAULT_GOOGLE_AUDIO_MODEL, - defaultPrompt: DEFAULT_GOOGLE_AUDIO_PROMPT, - defaultMime: "audio/wav", - httpErrorLabel: "Audio transcription failed", - missingTextError: "Audio transcription response missing text", - }); - return { text, model }; -} diff --git a/src/media-understanding/providers/google/index.ts b/src/media-understanding/providers/google/index.ts deleted file mode 100644 index 50674aac396..00000000000 --- a/src/media-understanding/providers/google/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { MediaUnderstandingProvider } from "../../types.js"; -import { describeImageWithModel } from "../image.js"; -import { transcribeGeminiAudio } from "./audio.js"; -import { describeGeminiVideo } from "./video.js"; - -export const googleProvider: MediaUnderstandingProvider = { - id: "google", - capabilities: ["image", "audio", "video"], - describeImage: describeImageWithModel, - transcribeAudio: transcribeGeminiAudio, - describeVideo: describeGeminiVideo, -}; diff --git a/src/media-understanding/providers/google/inline-data.ts b/src/media-understanding/providers/google/inline-data.ts deleted file mode 100644 index 18116a54bc2..00000000000 --- a/src/media-understanding/providers/google/inline-data.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { normalizeGoogleModelId } from "../../../agents/model-id-normalization.js"; -import { parseGeminiAuth } from "../../../infra/gemini-auth.js"; -import { assertOkOrThrowHttpError, normalizeBaseUrl, postJsonRequest } from "../shared.js"; - -export async function generateGeminiInlineDataText(params: { - buffer: Buffer; - mime?: string; - apiKey: string; - baseUrl?: string; - headers?: Record; - model?: string; - prompt?: string; - timeoutMs: number; - fetchFn?: typeof fetch; - defaultBaseUrl: string; - defaultModel: string; - defaultPrompt: string; - defaultMime: string; - httpErrorLabel: string; - missingTextError: string; -}): Promise<{ text: string; model: string }> { - const fetchFn = params.fetchFn ?? fetch; - const baseUrl = normalizeBaseUrl(params.baseUrl, params.defaultBaseUrl); - const allowPrivate = Boolean(params.baseUrl?.trim()); - const model = (() => { - const trimmed = params.model?.trim(); - if (!trimmed) { - return params.defaultModel; - } - return normalizeGoogleModelId(trimmed); - })(); - const url = `${baseUrl}/models/${model}:generateContent`; - - const authHeaders = parseGeminiAuth(params.apiKey); - const headers = new Headers(params.headers); - for (const [key, value] of Object.entries(authHeaders.headers)) { - if (!headers.has(key)) { - headers.set(key, value); - } - } - - const prompt = (() => { - const trimmed = params.prompt?.trim(); - return trimmed || params.defaultPrompt; - })(); - - const body = { - contents: [ - { - role: "user", - parts: [ - { text: prompt }, - { - inline_data: { - mime_type: params.mime ?? params.defaultMime, - data: params.buffer.toString("base64"), - }, - }, - ], - }, - ], - }; - - const { response: res, release } = await postJsonRequest({ - url, - headers, - body, - timeoutMs: params.timeoutMs, - fetchFn, - allowPrivateNetwork: allowPrivate, - }); - - try { - await assertOkOrThrowHttpError(res, params.httpErrorLabel); - - const payload = (await res.json()) as { - candidates?: Array<{ - content?: { parts?: Array<{ text?: string }> }; - }>; - }; - const parts = payload.candidates?.[0]?.content?.parts ?? []; - const text = parts - .map((part) => part?.text?.trim()) - .filter(Boolean) - .join("\n"); - if (!text) { - throw new Error(params.missingTextError); - } - return { text, model }; - } finally { - await release(); - } -} diff --git a/src/media-understanding/providers/google/video.test.ts b/src/media-understanding/providers/google/video.test.ts index 772d01e2d70..c4307e4caad 100644 --- a/src/media-understanding/providers/google/video.test.ts +++ b/src/media-understanding/providers/google/video.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describeGeminiVideo } from "../../../../extensions/google/media-understanding-provider.js"; import * as ssrf from "../../../infra/net/ssrf.js"; import { withFetchPreconnect } from "../../../test-utils/fetch-mock.js"; import { createRequestCaptureJsonFetch } from "../audio.test-helpers.js"; -import { describeGeminiVideo } from "./video.js"; const TEST_NET_IP = "203.0.113.10"; diff --git a/src/media-understanding/providers/google/video.ts b/src/media-understanding/providers/google/video.ts deleted file mode 100644 index edbeccf0288..00000000000 --- a/src/media-understanding/providers/google/video.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { VideoDescriptionRequest, VideoDescriptionResult } from "../../types.js"; -import { generateGeminiInlineDataText } from "./inline-data.js"; - -export const DEFAULT_GOOGLE_VIDEO_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"; -const DEFAULT_GOOGLE_VIDEO_MODEL = "gemini-3-flash-preview"; -const DEFAULT_GOOGLE_VIDEO_PROMPT = "Describe the video."; - -export async function describeGeminiVideo( - params: VideoDescriptionRequest, -): Promise { - const { text, model } = await generateGeminiInlineDataText({ - ...params, - defaultBaseUrl: DEFAULT_GOOGLE_VIDEO_BASE_URL, - defaultModel: DEFAULT_GOOGLE_VIDEO_MODEL, - defaultPrompt: DEFAULT_GOOGLE_VIDEO_PROMPT, - defaultMime: "video/mp4", - httpErrorLabel: "Video description failed", - missingTextError: "Video description response missing text", - }); - return { text, model }; -} diff --git a/src/media-understanding/providers/groq/index.ts b/src/media-understanding/providers/groq/index.ts index 5f59e5702ab..0e4a2ec33e4 100644 --- a/src/media-understanding/providers/groq/index.ts +++ b/src/media-understanding/providers/groq/index.ts @@ -1,7 +1,8 @@ import type { MediaUnderstandingProvider } from "../../types.js"; -import { transcribeOpenAiCompatibleAudio } from "../openai/audio.js"; +import { transcribeOpenAiCompatibleAudio } from "../openai-compatible-audio.js"; const DEFAULT_GROQ_AUDIO_BASE_URL = "https://api.groq.com/openai/v1"; +const DEFAULT_GROQ_AUDIO_MODEL = "whisper-large-v3-turbo"; export const groqProvider: MediaUnderstandingProvider = { id: "groq", @@ -10,5 +11,7 @@ export const groqProvider: MediaUnderstandingProvider = { transcribeOpenAiCompatibleAudio({ ...req, baseUrl: req.baseUrl ?? DEFAULT_GROQ_AUDIO_BASE_URL, + defaultBaseUrl: DEFAULT_GROQ_AUDIO_BASE_URL, + defaultModel: DEFAULT_GROQ_AUDIO_MODEL, }), }; diff --git a/src/media-understanding/providers/index.test.ts b/src/media-understanding/providers/index.test.ts index 9294d44acd5..31bc041a608 100644 --- a/src/media-understanding/providers/index.test.ts +++ b/src/media-understanding/providers/index.test.ts @@ -1,35 +1,63 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import { createEmptyPluginRegistry } from "../../plugins/registry.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { buildMediaUnderstandingRegistry, getMediaUnderstandingProvider } from "./index.js"; describe("media-understanding provider registry", () => { - it("registers the Mistral provider", () => { - const registry = buildMediaUnderstandingRegistry(); - const provider = getMediaUnderstandingProvider("mistral", registry); - - expect(provider?.id).toBe("mistral"); - expect(provider?.capabilities).toEqual(["audio"]); + afterEach(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); }); - it("keeps provider id normalization behavior", () => { + it("keeps core-owned fallback providers registered by default", () => { + const registry = buildMediaUnderstandingRegistry(); + const groqProvider = getMediaUnderstandingProvider("groq", registry); + const deepgramProvider = getMediaUnderstandingProvider("deepgram", registry); + + expect(groqProvider?.id).toBe("groq"); + expect(groqProvider?.capabilities).toEqual(["audio"]); + expect(deepgramProvider?.id).toBe("deepgram"); + expect(deepgramProvider?.capabilities).toEqual(["audio"]); + }); + + it("merges plugin-registered media providers into the active registry", async () => { + const pluginRegistry = createEmptyPluginRegistry(); + pluginRegistry.mediaUnderstandingProviders.push({ + pluginId: "google", + pluginName: "Google Plugin", + source: "test", + provider: { + id: "google", + capabilities: ["image", "audio", "video"], + describeImage: async () => ({ text: "plugin image" }), + transcribeAudio: async () => ({ text: "plugin audio" }), + describeVideo: async () => ({ text: "plugin video" }), + }, + }); + setActivePluginRegistry(pluginRegistry); + + const registry = buildMediaUnderstandingRegistry(); + const provider = getMediaUnderstandingProvider("gemini", registry); + + expect(provider?.id).toBe("google"); + expect(await provider?.describeVideo?.({} as never)).toEqual({ text: "plugin video" }); + }); + + it("keeps provider id normalization behavior for plugin-owned providers", () => { + const pluginRegistry = createEmptyPluginRegistry(); + pluginRegistry.mediaUnderstandingProviders.push({ + pluginId: "google", + pluginName: "Google Plugin", + source: "test", + provider: { + id: "google", + capabilities: ["image", "audio", "video"], + }, + }); + setActivePluginRegistry(pluginRegistry); + const registry = buildMediaUnderstandingRegistry(); const provider = getMediaUnderstandingProvider("gemini", registry); expect(provider?.id).toBe("google"); }); - - it("registers the Moonshot provider", () => { - const registry = buildMediaUnderstandingRegistry(); - const provider = getMediaUnderstandingProvider("moonshot", registry); - - expect(provider?.id).toBe("moonshot"); - expect(provider?.capabilities).toEqual(["image", "video"]); - }); - - it("registers the minimax portal provider", () => { - const registry = buildMediaUnderstandingRegistry(); - const provider = getMediaUnderstandingProvider("minimax-portal", registry); - - expect(provider?.id).toBe("minimax-portal"); - expect(provider?.capabilities).toEqual(["image"]); - }); }); diff --git a/src/media-understanding/providers/index.ts b/src/media-understanding/providers/index.ts index 0ceaa78fd80..67a45fc2019 100644 --- a/src/media-understanding/providers/index.ts +++ b/src/media-understanding/providers/index.ts @@ -1,27 +1,26 @@ import { normalizeProviderId } from "../../agents/model-selection.js"; +import { getActivePluginRegistry } from "../../plugins/runtime.js"; import type { MediaUnderstandingProvider } from "../types.js"; -import { anthropicProvider } from "./anthropic/index.js"; import { deepgramProvider } from "./deepgram/index.js"; -import { googleProvider } from "./google/index.js"; import { groqProvider } from "./groq/index.js"; -import { minimaxPortalProvider, minimaxProvider } from "./minimax/index.js"; -import { mistralProvider } from "./mistral/index.js"; -import { moonshotProvider } from "./moonshot/index.js"; -import { openaiProvider } from "./openai/index.js"; -import { zaiProvider } from "./zai/index.js"; -const PROVIDERS: MediaUnderstandingProvider[] = [ - groqProvider, - openaiProvider, - googleProvider, - anthropicProvider, - minimaxProvider, - minimaxPortalProvider, - moonshotProvider, - mistralProvider, - zaiProvider, - deepgramProvider, -]; +const PROVIDERS: MediaUnderstandingProvider[] = [groqProvider, deepgramProvider]; + +function mergeProviderIntoRegistry( + registry: Map, + provider: MediaUnderstandingProvider, +) { + const normalizedKey = normalizeMediaProviderId(provider.id); + const existing = registry.get(normalizedKey); + const merged = existing + ? { + ...existing, + ...provider, + capabilities: provider.capabilities ?? existing.capabilities, + } + : provider; + registry.set(normalizedKey, merged); +} export function normalizeMediaProviderId(id: string): string { const normalized = normalizeProviderId(id); @@ -36,7 +35,10 @@ export function buildMediaUnderstandingRegistry( ): Map { const registry = new Map(); for (const provider of PROVIDERS) { - registry.set(normalizeMediaProviderId(provider.id), provider); + mergeProviderIntoRegistry(registry, provider); + } + for (const entry of getActivePluginRegistry()?.mediaUnderstandingProviders ?? []) { + mergeProviderIntoRegistry(registry, entry.provider); } if (overrides) { for (const [key, provider] of Object.entries(overrides)) { diff --git a/src/media-understanding/providers/minimax/index.ts b/src/media-understanding/providers/minimax/index.ts deleted file mode 100644 index c9a7936f4d3..00000000000 --- a/src/media-understanding/providers/minimax/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { MediaUnderstandingProvider } from "../../types.js"; -import { describeImageWithModel } from "../image.js"; - -export const minimaxProvider: MediaUnderstandingProvider = { - id: "minimax", - capabilities: ["image"], - describeImage: describeImageWithModel, -}; - -export const minimaxPortalProvider: MediaUnderstandingProvider = { - id: "minimax-portal", - capabilities: ["image"], - describeImage: describeImageWithModel, -}; diff --git a/src/media-understanding/providers/mistral/index.test.ts b/src/media-understanding/providers/mistral/index.test.ts index b368e516667..1afa3bd9265 100644 --- a/src/media-understanding/providers/mistral/index.test.ts +++ b/src/media-understanding/providers/mistral/index.test.ts @@ -1,23 +1,23 @@ import { describe, expect, it } from "vitest"; +import { mistralMediaUnderstandingProvider } from "../../../../extensions/mistral/media-understanding-provider.js"; import { createRequestCaptureJsonFetch, installPinnedHostnameTestHooks, } from "../audio.test-helpers.js"; -import { mistralProvider } from "./index.js"; installPinnedHostnameTestHooks(); -describe("mistralProvider", () => { +describe("mistralMediaUnderstandingProvider", () => { it("has expected provider metadata", () => { - expect(mistralProvider.id).toBe("mistral"); - expect(mistralProvider.capabilities).toEqual(["audio"]); - expect(mistralProvider.transcribeAudio).toBeDefined(); + expect(mistralMediaUnderstandingProvider.id).toBe("mistral"); + expect(mistralMediaUnderstandingProvider.capabilities).toEqual(["audio"]); + expect(mistralMediaUnderstandingProvider.transcribeAudio).toBeDefined(); }); it("uses Mistral base URL by default", async () => { const { fetchFn, getRequest } = createRequestCaptureJsonFetch({ text: "bonjour" }); - const result = await mistralProvider.transcribeAudio!({ + const result = await mistralMediaUnderstandingProvider.transcribeAudio!({ buffer: Buffer.from("audio-bytes"), fileName: "voice.ogg", apiKey: "test-mistral-key", // pragma: allowlist secret @@ -32,7 +32,7 @@ describe("mistralProvider", () => { it("allows overriding baseUrl", async () => { const { fetchFn, getRequest } = createRequestCaptureJsonFetch({ text: "ok" }); - await mistralProvider.transcribeAudio!({ + await mistralMediaUnderstandingProvider.transcribeAudio!({ buffer: Buffer.from("audio"), fileName: "note.mp3", apiKey: "key", // pragma: allowlist secret diff --git a/src/media-understanding/providers/mistral/index.ts b/src/media-understanding/providers/mistral/index.ts deleted file mode 100644 index ae146d84c80..00000000000 --- a/src/media-understanding/providers/mistral/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { MediaUnderstandingProvider } from "../../types.js"; -import { transcribeOpenAiCompatibleAudio } from "../openai/audio.js"; - -const DEFAULT_MISTRAL_AUDIO_BASE_URL = "https://api.mistral.ai/v1"; - -export const mistralProvider: MediaUnderstandingProvider = { - id: "mistral", - capabilities: ["audio"], - transcribeAudio: (req) => - transcribeOpenAiCompatibleAudio({ - ...req, - baseUrl: req.baseUrl ?? DEFAULT_MISTRAL_AUDIO_BASE_URL, - }), -}; diff --git a/src/media-understanding/providers/moonshot/index.ts b/src/media-understanding/providers/moonshot/index.ts deleted file mode 100644 index 78a525129dc..00000000000 --- a/src/media-understanding/providers/moonshot/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { MediaUnderstandingProvider } from "../../types.js"; -import { describeImageWithModel } from "../image.js"; -import { describeMoonshotVideo } from "./video.js"; - -export const moonshotProvider: MediaUnderstandingProvider = { - id: "moonshot", - capabilities: ["image", "video"], - describeImage: describeImageWithModel, - describeVideo: describeMoonshotVideo, -}; diff --git a/src/media-understanding/providers/moonshot/video.test.ts b/src/media-understanding/providers/moonshot/video.test.ts index f6ffb1ca957..0306e7927ca 100644 --- a/src/media-understanding/providers/moonshot/video.test.ts +++ b/src/media-understanding/providers/moonshot/video.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; +import { describeMoonshotVideo } from "../../../../extensions/moonshot/media-understanding-provider.js"; import { createRequestCaptureJsonFetch, installPinnedHostnameTestHooks, } from "../audio.test-helpers.js"; -import { describeMoonshotVideo } from "./video.js"; installPinnedHostnameTestHooks(); diff --git a/src/media-understanding/providers/openai/audio.ts b/src/media-understanding/providers/openai-compatible-audio.ts similarity index 78% rename from src/media-understanding/providers/openai/audio.ts rename to src/media-understanding/providers/openai-compatible-audio.ts index 26db4b0c201..669f8ddc873 100644 --- a/src/media-understanding/providers/openai/audio.ts +++ b/src/media-understanding/providers/openai-compatible-audio.ts @@ -1,29 +1,31 @@ import path from "node:path"; -import type { AudioTranscriptionRequest, AudioTranscriptionResult } from "../../types.js"; +import type { AudioTranscriptionRequest, AudioTranscriptionResult } from "../types.js"; import { assertOkOrThrowHttpError, normalizeBaseUrl, postTranscriptionRequest, requireTranscriptionText, -} from "../shared.js"; +} from "./shared.js"; -export const DEFAULT_OPENAI_AUDIO_BASE_URL = "https://api.openai.com/v1"; -const DEFAULT_OPENAI_AUDIO_MODEL = "gpt-4o-mini-transcribe"; +type OpenAiCompatibleAudioParams = AudioTranscriptionRequest & { + defaultBaseUrl: string; + defaultModel: string; +}; -function resolveModel(model?: string): string { +function resolveModel(model: string | undefined, fallback: string): string { const trimmed = model?.trim(); - return trimmed || DEFAULT_OPENAI_AUDIO_MODEL; + return trimmed || fallback; } export async function transcribeOpenAiCompatibleAudio( - params: AudioTranscriptionRequest, + params: OpenAiCompatibleAudioParams, ): Promise { const fetchFn = params.fetchFn ?? fetch; - const baseUrl = normalizeBaseUrl(params.baseUrl, DEFAULT_OPENAI_AUDIO_BASE_URL); + const baseUrl = normalizeBaseUrl(params.baseUrl, params.defaultBaseUrl); const allowPrivate = Boolean(params.baseUrl?.trim()); const url = `${baseUrl}/audio/transcriptions`; - const model = resolveModel(params.model); + const model = resolveModel(params.model, params.defaultModel); const form = new FormData(); const fileName = params.fileName?.trim() || path.basename(params.fileName) || "audio"; const bytes = new Uint8Array(params.buffer); diff --git a/src/media-understanding/providers/openai/audio.test.ts b/src/media-understanding/providers/openai/audio.test.ts index aeafb6f2ae8..06366a4c3cc 100644 --- a/src/media-understanding/providers/openai/audio.test.ts +++ b/src/media-understanding/providers/openai/audio.test.ts @@ -1,18 +1,18 @@ import { describe, expect, it } from "vitest"; +import { transcribeOpenAiAudio } from "../../../../extensions/openai/media-understanding-provider.js"; import { createAuthCaptureJsonFetch, createRequestCaptureJsonFetch, installPinnedHostnameTestHooks, } from "../audio.test-helpers.js"; -import { transcribeOpenAiCompatibleAudio } from "./audio.js"; installPinnedHostnameTestHooks(); -describe("transcribeOpenAiCompatibleAudio", () => { +describe("transcribeOpenAiAudio", () => { it("respects lowercase authorization header overrides", async () => { const { fetchFn, getAuthHeader } = createAuthCaptureJsonFetch({ text: "ok" }); - const result = await transcribeOpenAiCompatibleAudio({ + const result = await transcribeOpenAiAudio({ buffer: Buffer.from("audio"), fileName: "note.mp3", apiKey: "test-key", @@ -28,7 +28,7 @@ describe("transcribeOpenAiCompatibleAudio", () => { it("builds the expected request payload", async () => { const { fetchFn, getRequest } = createRequestCaptureJsonFetch({ text: "hello" }); - const result = await transcribeOpenAiCompatibleAudio({ + const result = await transcribeOpenAiAudio({ buffer: Buffer.from("audio-bytes"), fileName: "voice.wav", apiKey: "test-key", @@ -72,7 +72,7 @@ describe("transcribeOpenAiCompatibleAudio", () => { const { fetchFn } = createRequestCaptureJsonFetch({}); await expect( - transcribeOpenAiCompatibleAudio({ + transcribeOpenAiAudio({ buffer: Buffer.from("audio-bytes"), fileName: "voice.wav", apiKey: "test-key", diff --git a/src/media-understanding/providers/openai/index.ts b/src/media-understanding/providers/openai/index.ts deleted file mode 100644 index 24d01964562..00000000000 --- a/src/media-understanding/providers/openai/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { MediaUnderstandingProvider } from "../../types.js"; -import { describeImageWithModel } from "../image.js"; -import { transcribeOpenAiCompatibleAudio } from "./audio.js"; - -export const openaiProvider: MediaUnderstandingProvider = { - id: "openai", - capabilities: ["image", "audio"], - describeImage: describeImageWithModel, - transcribeAudio: transcribeOpenAiCompatibleAudio, -}; diff --git a/src/media-understanding/providers/zai/index.ts b/src/media-understanding/providers/zai/index.ts deleted file mode 100644 index 337ea0a6853..00000000000 --- a/src/media-understanding/providers/zai/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { MediaUnderstandingProvider } from "../../types.js"; -import { describeImageWithModel } from "../image.js"; - -export const zaiProvider: MediaUnderstandingProvider = { - id: "zai", - capabilities: ["image"], - describeImage: describeImageWithModel, -}; diff --git a/src/media-understanding/runtime.test.ts b/src/media-understanding/runtime.test.ts new file mode 100644 index 00000000000..e15648a57fd --- /dev/null +++ b/src/media-understanding/runtime.test.ts @@ -0,0 +1,92 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { createEmptyPluginRegistry } from "../plugins/registry.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { describeImageFile, runMediaUnderstandingFile } from "./runtime.js"; + +describe("media-understanding runtime helpers", () => { + afterEach(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); + }); + + it("describes images through the active media-understanding registry", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-runtime-")); + const imagePath = path.join(tempDir, "sample.jpg"); + await fs.writeFile(imagePath, Buffer.from("image-bytes")); + + const pluginRegistry = createEmptyPluginRegistry(); + pluginRegistry.mediaUnderstandingProviders.push({ + pluginId: "vision-plugin", + pluginName: "Vision Plugin", + source: "test", + provider: { + id: "vision-plugin", + capabilities: ["image"], + describeImage: async () => ({ text: "image ok", model: "vision-v1" }), + }, + }); + setActivePluginRegistry(pluginRegistry); + + const cfg = { + tools: { + media: { + image: { + models: [{ provider: "vision-plugin", model: "vision-v1" }], + }, + }, + }, + } as OpenClawConfig; + + const result = await describeImageFile({ + filePath: imagePath, + mime: "image/jpeg", + cfg, + agentDir: "/tmp/agent", + }); + + expect(result).toEqual({ + text: "image ok", + provider: "vision-plugin", + model: "vision-v1", + output: { + kind: "image.description", + attachmentIndex: 0, + text: "image ok", + provider: "vision-plugin", + model: "vision-v1", + }, + }); + }); + + it("returns undefined when no media output is produced", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-media-runtime-")); + const imagePath = path.join(tempDir, "sample.jpg"); + await fs.writeFile(imagePath, Buffer.from("image-bytes")); + + const result = await runMediaUnderstandingFile({ + capability: "image", + filePath: imagePath, + mime: "image/jpeg", + cfg: { + tools: { + media: { + image: { + enabled: false, + }, + }, + }, + } as OpenClawConfig, + agentDir: "/tmp/agent", + }); + + expect(result).toEqual({ + text: undefined, + provider: undefined, + model: undefined, + output: undefined, + }); + }); +}); diff --git a/src/media-understanding/runtime.ts b/src/media-understanding/runtime.ts new file mode 100644 index 00000000000..e9351921dac --- /dev/null +++ b/src/media-understanding/runtime.ts @@ -0,0 +1,112 @@ +import path from "node:path"; +import type { MsgContext } from "../auto-reply/templating.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { + buildProviderRegistry, + createMediaAttachmentCache, + normalizeMediaAttachments, + runCapability, + type ActiveMediaModel, +} from "./runner.js"; +import type { MediaUnderstandingCapability, MediaUnderstandingOutput } from "./types.js"; + +const KIND_BY_CAPABILITY: Record = { + audio: "audio.transcription", + image: "image.description", + video: "video.description", +}; + +export type RunMediaUnderstandingFileParams = { + capability: MediaUnderstandingCapability; + filePath: string; + cfg: OpenClawConfig; + agentDir?: string; + mime?: string; + activeModel?: ActiveMediaModel; +}; + +export type RunMediaUnderstandingFileResult = { + text: string | undefined; + provider?: string; + model?: string; + output?: MediaUnderstandingOutput; +}; + +function buildFileContext(params: { filePath: string; mime?: string }): MsgContext { + return { + MediaPath: params.filePath, + MediaType: params.mime, + }; +} + +export async function runMediaUnderstandingFile( + params: RunMediaUnderstandingFileParams, +): Promise { + const ctx = buildFileContext(params); + const attachments = normalizeMediaAttachments(ctx); + if (attachments.length === 0) { + return { text: undefined }; + } + + const providerRegistry = buildProviderRegistry(); + const cache = createMediaAttachmentCache(attachments, { + localPathRoots: [path.dirname(params.filePath)], + }); + + try { + const result = await runCapability({ + capability: params.capability, + cfg: params.cfg, + ctx, + attachments: cache, + media: attachments, + agentDir: params.agentDir, + providerRegistry, + config: params.cfg.tools?.media?.[params.capability], + activeModel: params.activeModel, + }); + const output = result.outputs.find( + (entry) => entry.kind === KIND_BY_CAPABILITY[params.capability], + ); + const text = output?.text?.trim(); + return { + text: text || undefined, + provider: output?.provider, + model: output?.model, + output, + }; + } finally { + await cache.cleanup(); + } +} + +export async function describeImageFile(params: { + filePath: string; + cfg: OpenClawConfig; + agentDir?: string; + mime?: string; + activeModel?: ActiveMediaModel; +}): Promise { + return await runMediaUnderstandingFile({ ...params, capability: "image" }); +} + +export async function describeVideoFile(params: { + filePath: string; + cfg: OpenClawConfig; + agentDir?: string; + mime?: string; + activeModel?: ActiveMediaModel; +}): Promise { + return await runMediaUnderstandingFile({ ...params, capability: "video" }); +} + +export async function transcribeAudioFile(params: { + filePath: string; + cfg: OpenClawConfig; + agentDir?: string; + mime?: string; + activeModel?: ActiveMediaModel; +}): Promise<{ text: string | undefined }> { + const result = await runMediaUnderstandingFile({ ...params, capability: "audio" }); + return { text: result.text }; +} diff --git a/src/media-understanding/transcribe-audio.test.ts b/src/media-understanding/transcribe-audio.test.ts index 8e76cb2b9d7..3ecddc60ce3 100644 --- a/src/media-understanding/transcribe-audio.test.ts +++ b/src/media-understanding/transcribe-audio.test.ts @@ -1,13 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -const { runAudioTranscription } = vi.hoisted(() => { - const runAudioTranscription = vi.fn(); - return { runAudioTranscription }; +const { transcribeAudioFileFromRuntime } = vi.hoisted(() => { + const transcribeAudioFileFromRuntime = vi.fn(); + return { transcribeAudioFileFromRuntime }; }); -vi.mock("./audio-transcription-runner.js", () => ({ - runAudioTranscription, +vi.mock("./runtime.js", () => ({ + transcribeAudioFile: transcribeAudioFileFromRuntime, })); import { transcribeAudioFile } from "./transcribe-audio.js"; @@ -17,27 +17,23 @@ describe("transcribeAudioFile", () => { vi.clearAllMocks(); }); - it("does not force audio/wav when mime is omitted", async () => { - runAudioTranscription.mockResolvedValue({ transcript: "hello", attachments: [] }); + it("forwards file transcription requests to the shared runtime helper", async () => { + transcribeAudioFileFromRuntime.mockResolvedValue({ text: "hello" }); const result = await transcribeAudioFile({ filePath: "/tmp/note.mp3", cfg: {} as OpenClawConfig, }); - expect(runAudioTranscription).toHaveBeenCalledWith({ - ctx: { - MediaPath: "/tmp/note.mp3", - MediaType: undefined, - }, + expect(transcribeAudioFileFromRuntime).toHaveBeenCalledWith({ + filePath: "/tmp/note.mp3", cfg: {} as OpenClawConfig, - agentDir: undefined, }); expect(result).toEqual({ text: "hello" }); }); - it("returns undefined when helper returns no transcript", async () => { - runAudioTranscription.mockResolvedValue({ transcript: undefined, attachments: [] }); + it("returns undefined when the runtime helper returns no transcript", async () => { + transcribeAudioFileFromRuntime.mockResolvedValue({ text: undefined }); const result = await transcribeAudioFile({ filePath: "/tmp/missing.wav", @@ -51,7 +47,7 @@ describe("transcribeAudioFile", () => { const cfg = { tools: { media: { audio: { timeoutSeconds: 10 } } }, } as unknown as OpenClawConfig; - runAudioTranscription.mockRejectedValue(new Error("boom")); + transcribeAudioFileFromRuntime.mockRejectedValue(new Error("boom")); await expect( transcribeAudioFile({ diff --git a/src/media-understanding/transcribe-audio.ts b/src/media-understanding/transcribe-audio.ts index b2840c80ea3..c0d567b9e83 100644 --- a/src/media-understanding/transcribe-audio.ts +++ b/src/media-understanding/transcribe-audio.ts @@ -1,29 +1 @@ -import type { OpenClawConfig } from "../config/config.js"; -import { runAudioTranscription } from "./audio-transcription-runner.js"; - -/** - * Transcribe an audio file using the configured media-understanding provider. - * - * Reads provider/model/apiKey from `tools.media.audio` in the openclaw config, - * falling back through configured models until one succeeds. - * - * This is the runtime-exposed entry point for external plugins (e.g. marmot) - * that need STT without importing internal media-understanding modules directly. - */ -export async function transcribeAudioFile(params: { - filePath: string; - cfg: OpenClawConfig; - agentDir?: string; - mime?: string; -}): Promise<{ text: string | undefined }> { - const ctx = { - MediaPath: params.filePath, - MediaType: params.mime, - }; - const { transcript } = await runAudioTranscription({ - ctx, - cfg: params.cfg, - agentDir: params.agentDir, - }); - return { text: transcript }; -} +export { transcribeAudioFile } from "./runtime.js"; diff --git a/src/plugin-sdk-internal/accounts.ts b/src/plugin-sdk-internal/accounts.ts index 853d41c5f42..71807c97c6e 100644 --- a/src/plugin-sdk-internal/accounts.ts +++ b/src/plugin-sdk-internal/accounts.ts @@ -3,6 +3,10 @@ export type { OpenClawConfig } from "../config/config.js"; export { createAccountActionGate } from "../channels/plugins/account-action-gate.js"; export { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; export { normalizeChatType } from "../channels/chat-type.js"; +export { + listConfiguredAccountIds, + resolveAccountWithDefaultFallback, +} from "../plugin-sdk/account-resolution.js"; export { resolveAccountEntry } from "../routing/account-lookup.js"; export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; export { normalizeE164, pathExists, resolveUserPath } from "../utils.js"; diff --git a/src/plugin-sdk-internal/channel-config.ts b/src/plugin-sdk-internal/channel-config.ts new file mode 100644 index 00000000000..64b62fb77b0 --- /dev/null +++ b/src/plugin-sdk-internal/channel-config.ts @@ -0,0 +1,17 @@ +// Private bridge for bundled channel plugins. These config helpers are shared +// internally, but do not belong on the public compat surface. +export { buildAccountScopedAllowlistConfigEditor } from "../plugin-sdk/allowlist-config-edit.js"; +export { formatAllowFromLowercase } from "../plugin-sdk/allow-from.js"; +export { + createScopedAccountConfigAccessors, + createScopedChannelConfigBase, + createScopedDmSecurityResolver, +} from "../plugin-sdk/channel-config-helpers.js"; +export { + collectAllowlistProviderGroupPolicyWarnings, + collectAllowlistProviderRestrictSendersWarnings, + collectOpenGroupPolicyConfiguredRouteWarnings, + collectOpenGroupPolicyRouteAllowlistWarnings, + collectOpenProviderGroupPolicyWarnings, +} from "../channels/plugins/group-policy-warnings.js"; +export { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js"; diff --git a/src/plugin-sdk-internal/core.ts b/src/plugin-sdk-internal/core.ts new file mode 100644 index 00000000000..aa5ef23268d --- /dev/null +++ b/src/plugin-sdk-internal/core.ts @@ -0,0 +1,14 @@ +// Private bridge for bundled channel plugins. Keep public sdk/core slim for +// third-party plugins; bundled channels can reach shared runtime helpers here. +export type { + ChannelMessageActionContext, + OpenClawPluginApi, + PluginRuntime, +} from "../plugin-sdk/channel-plugin-common.js"; +export { createPluginRuntimeStore } from "../plugin-sdk/runtime-store.js"; +export { + buildAgentSessionKey, + type RoutePeer, + type RoutePeerKind, +} from "../routing/resolve-route.js"; +export { resolveThreadSessionKeys } from "../routing/session-key.js"; diff --git a/src/plugin-sdk-internal/imessage.ts b/src/plugin-sdk-internal/imessage.ts index 170dd7ff188..757885fc616 100644 --- a/src/plugin-sdk-internal/imessage.ts +++ b/src/plugin-sdk-internal/imessage.ts @@ -11,6 +11,7 @@ export { resolveIMessageConfigAllowFrom, resolveIMessageConfigDefaultTo, } from "../plugin-sdk/channel-config-helpers.js"; +export { isAllowedParsedChatSender } from "../plugin-sdk/allow-from.js"; export { looksLikeIMessageTargetId, normalizeIMessageMessagingTarget, diff --git a/src/plugin-sdk-internal/setup.ts b/src/plugin-sdk-internal/setup.ts index 6caf9253e14..c035d40376a 100644 --- a/src/plugin-sdk-internal/setup.ts +++ b/src/plugin-sdk-internal/setup.ts @@ -1,4 +1,5 @@ export type { OpenClawConfig } from "../config/config.js"; +export type { DmPolicy } from "../config/types.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; export type { ChannelSetupDmPolicy } from "../channels/plugins/setup-wizard-types.js"; diff --git a/src/plugin-sdk-internal/signal.ts b/src/plugin-sdk-internal/signal.ts index 4594420af8d..6b938e66518 100644 --- a/src/plugin-sdk-internal/signal.ts +++ b/src/plugin-sdk-internal/signal.ts @@ -1,4 +1,5 @@ export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js"; +export type { OpenClawConfig } from "../config/config.js"; export type { ResolvedSignalAccount } from "../../extensions/signal/src/accounts.js"; export type { SignalAccountConfig } from "../config/types.js"; export * from "../plugin-sdk/channel-plugin-common.js"; @@ -23,6 +24,7 @@ export { resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, } from "../config/runtime-group-policy.js"; +export { evaluateSenderGroupAccessForPolicy } from "../plugin-sdk/group-access.js"; export { signalSetupWizard } from "../../extensions/signal/src/setup-surface.js"; export { signalSetupAdapter } from "../../extensions/signal/src/setup-core.js"; export { SignalConfigSchema } from "../config/zod-schema.providers-core.js"; diff --git a/src/plugin-sdk-internal/telegram.ts b/src/plugin-sdk-internal/telegram.ts index bb983d690d1..d5dd45a96d6 100644 --- a/src/plugin-sdk-internal/telegram.ts +++ b/src/plugin-sdk-internal/telegram.ts @@ -7,7 +7,11 @@ export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; export type { OpenClawConfig } from "../config/config.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; -export type { TelegramAccountConfig, TelegramActionConfig } from "../config/types.js"; +export type { + TelegramAccountConfig, + TelegramActionConfig, + TelegramNetworkConfig, +} from "../config/types.js"; export type { InspectedTelegramAccount } from "../../extensions/telegram/src/account-inspect.js"; export type { ResolvedTelegramAccount } from "../../extensions/telegram/src/accounts.js"; export type { TelegramProbe } from "../../extensions/telegram/src/probe.js"; @@ -102,6 +106,9 @@ export { resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, } from "../config/runtime-group-policy.js"; +export { readBooleanParam } from "../plugin-sdk/boolean-param.js"; +export { evaluateMatchedGroupAccessForPolicy } from "../plugin-sdk/group-access.js"; +export { extractToolSend } from "../plugin-sdk/tool-send.js"; export { resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 00621521067..13b075e3352 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -1,5 +1,6 @@ export type { AnyAgentTool, + MediaUnderstandingProviderPlugin, OpenClawPluginConfigSchema, ProviderDiscoveryContext, ProviderCatalogContext, diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 07b51661d2d..c5ba9d90541 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -108,6 +108,7 @@ export { ACP_ERROR_CODES, AcpRuntimeError } from "../acp/runtime/errors.js"; export type { AcpRuntimeErrorCode } from "../acp/runtime/errors.js"; export type { AnyAgentTool, + MediaUnderstandingProviderPlugin, OpenClawPluginConfigSchema, OpenClawPluginApi, OpenClawPluginService, @@ -797,21 +798,21 @@ export { SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, SELF_HOSTED_DEFAULT_COST, SELF_HOSTED_DEFAULT_MAX_TOKENS, -} from "../commands/self-hosted-provider-setup.js"; +} from "../commands/self-hosted-provider-setup.ts"; export { OLLAMA_DEFAULT_BASE_URL, OLLAMA_DEFAULT_MODEL, configureOllamaNonInteractive, ensureOllamaModelPulled, promptAndConfigureOllama, -} from "../commands/ollama-setup.js"; +} from "../commands/ollama-setup.ts"; export { VLLM_DEFAULT_BASE_URL, VLLM_DEFAULT_CONTEXT_WINDOW, VLLM_DEFAULT_COST, VLLM_DEFAULT_MAX_TOKENS, promptAndConfigureVllm, -} from "../commands/vllm-setup.js"; +} from "../commands/vllm-setup.ts"; export { buildOllamaProvider, buildSglangProvider, diff --git a/src/plugin-sdk/ollama-setup.ts b/src/plugin-sdk/ollama-setup.ts index 5b6fd732774..fa8c9032dda 100644 --- a/src/plugin-sdk/ollama-setup.ts +++ b/src/plugin-sdk/ollama-setup.ts @@ -12,6 +12,6 @@ export { configureOllamaNonInteractive, ensureOllamaModelPulled, promptAndConfigureOllama, -} from "../commands/ollama-setup.js"; +} from "../commands/ollama-setup.ts"; export { buildOllamaProvider } from "../agents/models-config.providers.discovery.js"; diff --git a/src/plugin-sdk/provider-setup.ts b/src/plugin-sdk/provider-setup.ts index 6569c36a324..4489c8ae34d 100644 --- a/src/plugin-sdk/provider-setup.ts +++ b/src/plugin-sdk/provider-setup.ts @@ -15,21 +15,21 @@ export { SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, SELF_HOSTED_DEFAULT_COST, SELF_HOSTED_DEFAULT_MAX_TOKENS, -} from "../commands/self-hosted-provider-setup.js"; +} from "../commands/self-hosted-provider-setup.ts"; export { OLLAMA_DEFAULT_BASE_URL, OLLAMA_DEFAULT_MODEL, configureOllamaNonInteractive, ensureOllamaModelPulled, promptAndConfigureOllama, -} from "../commands/ollama-setup.js"; +} from "../commands/ollama-setup.ts"; export { VLLM_DEFAULT_BASE_URL, VLLM_DEFAULT_CONTEXT_WINDOW, VLLM_DEFAULT_COST, VLLM_DEFAULT_MAX_TOKENS, promptAndConfigureVllm, -} from "../commands/vllm-setup.js"; +} from "../commands/vllm-setup.ts"; export { buildOllamaProvider, buildSglangProvider, diff --git a/src/plugin-sdk/self-hosted-provider-setup.ts b/src/plugin-sdk/self-hosted-provider-setup.ts index 950bbbb953e..60be2852a2d 100644 --- a/src/plugin-sdk/self-hosted-provider-setup.ts +++ b/src/plugin-sdk/self-hosted-provider-setup.ts @@ -15,7 +15,7 @@ export { SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, SELF_HOSTED_DEFAULT_COST, SELF_HOSTED_DEFAULT_MAX_TOKENS, -} from "../commands/self-hosted-provider-setup.js"; +} from "../commands/self-hosted-provider-setup.ts"; export { buildSglangProvider, diff --git a/src/plugin-sdk/setup.ts b/src/plugin-sdk/setup.ts new file mode 100644 index 00000000000..e77af2904c3 --- /dev/null +++ b/src/plugin-sdk/setup.ts @@ -0,0 +1,37 @@ +// Shared setup wizard/types/helpers for extension setup surfaces and adapters. + +export type { OpenClawConfig } from "../config/config.js"; +export type { DmPolicy, GroupPolicy } from "../config/types.js"; +export type { SecretInput } from "../config/types.secrets.js"; +export type { WizardPrompter } from "../wizard/prompts.js"; +export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; +export type { ChannelSetupInput } from "../channels/plugins/types.core.js"; +export type { ChannelSetupDmPolicy } from "../channels/plugins/setup-wizard-types.js"; +export type { ChannelSetupWizard } from "../channels/plugins/setup-wizard.js"; + +export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +export { formatDocsLink } from "../terminal/links.js"; +export { hasConfiguredSecretInput, normalizeSecretInputString } from "../config/types.secrets.js"; + +export { + applyAccountNameToChannelSection, + applySetupAccountConfigPatch, + migrateBaseNameToDefaultAccount, + patchScopedAccountConfig, +} from "../channels/plugins/setup-helpers.js"; +export { + addWildcardAllowFrom, + buildSingleChannelSecretPromptState, + mergeAllowFromEntries, + patchChannelConfigForAccount, + promptSingleChannelSecretInput, + resolveSetupAccountId, + runSingleChannelSecretStep, + setSetupChannelEnabled, + setTopLevelChannelAllowFrom, + setTopLevelChannelDmPolicyWithAllowFrom, + setTopLevelChannelGroupPolicy, + splitSetupEntries, +} from "../channels/plugins/setup-wizard-helpers.js"; + +export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 7b15bcfce97..d7d15f88748 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -14,6 +14,7 @@ import * as ollamaSetupSdk from "openclaw/plugin-sdk/ollama-setup"; import * as providerSetupSdk from "openclaw/plugin-sdk/provider-setup"; import * as sandboxSdk from "openclaw/plugin-sdk/sandbox"; import * as selfHostedProviderSetupSdk from "openclaw/plugin-sdk/self-hosted-provider-setup"; +import * as setupSdk from "openclaw/plugin-sdk/setup"; import * as signalSdk from "openclaw/plugin-sdk/signal"; import * as slackSdk from "openclaw/plugin-sdk/slack"; import * as telegramSdk from "openclaw/plugin-sdk/telegram"; @@ -63,6 +64,14 @@ describe("plugin-sdk subpath exports", () => { ); }); + it("exports shared setup helpers from the dedicated subpath", () => { + expect(typeof setupSdk.DEFAULT_ACCOUNT_ID).toBe("string"); + expect(typeof setupSdk.formatDocsLink).toBe("function"); + expect(typeof setupSdk.mergeAllowFromEntries).toBe("function"); + expect(typeof setupSdk.setTopLevelChannelDmPolicyWithAllowFrom).toBe("function"); + expect(typeof setupSdk.formatResolvedUnresolvedNote).toBe("function"); + }); + it("exports narrow self-hosted provider setup helpers", () => { expect(typeof selfHostedProviderSetupSdk.buildVllmProvider).toBe("function"); expect(typeof selfHostedProviderSetupSdk.buildSglangProvider).toBe("function"); diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index 3e1275c1425..6551baffe87 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -2,6 +2,7 @@ export type { ChannelAccountSnapshot, ChannelGatewayContext, ChannelMessageActionAdapter, + ChannelPlugin, } from "../channels/plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; diff --git a/src/plugins/bundle-mcp.ts b/src/plugins/bundle-mcp.ts index 6ce186384c7..62c10e59156 100644 --- a/src/plugins/bundle-mcp.ts +++ b/src/plugins/bundle-mcp.ts @@ -105,7 +105,7 @@ function resolveBundleMcpConfigPaths(params: { return mergeUniquePathLists(defaults, declared); } -function extractMcpServerMap(raw: unknown): Record { +export function extractMcpServerMap(raw: unknown): Record { if (!isRecord(raw)) { return {}; } diff --git a/src/plugins/bundled-dir.ts b/src/plugins/bundled-dir.ts index b69da702a7e..8b5ffdd5c4d 100644 --- a/src/plugins/bundled-dir.ts +++ b/src/plugins/bundled-dir.ts @@ -19,9 +19,11 @@ export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): ); for (const packageRoot of packageRoots) { // Local source checkouts stage a runtime-complete bundled plugin tree under - // dist-runtime/. Prefer that over release-shaped dist/extensions. + // dist-runtime/. Prefer that over source extensions only when the paired + // dist/ tree exists; otherwise wrappers can drift ahead of the last build. const runtimeExtensionsDir = path.join(packageRoot, "dist-runtime", "extensions"); - if (fs.existsSync(runtimeExtensionsDir)) { + const builtExtensionsDir = path.join(packageRoot, "dist", "extensions"); + if (fs.existsSync(runtimeExtensionsDir) && fs.existsSync(builtExtensionsDir)) { return runtimeExtensionsDir; } } diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index d95a98b18d9..d41841be380 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -7,6 +7,7 @@ import { executePluginCommand, getPluginCommandSpecs, listPluginCommands, + matchPluginCommand, registerPluginCommand, } from "./commands.js"; import { setActivePluginRegistry } from "./runtime.js"; @@ -107,6 +108,29 @@ describe("registerPluginCommand", () => { expect(getPluginCommandSpecs("slack")).toEqual([]); }); + it("matches provider-specific native aliases back to the canonical command", () => { + const result = registerPluginCommand("demo-plugin", { + name: "voice", + nativeNames: { + default: "talkvoice", + discord: "discordvoice", + }, + description: "Demo command", + acceptsArgs: true, + handler: async () => ({ text: "ok" }), + }); + + expect(result).toEqual({ ok: true }); + expect(matchPluginCommand("/talkvoice now")).toMatchObject({ + command: expect.objectContaining({ name: "voice", pluginId: "demo-plugin" }), + args: "now", + }); + expect(matchPluginCommand("/discordvoice now")).toMatchObject({ + command: expect.objectContaining({ name: "voice", pluginId: "demo-plugin" }), + args: "now", + }); + }); + it("resolves Discord DM command bindings with the user target prefix intact", () => { expect( __testing.resolveBindingConversationFromCommand({ diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index fdd71d4f31c..945d5cbfb15 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -219,7 +219,11 @@ export function matchPluginCommand( const args = spaceIndex === -1 ? undefined : trimmed.slice(spaceIndex + 1).trim(); const key = commandName.toLowerCase(); - const command = pluginCommands.get(key); + const command = + pluginCommands.get(key) ?? + Array.from(pluginCommands.values()).find((candidate) => + listPluginInvocationNames(candidate).includes(key), + ); if (!command) { return null; @@ -458,6 +462,24 @@ function resolvePluginNativeName( return command.name; } +function listPluginInvocationNames(command: OpenClawPluginCommandDefinition): string[] { + const names = new Set(); + const push = (value: string | undefined) => { + const normalized = value?.trim().toLowerCase(); + if (!normalized) { + return; + } + names.add(`/${normalized}`); + }; + + push(command.name); + push(command.nativeNames?.default); + push(command.nativeNames?.telegram); + push(command.nativeNames?.discord); + + return [...names]; +} + /** * Get plugin command specs for native command registration (e.g., Telegram). */ diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 46070deab34..8700cf8226b 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -33,7 +33,7 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ "google", "huggingface", "kilocode", - "kimi-coding", + "kimi", "minimax", "mistral", "modelstudio", @@ -62,6 +62,7 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ const PLUGIN_ID_ALIASES: Readonly> = { "openai-codex": "openai", + "kimi-coding": "kimi", "minimax-portal-auth": "minimax", }; diff --git a/src/plugins/contracts/loader.contract.test.ts b/src/plugins/contracts/loader.contract.test.ts index a42c24712ec..874a94a0b5e 100644 --- a/src/plugins/contracts/loader.contract.test.ts +++ b/src/plugins/contracts/loader.contract.test.ts @@ -19,6 +19,7 @@ describe("plugin loader contract", () => { loadOpenClawPluginsMock.mockReset(); loadOpenClawPluginsMock.mockReturnValue({ providers: [], + mediaUnderstandingProviders: [], webSearchProviders: [], }); }); diff --git a/src/plugins/contracts/registry.contract.test.ts b/src/plugins/contracts/registry.contract.test.ts index 2bf113fe76d..06430449808 100644 --- a/src/plugins/contracts/registry.contract.test.ts +++ b/src/plugins/contracts/registry.contract.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { + mediaUnderstandingProviderContractRegistry, pluginRegistrationContractRegistry, providerContractRegistry, + speechProviderContractRegistry, webSearchProviderContractRegistry, } from "./registry.js"; @@ -19,6 +21,28 @@ function findWebSearchIdsForPlugin(pluginId: string) { .toSorted((left, right) => left.localeCompare(right)); } +function findSpeechProviderIdsForPlugin(pluginId: string) { + return speechProviderContractRegistry + .filter((entry) => entry.pluginId === pluginId) + .map((entry) => entry.provider.id) + .toSorted((left, right) => left.localeCompare(right)); +} + +function findSpeechProviderForPlugin(pluginId: string) { + const entry = speechProviderContractRegistry.find((candidate) => candidate.pluginId === pluginId); + if (!entry) { + throw new Error(`speech provider contract missing for ${pluginId}`); + } + return entry.provider; +} + +function findMediaUnderstandingProviderIdsForPlugin(pluginId: string) { + return mediaUnderstandingProviderContractRegistry + .filter((entry) => entry.pluginId === pluginId) + .map((entry) => entry.provider.id) + .toSorted((left, right) => left.localeCompare(right)); +} + function findRegistrationForPlugin(pluginId: string) { const entry = pluginRegistrationContractRegistry.find( (candidate) => candidate.pluginId === pluginId, @@ -40,6 +64,16 @@ describe("plugin contract registry", () => { expect(ids).toEqual([...new Set(ids)]); }); + it("does not duplicate bundled speech provider ids", () => { + const ids = speechProviderContractRegistry.map((entry) => entry.provider.id); + expect(ids).toEqual([...new Set(ids)]); + }); + + it("does not duplicate bundled media provider ids", () => { + const ids = mediaUnderstandingProviderContractRegistry.map((entry) => entry.provider.id); + expect(ids).toEqual([...new Set(ids)]); + }); + it("keeps multi-provider plugin ownership explicit", () => { expect(findProviderIdsForPlugin("google")).toEqual(["google", "google-gemini-cli"]); expect(findProviderIdsForPlugin("minimax")).toEqual(["minimax", "minimax-portal"]); @@ -55,11 +89,56 @@ describe("plugin contract registry", () => { expect(findWebSearchIdsForPlugin("xai")).toEqual(["grok"]); }); + it("keeps bundled speech ownership explicit", () => { + expect(findSpeechProviderIdsForPlugin("elevenlabs")).toEqual(["elevenlabs"]); + expect(findSpeechProviderIdsForPlugin("microsoft")).toEqual(["microsoft"]); + expect(findSpeechProviderIdsForPlugin("openai")).toEqual(["openai"]); + }); + + it("keeps bundled media-understanding ownership explicit", () => { + expect(findMediaUnderstandingProviderIdsForPlugin("anthropic")).toEqual(["anthropic"]); + expect(findMediaUnderstandingProviderIdsForPlugin("google")).toEqual(["google"]); + expect(findMediaUnderstandingProviderIdsForPlugin("minimax")).toEqual([ + "minimax", + "minimax-portal", + ]); + expect(findMediaUnderstandingProviderIdsForPlugin("mistral")).toEqual(["mistral"]); + expect(findMediaUnderstandingProviderIdsForPlugin("moonshot")).toEqual(["moonshot"]); + expect(findMediaUnderstandingProviderIdsForPlugin("openai")).toEqual(["openai"]); + expect(findMediaUnderstandingProviderIdsForPlugin("zai")).toEqual(["zai"]); + }); + it("keeps bundled provider and web search tool ownership explicit", () => { expect(findRegistrationForPlugin("firecrawl")).toMatchObject({ providerIds: [], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], webSearchProviderIds: ["firecrawl"], toolNames: ["firecrawl_search", "firecrawl_scrape"], }); }); + + it("tracks speech registrations on bundled provider plugins", () => { + expect(findRegistrationForPlugin("openai")).toMatchObject({ + providerIds: ["openai", "openai-codex"], + speechProviderIds: ["openai"], + mediaUnderstandingProviderIds: ["openai"], + }); + expect(findRegistrationForPlugin("elevenlabs")).toMatchObject({ + providerIds: [], + speechProviderIds: ["elevenlabs"], + mediaUnderstandingProviderIds: [], + }); + expect(findRegistrationForPlugin("microsoft")).toMatchObject({ + providerIds: [], + speechProviderIds: ["microsoft"], + mediaUnderstandingProviderIds: [], + }); + }); + + it("keeps bundled speech voice-list support explicit", () => { + expect(findSpeechProviderForPlugin("openai").listVoices).toEqual(expect.any(Function)); + expect(findSpeechProviderForPlugin("elevenlabs").listVoices).toEqual(expect.any(Function)); + expect(findSpeechProviderForPlugin("microsoft").listVoices).toEqual(expect.any(Function)); + }); }); diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index 8099ce4ca44..14dbb17262c 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -3,12 +3,14 @@ import bravePlugin from "../../../extensions/brave/index.js"; import byteplusPlugin from "../../../extensions/byteplus/index.js"; import cloudflareAiGatewayPlugin from "../../../extensions/cloudflare-ai-gateway/index.js"; import copilotProxyPlugin from "../../../extensions/copilot-proxy/index.js"; +import elevenLabsPlugin from "../../../extensions/elevenlabs/index.js"; import firecrawlPlugin from "../../../extensions/firecrawl/index.js"; import githubCopilotPlugin from "../../../extensions/github-copilot/index.js"; import googlePlugin from "../../../extensions/google/index.js"; import huggingFacePlugin from "../../../extensions/huggingface/index.js"; import kilocodePlugin from "../../../extensions/kilocode/index.js"; import kimiCodingPlugin from "../../../extensions/kimi-coding/index.js"; +import microsoftPlugin from "../../../extensions/microsoft/index.js"; import minimaxPlugin from "../../../extensions/minimax/index.js"; import mistralPlugin from "../../../extensions/mistral/index.js"; import modelStudioPlugin from "../../../extensions/modelstudio/index.js"; @@ -33,7 +35,12 @@ import xaiPlugin from "../../../extensions/xai/index.js"; import xiaomiPlugin from "../../../extensions/xiaomi/index.js"; import zaiPlugin from "../../../extensions/zai/index.js"; import { createCapturedPluginRegistration } from "../../test-utils/plugin-registration.js"; -import type { ProviderPlugin, WebSearchProviderPlugin } from "../types.js"; +import type { + MediaUnderstandingProviderPlugin, + ProviderPlugin, + SpeechProviderPlugin, + WebSearchProviderPlugin, +} from "../types.js"; type RegistrablePlugin = { id: string; @@ -51,9 +58,21 @@ type WebSearchProviderContractEntry = { credentialValue: unknown; }; +type SpeechProviderContractEntry = { + pluginId: string; + provider: SpeechProviderPlugin; +}; + +type MediaUnderstandingProviderContractEntry = { + pluginId: string; + provider: MediaUnderstandingProviderPlugin; +}; + type PluginRegistrationContractEntry = { pluginId: string; providerIds: string[]; + speechProviderIds: string[]; + mediaUnderstandingProviderIds: string[]; webSearchProviderIds: string[]; toolNames: string[]; }; @@ -101,6 +120,18 @@ const bundledWebSearchPlugins: Array { + const captured = captureRegistrations(plugin); + return captured.speechProviders.map((provider) => ({ + pluginId: plugin.id, + provider, + })); + }); + +export const mediaUnderstandingProviderContractRegistry: MediaUnderstandingProviderContractEntry[] = + bundledMediaUnderstandingPlugins.flatMap((plugin) => { + const captured = captureRegistrations(plugin); + return captured.mediaUnderstandingProviders.map((provider) => ({ + pluginId: plugin.id, + provider, + })); + }); + const bundledPluginRegistrationList = [ ...new Map( - [...bundledProviderPlugins, ...bundledWebSearchPlugins].map((plugin) => [plugin.id, plugin]), + [ + ...bundledProviderPlugins, + ...bundledSpeechPlugins, + ...bundledMediaUnderstandingPlugins, + ...bundledWebSearchPlugins, + ].map((plugin) => [plugin.id, plugin]), ).values(), ]; @@ -139,6 +193,10 @@ export const pluginRegistrationContractRegistry: PluginRegistrationContractEntry return { pluginId: plugin.id, providerIds: captured.providers.map((provider) => provider.id), + speechProviderIds: captured.speechProviders.map((provider) => provider.id), + mediaUnderstandingProviderIds: captured.mediaUnderstandingProviders.map( + (provider) => provider.id, + ), webSearchProviderIds: captured.webSearchProviders.map((provider) => provider.id), toolNames: captured.tools.map((tool) => tool.name), }; diff --git a/src/plugins/hooks.test-helpers.ts b/src/plugins/hooks.test-helpers.ts index 7954257e714..ea01163d4b0 100644 --- a/src/plugins/hooks.test-helpers.ts +++ b/src/plugins/hooks.test-helpers.ts @@ -17,6 +17,9 @@ export function createMockPluginRegistry( hookNames: [], channelIds: [], providerIds: [], + speechProviderIds: [], + mediaUnderstandingProviderIds: [], + webSearchProviderIds: [], gatewayMethods: [], cliCommands: [], services: [], @@ -35,13 +38,18 @@ export function createMockPluginRegistry( source: "test", })), tools: [], + channels: [], + channelSetups: [], + providers: [], + speechProviders: [], + mediaUnderstandingProviders: [], + webSearchProviders: [], httpRoutes: [], - channelRegistrations: [], gatewayHandlers: {}, cliRegistrars: [], services: [], - providers: [], commands: [], + diagnostics: [], } as unknown as PluginRegistry; } diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index a2e05fc06b9..873fff6b9bf 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -495,6 +495,7 @@ function createPluginRecord(params: { channelIds: [], providerIds: [], speechProviderIds: [], + mediaUnderstandingProviderIds: [], webSearchProviderIds: [], gatewayMethods: [], cliCommands: [], diff --git a/src/plugins/provider-api-key-auth.runtime.ts b/src/plugins/provider-api-key-auth.runtime.ts index 6909bd4cc2c..010e2b3e16e 100644 --- a/src/plugins/provider-api-key-auth.runtime.ts +++ b/src/plugins/provider-api-key-auth.runtime.ts @@ -1,7 +1,7 @@ import { normalizeApiKeyInput, validateApiKeyInput } from "../commands/auth-choice.api-key.js"; import { ensureApiKeyFromOptionEnvOrPrompt } from "../commands/auth-choice.apply-helpers.js"; +import { buildApiKeyCredential } from "../commands/auth-credentials.js"; import { applyPrimaryModel } from "../commands/model-picker.js"; -import { buildApiKeyCredential } from "../commands/onboard-auth.credentials.js"; import { applyAuthProfileConfig } from "../commands/onboard-auth.js"; export { diff --git a/src/plugins/provider-catalog-metadata.ts b/src/plugins/provider-catalog-metadata.ts index 123fef24289..5714861b219 100644 --- a/src/plugins/provider-catalog-metadata.ts +++ b/src/plugins/provider-catalog-metadata.ts @@ -1,4 +1,5 @@ import { normalizeProviderId } from "../agents/provider-id.js"; +import { findCatalogTemplate } from "./provider-catalog.js"; import type { ProviderAugmentModelCatalogContext, ProviderBuiltInModelSuppressionContext, @@ -9,22 +10,6 @@ const OPENAI_CODEX_PROVIDER_ID = "openai-codex"; const OPENAI_DIRECT_SPARK_MODEL_ID = "gpt-5.3-codex-spark"; const SUPPRESSED_SPARK_PROVIDERS = new Set(["openai", "azure-openai-responses"]); -function findCatalogTemplate(params: { - entries: ReadonlyArray<{ provider: string; id: string }>; - providerId: string; - templateIds: readonly string[]; -}) { - return params.templateIds - .map((templateId) => - params.entries.find( - (entry) => - entry.provider.toLowerCase() === params.providerId.toLowerCase() && - entry.id.toLowerCase() === templateId.toLowerCase(), - ), - ) - .find((entry) => entry !== undefined); -} - export function resolveBundledProviderBuiltInModelSuppression( context: ProviderBuiltInModelSuppressionContext, ) { diff --git a/src/plugins/provider-catalog.test.ts b/src/plugins/provider-catalog.test.ts new file mode 100644 index 00000000000..e150d021a7b --- /dev/null +++ b/src/plugins/provider-catalog.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { + buildPairedProviderApiKeyCatalog, + buildSingleProviderApiKeyCatalog, + findCatalogTemplate, +} from "./provider-catalog.js"; +import type { ProviderCatalogContext } from "./types.js"; + +function createCatalogContext(params: { + config?: OpenClawConfig; + apiKeys?: Record; +}): ProviderCatalogContext { + return { + config: params.config ?? {}, + env: {}, + resolveProviderApiKey: (providerId) => ({ + apiKey: providerId ? params.apiKeys?.[providerId] : undefined, + }), + }; +} + +describe("buildSingleProviderApiKeyCatalog", () => { + it("matches provider templates case-insensitively", () => { + const result = findCatalogTemplate({ + entries: [ + { provider: "OpenAI", id: "gpt-5.2" }, + { provider: "other", id: "fallback" }, + ], + providerId: "openai", + templateIds: ["missing", "GPT-5.2"], + }); + + expect(result).toEqual({ provider: "OpenAI", id: "gpt-5.2" }); + }); + + it("returns null when api key is missing", async () => { + const result = await buildSingleProviderApiKeyCatalog({ + ctx: createCatalogContext({}), + providerId: "test-provider", + buildProvider: () => ({ api: "openai-completions", provider: "test-provider" }), + }); + + expect(result).toBeNull(); + }); + + it("adds api key to the built provider", async () => { + const result = await buildSingleProviderApiKeyCatalog({ + ctx: createCatalogContext({ + apiKeys: { "test-provider": "secret-key" }, + }), + providerId: "test-provider", + buildProvider: async () => ({ api: "openai-completions", provider: "test-provider" }), + }); + + expect(result).toEqual({ + provider: { + api: "openai-completions", + provider: "test-provider", + apiKey: "secret-key", + }, + }); + }); + + it("prefers explicit base url when allowed", async () => { + const result = await buildSingleProviderApiKeyCatalog({ + ctx: createCatalogContext({ + apiKeys: { "test-provider": "secret-key" }, + config: { + models: { + providers: { + "test-provider": { + baseUrl: " https://override.example/v1/ ", + }, + }, + }, + }, + }), + providerId: "test-provider", + buildProvider: () => ({ + api: "openai-completions", + provider: "test-provider", + baseUrl: "https://default.example/v1", + }), + allowExplicitBaseUrl: true, + }); + + expect(result).toEqual({ + provider: { + api: "openai-completions", + provider: "test-provider", + baseUrl: "https://override.example/v1/", + apiKey: "secret-key", + }, + }); + }); + + it("adds api key to each paired provider", async () => { + const result = await buildPairedProviderApiKeyCatalog({ + ctx: createCatalogContext({ + apiKeys: { "test-provider": "secret-key" }, + }), + providerId: "test-provider", + buildProviders: async () => ({ + alpha: { api: "openai-completions", provider: "alpha" }, + beta: { api: "openai-completions", provider: "beta" }, + }), + }); + + expect(result).toEqual({ + providers: { + alpha: { + api: "openai-completions", + provider: "alpha", + apiKey: "secret-key", + }, + beta: { + api: "openai-completions", + provider: "beta", + apiKey: "secret-key", + }, + }, + }); + }); +}); diff --git a/src/plugins/provider-catalog.ts b/src/plugins/provider-catalog.ts new file mode 100644 index 00000000000..1d357887c03 --- /dev/null +++ b/src/plugins/provider-catalog.ts @@ -0,0 +1,64 @@ +import type { ModelProviderConfig } from "../config/types.js"; +import type { ProviderCatalogContext, ProviderCatalogResult } from "./types.js"; + +export function findCatalogTemplate(params: { + entries: ReadonlyArray<{ provider: string; id: string }>; + providerId: string; + templateIds: readonly string[]; +}) { + return params.templateIds + .map((templateId) => + params.entries.find( + (entry) => + entry.provider.toLowerCase() === params.providerId.toLowerCase() && + entry.id.toLowerCase() === templateId.toLowerCase(), + ), + ) + .find((entry) => entry !== undefined); +} + +export async function buildSingleProviderApiKeyCatalog(params: { + ctx: ProviderCatalogContext; + providerId: string; + buildProvider: () => ModelProviderConfig | Promise; + allowExplicitBaseUrl?: boolean; +}): Promise { + const apiKey = params.ctx.resolveProviderApiKey(params.providerId).apiKey; + if (!apiKey) { + return null; + } + + const explicitProvider = params.allowExplicitBaseUrl + ? params.ctx.config.models?.providers?.[params.providerId] + : undefined; + const explicitBaseUrl = + typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl.trim() : ""; + + return { + provider: { + ...(await params.buildProvider()), + ...(explicitBaseUrl ? { baseUrl: explicitBaseUrl } : {}), + apiKey, + }, + }; +} + +export async function buildPairedProviderApiKeyCatalog(params: { + ctx: ProviderCatalogContext; + providerId: string; + buildProviders: () => + | Record + | Promise>; +}): Promise { + const apiKey = params.ctx.resolveProviderApiKey(params.providerId).apiKey; + if (!apiKey) { + return null; + } + + const providers = await params.buildProviders(); + return { + providers: Object.fromEntries( + Object.entries(providers).map(([id, provider]) => [id, { ...provider, apiKey }]), + ), + }; +} diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 231e6f267aa..6ec51d889fc 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -31,6 +31,7 @@ import type { OpenClawPluginHttpRouteHandler, OpenClawPluginHttpRouteParams, OpenClawPluginHookOptions, + MediaUnderstandingProviderPlugin, ProviderPlugin, OpenClawPluginService, OpenClawPluginToolContext, @@ -119,6 +120,14 @@ export type PluginSpeechProviderRegistration = { rootDir?: string; }; +export type PluginMediaUnderstandingProviderRegistration = { + pluginId: string; + pluginName?: string; + provider: MediaUnderstandingProviderPlugin; + source: string; + rootDir?: string; +}; + export type PluginHookRegistration = { pluginId: string; entry: HookEntry; @@ -164,6 +173,7 @@ export type PluginRecord = { channelIds: string[]; providerIds: string[]; speechProviderIds: string[]; + mediaUnderstandingProviderIds: string[]; webSearchProviderIds: string[]; gatewayMethods: string[]; cliCommands: string[]; @@ -185,6 +195,7 @@ export type PluginRegistry = { channelSetups: PluginChannelSetupRegistration[]; providers: PluginProviderRegistration[]; speechProviders: PluginSpeechProviderRegistration[]; + mediaUnderstandingProviders: PluginMediaUnderstandingProviderRegistration[]; webSearchProviders: PluginWebSearchProviderRegistration[]; gatewayHandlers: GatewayRequestHandlers; httpRoutes: PluginHttpRouteRegistration[]; @@ -231,6 +242,7 @@ export function createEmptyPluginRegistry(): PluginRegistry { channelSetups: [], providers: [], speechProviders: [], + mediaUnderstandingProviders: [], webSearchProviders: [], gatewayHandlers: {}, httpRoutes: [], @@ -562,65 +574,85 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); }; - const registerSpeechProvider = (record: PluginRecord, provider: SpeechProviderPlugin) => { - const id = provider.id.trim(); + const registerUniqueProviderLike = < + T extends { id: string }, + R extends { + pluginId: string; + pluginName?: string; + provider: T; + source: string; + rootDir?: string; + }, + >(params: { + record: PluginRecord; + provider: T; + kindLabel: string; + registrations: R[]; + ownedIds: string[]; + }) => { + const id = params.provider.id.trim(); + const { record, kindLabel } = params; + const missingLabel = `${kindLabel} registration missing id`; + const duplicateLabel = `${kindLabel} already registered: ${id}`; if (!id) { pushDiagnostic({ level: "error", pluginId: record.id, source: record.source, - message: "speech provider registration missing id", + message: missingLabel, }); return; } - const existing = registry.speechProviders.find((entry) => entry.provider.id === id); + const existing = params.registrations.find((entry) => entry.provider.id === id); if (existing) { pushDiagnostic({ level: "error", pluginId: record.id, source: record.source, - message: `speech provider already registered: ${id} (${existing.pluginId})`, + message: `${duplicateLabel} (${existing.pluginId})`, }); return; } - record.speechProviderIds.push(id); - registry.speechProviders.push({ + params.ownedIds.push(id); + params.registrations.push({ pluginId: record.id, pluginName: record.name, - provider, + provider: params.provider, source: record.source, rootDir: record.rootDir, + } as R); + }; + + const registerSpeechProvider = (record: PluginRecord, provider: SpeechProviderPlugin) => { + registerUniqueProviderLike({ + record, + provider, + kindLabel: "speech provider", + registrations: registry.speechProviders, + ownedIds: record.speechProviderIds, + }); + }; + + const registerMediaUnderstandingProvider = ( + record: PluginRecord, + provider: MediaUnderstandingProviderPlugin, + ) => { + registerUniqueProviderLike({ + record, + provider, + kindLabel: "media provider", + registrations: registry.mediaUnderstandingProviders, + ownedIds: record.mediaUnderstandingProviderIds, }); }; const registerWebSearchProvider = (record: PluginRecord, provider: WebSearchProviderPlugin) => { - const id = provider.id.trim(); - if (!id) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: "web search provider registration missing id", - }); - return; - } - const existing = registry.webSearchProviders.find((entry) => entry.provider.id === id); - if (existing) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: `web search provider already registered: ${id} (${existing.pluginId})`, - }); - return; - } - record.webSearchProviderIds.push(id); - registry.webSearchProviders.push({ - pluginId: record.id, - pluginName: record.name, + registerUniqueProviderLike({ + record, provider, - source: record.source, - rootDir: record.rootDir, + kindLabel: "web search provider", + registrations: registry.webSearchProviders, + ownedIds: record.webSearchProviderIds, }); }; @@ -836,6 +868,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registrationMode === "full" ? (provider) => registerSpeechProvider(record, provider) : () => {}, + registerMediaUnderstandingProvider: + registrationMode === "full" + ? (provider) => registerMediaUnderstandingProvider(record, provider) + : () => {}, registerWebSearchProvider: registrationMode === "full" ? (provider) => registerWebSearchProvider(record, provider) @@ -910,6 +946,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerChannel, registerProvider, registerSpeechProvider, + registerMediaUnderstandingProvider, registerWebSearchProvider, registerGatewayMethod, registerCli, diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index d94825062cd..48899303e2f 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -4,8 +4,13 @@ import { resolveApiKeyForProvider as resolveApiKeyForProviderRaw, } from "../../agents/model-auth.js"; import { resolveStateDir } from "../../config/paths.js"; -import { transcribeAudioFile } from "../../media-understanding/transcribe-audio.js"; -import { textToSpeechTelephony } from "../../tts/tts.js"; +import { + describeImageFile, + describeVideoFile, + runMediaUnderstandingFile, + transcribeAudioFile, +} from "../../media-understanding/runtime.js"; +import { listSpeechVoices, textToSpeech, textToSpeechTelephony } from "../../tts/tts.js"; import { createRuntimeAgent } from "./runtime-agent.js"; import { createRuntimeChannel } from "./runtime-channel.js"; import { createRuntimeConfig } from "./runtime-config.js"; @@ -135,7 +140,13 @@ export function createPluginRuntime(_options: CreatePluginRuntimeOptions = {}): ), system: createRuntimeSystem(), media: createRuntimeMedia(), - tts: { textToSpeechTelephony }, + tts: { textToSpeech, textToSpeechTelephony, listVoices: listSpeechVoices }, + mediaUnderstanding: { + runFile: runMediaUnderstandingFile, + describeImageFile, + describeVideoFile, + transcribeAudioFile, + }, stt: { transcribeAudioFile }, tools: createRuntimeTools(), channel: createRuntimeChannel(), diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index c1bb753fb11..822f0026b49 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -47,7 +47,15 @@ export type PluginRuntimeCore = { resizeToJpeg: typeof import("../../media/image-ops.js").resizeToJpeg; }; tts: { + textToSpeech: typeof import("../../tts/tts.js").textToSpeech; textToSpeechTelephony: typeof import("../../tts/tts.js").textToSpeechTelephony; + listVoices: typeof import("../../tts/tts.js").listSpeechVoices; + }; + mediaUnderstanding: { + runFile: typeof import("../../media-understanding/runtime.js").runMediaUnderstandingFile; + describeImageFile: typeof import("../../media-understanding/runtime.js").describeImageFile; + describeVideoFile: typeof import("../../media-understanding/runtime.js").describeVideoFile; + transcribeAudioFile: typeof import("../../media-understanding/runtime.js").transcribeAudioFile; }; stt: { transcribeAudioFile: typeof import("../../media-understanding/transcribe-audio.js").transcribeAudioFile; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 2a2e2b9fd5f..23e761940df 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -25,15 +25,18 @@ import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import type { InternalHookHandler } from "../hooks/internal-hooks.js"; import type { HookEntry } from "../hooks/types.js"; import type { ProviderUsageSnapshot } from "../infra/provider-usage.types.js"; +import type { MediaUnderstandingProvider } from "../media-understanding/types.js"; import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeWebSearchMetadata } from "../secrets/runtime-web-tools.types.js"; import type { SpeechProviderConfiguredContext, + SpeechListVoicesRequest, SpeechProviderId, SpeechSynthesisRequest, SpeechSynthesisResult, SpeechTelephonySynthesisRequest, SpeechTelephonySynthesisResult, + SpeechVoiceOption, } from "../tts/provider-types.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { PluginRuntime } from "./runtime/types.js"; @@ -872,12 +875,15 @@ export type SpeechProviderPlugin = { synthesizeTelephony?: ( req: SpeechTelephonySynthesisRequest, ) => Promise; + listVoices?: (req: SpeechListVoicesRequest) => Promise; }; export type PluginSpeechProviderEntry = SpeechProviderPlugin & { pluginId: string; }; +export type MediaUnderstandingProviderPlugin = MediaUnderstandingProvider; + export type OpenClawPluginGatewayMethod = { method: string; handler: GatewayRequestHandler; @@ -1237,6 +1243,7 @@ export type OpenClawPluginApi = { registerService: (service: OpenClawPluginService) => void; registerProvider: (provider: ProviderPlugin) => void; registerSpeechProvider: (provider: SpeechProviderPlugin) => void; + registerMediaUnderstandingProvider: (provider: MediaUnderstandingProviderPlugin) => void; registerWebSearchProvider: (provider: WebSearchProviderPlugin) => void; registerInteractiveHandler: (registration: PluginInteractiveHandlerRegistration) => void; /** diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 588c1ca7db6..1283ac9f506 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -27,6 +27,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl })), providers: [], speechProviders: [], + mediaUnderstandingProviders: [], webSearchProviders: [], gatewayHandlers: {}, httpRoutes: [], diff --git a/src/test-utils/plugin-registration.ts b/src/test-utils/plugin-registration.ts index 6231dedf17b..de8e5422ccf 100644 --- a/src/test-utils/plugin-registration.ts +++ b/src/test-utils/plugin-registration.ts @@ -1,5 +1,6 @@ import type { AnyAgentTool, + MediaUnderstandingProviderPlugin, OpenClawPluginApi, ProviderPlugin, SpeechProviderPlugin, @@ -10,6 +11,7 @@ export type CapturedPluginRegistration = { api: OpenClawPluginApi; providers: ProviderPlugin[]; speechProviders: SpeechProviderPlugin[]; + mediaUnderstandingProviders: MediaUnderstandingProviderPlugin[]; webSearchProviders: WebSearchProviderPlugin[]; tools: AnyAgentTool[]; }; @@ -17,12 +19,14 @@ export type CapturedPluginRegistration = { export function createCapturedPluginRegistration(): CapturedPluginRegistration { const providers: ProviderPlugin[] = []; const speechProviders: SpeechProviderPlugin[] = []; + const mediaUnderstandingProviders: MediaUnderstandingProviderPlugin[] = []; const webSearchProviders: WebSearchProviderPlugin[] = []; const tools: AnyAgentTool[] = []; return { providers, speechProviders, + mediaUnderstandingProviders, webSearchProviders, tools, api: { @@ -32,6 +36,9 @@ export function createCapturedPluginRegistration(): CapturedPluginRegistration { registerSpeechProvider(provider: SpeechProviderPlugin) { speechProviders.push(provider); }, + registerMediaUnderstandingProvider(provider: MediaUnderstandingProviderPlugin) { + mediaUnderstandingProviders.push(provider); + }, registerWebSearchProvider(provider: WebSearchProviderPlugin) { webSearchProviders.push(provider); }, diff --git a/src/tts/provider-types.ts b/src/tts/provider-types.ts index bfbeb38f02a..c0640b63614 100644 --- a/src/tts/provider-types.ts +++ b/src/tts/provider-types.ts @@ -36,3 +36,20 @@ export type SpeechTelephonySynthesisResult = { outputFormat: string; sampleRate: number; }; + +export type SpeechVoiceOption = { + id: string; + name?: string; + category?: string; + description?: string; + locale?: string; + gender?: string; + personalities?: string[]; +}; + +export type SpeechListVoicesRequest = { + cfg?: OpenClawConfig; + config?: ResolvedTtsConfig; + apiKey?: string; + baseUrl?: string; +}; diff --git a/src/tts/providers/elevenlabs.ts b/src/tts/providers/elevenlabs.ts index 2b6df133edc..c22425926bf 100644 --- a/src/tts/providers/elevenlabs.ts +++ b/src/tts/providers/elevenlabs.ts @@ -1,4 +1,5 @@ import type { SpeechProviderPlugin } from "../../plugins/types.js"; +import type { SpeechVoiceOption } from "../provider-types.js"; import { elevenLabsTTS } from "../tts-core.js"; const ELEVENLABS_TTS_MODELS = [ @@ -7,11 +8,62 @@ const ELEVENLABS_TTS_MODELS = [ "eleven_monolingual_v1", ] as const; +function normalizeElevenLabsBaseUrl(baseUrl: string | undefined): string { + const trimmed = baseUrl?.trim(); + return trimmed?.replace(/\/+$/, "") || "https://api.elevenlabs.io"; +} + +export async function listElevenLabsVoices(params: { + apiKey: string; + baseUrl?: string; +}): Promise { + const res = await fetch(`${normalizeElevenLabsBaseUrl(params.baseUrl)}/v1/voices`, { + headers: { + "xi-api-key": params.apiKey, + }, + }); + if (!res.ok) { + throw new Error(`ElevenLabs voices API error (${res.status})`); + } + const json = (await res.json()) as { + voices?: Array<{ + voice_id?: string; + name?: string; + category?: string; + description?: string; + }>; + }; + return Array.isArray(json.voices) + ? json.voices + .map((voice) => ({ + id: voice.voice_id?.trim() ?? "", + name: voice.name?.trim() || undefined, + category: voice.category?.trim() || undefined, + description: voice.description?.trim() || undefined, + })) + .filter((voice) => voice.id.length > 0) + : []; +} + export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin { return { id: "elevenlabs", label: "ElevenLabs", models: ELEVENLABS_TTS_MODELS, + listVoices: async (req) => { + const apiKey = + req.apiKey || + req.config?.elevenlabs.apiKey || + process.env.ELEVENLABS_API_KEY || + process.env.XI_API_KEY; + if (!apiKey) { + throw new Error("ElevenLabs API key missing"); + } + return listElevenLabsVoices({ + apiKey, + baseUrl: req.baseUrl ?? req.config?.elevenlabs.baseUrl, + }); + }, isConfigured: ({ config }) => Boolean(config.elevenlabs.apiKey || process.env.ELEVENLABS_API_KEY || process.env.XI_API_KEY), synthesize: async (req) => { diff --git a/src/tts/providers/microsoft.test.ts b/src/tts/providers/microsoft.test.ts new file mode 100644 index 00000000000..f78e09f70e4 --- /dev/null +++ b/src/tts/providers/microsoft.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { listMicrosoftVoices } from "./microsoft.js"; + +describe("listMicrosoftVoices", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("maps Microsoft voice metadata into speech voice options", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify([ + { + ShortName: "en-US-AvaNeural", + FriendlyName: "Microsoft Ava Online (Natural) - English (United States)", + Locale: "en-US", + Gender: "Female", + VoiceTag: { + ContentCategories: ["General"], + VoicePersonalities: ["Friendly", "Positive"], + }, + }, + ]), + { status: 200 }, + ), + ) as typeof globalThis.fetch; + + const voices = await listMicrosoftVoices(); + + expect(voices).toEqual([ + { + id: "en-US-AvaNeural", + name: "Microsoft Ava Online (Natural) - English (United States)", + category: "General", + description: "Friendly, Positive", + locale: "en-US", + gender: "Female", + personalities: ["Friendly", "Positive"], + }, + ]); + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining("/voices/list?trustedclienttoken="), + expect.objectContaining({ + headers: expect.objectContaining({ + Origin: "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold", + "Sec-MS-GEC": expect.any(String), + "Sec-MS-GEC-Version": expect.stringContaining("1-"), + }), + }), + ); + }); + + it("throws on Microsoft voice list failures", async () => { + globalThis.fetch = vi + .fn() + .mockResolvedValue(new Response("nope", { status: 503 })) as typeof globalThis.fetch; + + await expect(listMicrosoftVoices()).rejects.toThrow("Microsoft voices API error (503)"); + }); +}); diff --git a/src/tts/providers/microsoft.ts b/src/tts/providers/microsoft.ts index ee31e35a204..fef369740cb 100644 --- a/src/tts/providers/microsoft.ts +++ b/src/tts/providers/microsoft.ts @@ -1,17 +1,83 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import path from "node:path"; +import { + CHROMIUM_FULL_VERSION, + TRUSTED_CLIENT_TOKEN, + generateSecMsGecToken, +} from "node-edge-tts/dist/drm.js"; import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js"; import { isVoiceCompatibleAudio } from "../../media/audio.js"; import type { SpeechProviderPlugin } from "../../plugins/types.js"; +import type { SpeechVoiceOption } from "../provider-types.js"; import { edgeTTS, inferEdgeExtension } from "../tts-core.js"; const DEFAULT_EDGE_OUTPUT_FORMAT = "audio-24khz-48kbitrate-mono-mp3"; +type MicrosoftVoiceListEntry = { + ShortName?: string; + FriendlyName?: string; + Locale?: string; + Gender?: string; + VoiceTag?: { + ContentCategories?: string[]; + VoicePersonalities?: string[]; + }; +}; + +function buildMicrosoftVoiceHeaders(): Record { + const major = CHROMIUM_FULL_VERSION.split(".")[0] || "0"; + return { + Authority: "speech.platform.bing.com", + Origin: "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold", + Accept: "*/*", + "User-Agent": + `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ` + + `(KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36 Edg/${major}.0.0.0`, + "Sec-MS-GEC": generateSecMsGecToken(), + "Sec-MS-GEC-Version": `1-${CHROMIUM_FULL_VERSION}`, + }; +} + +function formatMicrosoftVoiceDescription(entry: MicrosoftVoiceListEntry): string | undefined { + const personalities = entry.VoiceTag?.VoicePersonalities?.filter(Boolean) ?? []; + return personalities.length > 0 ? personalities.join(", ") : undefined; +} + +export async function listMicrosoftVoices(): Promise { + const response = await fetch( + "https://speech.platform.bing.com/consumer/speech/synthesize/readaloud/voices/list" + + `?trustedclienttoken=${TRUSTED_CLIENT_TOKEN}`, + { + headers: buildMicrosoftVoiceHeaders(), + }, + ); + if (!response.ok) { + throw new Error(`Microsoft voices API error (${response.status})`); + } + const voices = (await response.json()) as MicrosoftVoiceListEntry[]; + return Array.isArray(voices) + ? voices + .map((voice) => ({ + id: voice.ShortName?.trim() ?? "", + name: voice.FriendlyName?.trim() || voice.ShortName?.trim() || undefined, + category: voice.VoiceTag?.ContentCategories?.find((value) => value.trim().length > 0), + description: formatMicrosoftVoiceDescription(voice), + locale: voice.Locale?.trim() || undefined, + gender: voice.Gender?.trim() || undefined, + personalities: voice.VoiceTag?.VoicePersonalities?.filter( + (value): value is string => value.trim().length > 0, + ), + })) + .filter((voice) => voice.id.length > 0) + : []; +} + export function buildMicrosoftSpeechProvider(): SpeechProviderPlugin { return { id: "microsoft", label: "Microsoft", aliases: ["edge"], + listVoices: async () => await listMicrosoftVoices(), isConfigured: ({ config }) => config.edge.enabled, synthesize: async (req) => { const tempRoot = resolvePreferredOpenClawTmpDir(); diff --git a/src/tts/providers/openai.ts b/src/tts/providers/openai.ts index bf52c1644a9..9f96e9ea6e9 100644 --- a/src/tts/providers/openai.ts +++ b/src/tts/providers/openai.ts @@ -7,6 +7,7 @@ export function buildOpenAISpeechProvider(): SpeechProviderPlugin { label: "OpenAI", models: OPENAI_TTS_MODELS, voices: OPENAI_TTS_VOICES, + listVoices: async () => OPENAI_TTS_VOICES.map((voice) => ({ id: voice, name: voice })), isConfigured: ({ config }) => Boolean(config.openai.apiKey || process.env.OPENAI_API_KEY), synthesize: async (req) => { const apiKey = req.config.openai.apiKey || process.env.OPENAI_API_KEY; diff --git a/src/tts/tts.ts b/src/tts/tts.ts index 44cb57fd6e8..39793fd2ba4 100644 --- a/src/tts/tts.ts +++ b/src/tts/tts.ts @@ -30,6 +30,7 @@ import { listSpeechProviders, normalizeSpeechProviderId, } from "./provider-registry.js"; +import type { SpeechVoiceOption } from "./provider-types.js"; import { DEFAULT_OPENAI_BASE_URL, isValidOpenAIModel, @@ -723,6 +724,36 @@ export async function textToSpeechTelephony(params: { return buildTtsFailureResult(errors); } +export async function listSpeechVoices(params: { + provider: string; + cfg?: OpenClawConfig; + config?: ResolvedTtsConfig; + apiKey?: string; + baseUrl?: string; +}): Promise { + const provider = normalizeSpeechProviderId(params.provider); + if (!provider) { + throw new Error("speech provider id is required"); + } + const config = params.config ?? (params.cfg ? resolveTtsConfig(params.cfg) : undefined); + if (!config) { + throw new Error(`speech provider ${provider} requires cfg or resolved config`); + } + const resolvedProvider = getSpeechProvider(provider, params.cfg); + if (!resolvedProvider) { + throw new Error(`speech provider ${provider} is not registered`); + } + if (!resolvedProvider.listVoices) { + throw new Error(`speech provider ${provider} does not support voice listing`); + } + return await resolvedProvider.listVoices({ + cfg: params.cfg, + config, + apiKey: params.apiKey, + baseUrl: params.baseUrl, + }); +} + export async function maybeApplyTtsToPayload(params: { payload: ReplyPayload; cfg: OpenClawConfig; diff --git a/src/types/node-edge-tts.d.ts b/src/types/node-edge-tts.d.ts index eaaaa9cdf5a..b800c986cb8 100644 --- a/src/types/node-edge-tts.d.ts +++ b/src/types/node-edge-tts.d.ts @@ -16,3 +16,9 @@ declare module "node-edge-tts" { ttsPromise(text: string, outputPath: string): Promise; } } + +declare module "node-edge-tts/dist/drm.js" { + export const CHROMIUM_FULL_VERSION: string; + export const TRUSTED_CLIENT_TOKEN: string; + export function generateSecMsGecToken(): string; +}