Merge branch 'main' into codex/fix-cron-delete-after-run-cleanup

This commit is contained in:
Ted Li
2026-04-17 21:11:28 -07:00
committed by GitHub
452 changed files with 13838 additions and 7909 deletions

View File

@@ -144,6 +144,7 @@ on:
permissions:
contents: read
pull-requests: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -151,7 +152,63 @@ env:
PNPM_VERSION: "10.32.1"
jobs:
validate_selected_ref:
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
selected_sha: ${{ steps.validate.outputs.selected_sha }}
trusted_reason: ${{ steps.validate.outputs.trusted_reason }}
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
fetch-depth: 0
- name: Validate selected ref
id: validate
env:
GH_TOKEN: ${{ github.token }}
INPUT_REF: ${{ inputs.ref }}
shell: bash
run: |
set -euo pipefail
selected_sha="$(git rev-parse HEAD)"
trusted_reason=""
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
if git merge-base --is-ancestor "$selected_sha" refs/remotes/origin/main; then
trusted_reason="main-ancestor"
elif git tag --points-at "$selected_sha" | grep -Eq '^v'; then
trusted_reason="release-tag"
else
pr_head_count="$(
gh api \
-H "Accept: application/vnd.github+json" \
"repos/${GITHUB_REPOSITORY}/commits/${selected_sha}/pulls" \
--jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${selected_sha}"'")] | length'
)"
if [[ "$pr_head_count" != "0" ]]; then
trusted_reason="open-pr-head"
fi
fi
if [[ -z "$trusted_reason" ]]; then
echo "Ref '${INPUT_REF}' resolved to $selected_sha, which is not trusted for secret-bearing live/E2E checks." >&2
echo "Allowed refs must be on main, point to a release tag, or match an open PR head in ${GITHUB_REPOSITORY}." >&2
exit 1
fi
echo "selected_sha=$selected_sha" >> "$GITHUB_OUTPUT"
echo "trusted_reason=$trusted_reason" >> "$GITHUB_OUTPUT"
{
echo "Validated ref: \`${INPUT_REF}\`"
echo "Resolved SHA: \`$selected_sha\`"
echo "Trust reason: \`$trusted_reason\`"
} >> "$GITHUB_STEP_SUMMARY"
validate_release_live_cache:
needs: validate_selected_ref
if: inputs.include_live_suites
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
@@ -164,7 +221,7 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 0
- name: Setup Node environment
@@ -191,6 +248,7 @@ jobs:
run: pnpm test:live:cache
validate_repo_e2e:
needs: validate_selected_ref
if: inputs.include_repo_e2e
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 90
@@ -200,7 +258,7 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 0
- name: Setup Node environment
@@ -218,6 +276,7 @@ jobs:
run: pnpm test:e2e
validate_special_e2e:
needs: validate_selected_ref
if: inputs.include_repo_e2e || inputs.include_live_suites
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: ${{ matrix.timeout_minutes }}
@@ -245,7 +304,7 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 0
- name: Setup Node environment
@@ -293,6 +352,7 @@ jobs:
run: ${{ matrix.command }}
validate_docker_e2e:
needs: validate_selected_ref
if: inputs.include_release_path_suites || inputs.include_openwebui
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: ${{ matrix.timeout_minutes }}
@@ -396,7 +456,7 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 0
- name: Setup Node environment
@@ -450,6 +510,7 @@ jobs:
run: ${{ matrix.command }}
validate_live_provider_suites:
needs: validate_selected_ref
if: inputs.include_live_suites
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: ${{ matrix.timeout_minutes }}
@@ -538,7 +599,7 @@ jobs:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 0
- name: Setup Node environment
@@ -562,9 +623,39 @@ jobs:
case "${{ matrix.suite_id }}" in
live-cli-backend-docker)
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=codex-cli/gpt-5.4" >> "$GITHUB_ENV"
# The CLI backend Docker lane should exercise the same staged
# Codex auth path Peter uses locally so MCP cron creation and
# multimodal probes stay covered in CI. Replace the staged
# config.toml with a minimal CI-safe config so the repo stays
# trusted for MCP/tool use without inheriting maintainer-local
# provider/profile overrides that do not exist inside CI.
# Codex's workspace-write sandbox relies on user namespaces that
# this Docker lane does not provide, so run Codex unsandboxed
# inside the already-isolated container to keep MCP cron/tool
# execution representative instead of failing on nested sandbox
# setup.
echo 'OPENCLAW_LIVE_CLI_BACKEND_CLEAR_ENV=["OPENAI_API_KEY","OPENAI_BASE_URL"]' >> "$GITHUB_ENV"
echo 'OPENCLAW_LIVE_CLI_BACKEND_ARGS=["exec","--json","--color","never","--sandbox","danger-full-access","--skip-git-repo-check"]' >> "$GITHUB_ENV"
echo 'OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS=["exec","resume","{sessionId}","-c","sandbox_mode=\"danger-full-access\"","--skip-git-repo-check"]' >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CLI_BACKEND_DEBUG=1" >> "$GITHUB_ENV"
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CLI_BACKEND_USE_CI_SAFE_CODEX_CONFIG=1" >> "$GITHUB_ENV"
;;
live-codex-harness-docker)
# Keep CI on the API-key path for now. The staged Codex auth secret
# is currently stale, but the wrapper still supports codex-auth for
# local maintainer reruns without changing Peter's flow.
echo "OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key" >> "$GITHUB_ENV"
;;
live-acp-bind-docker)
echo "OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex,gemini" >> "$GITHUB_ENV"
if [[ -n "${GEMINI_API_KEY:-}" || -n "${GOOGLE_API_KEY:-}" ]]; then
echo "OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex,gemini" >> "$GITHUB_ENV"
else
# The hydrated Gemini settings file only selects Gemini CLI auth
# mode. CI still needs a usable Gemini or Google API key before
# ACP bind can initialize a Gemini session.
echo "OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex" >> "$GITHUB_ENV"
fi
;;
esac

View File

@@ -130,12 +130,19 @@ jobs:
ref: ${{ needs.resolve_target.outputs.ref }}
provider: ${{ needs.resolve_target.outputs.provider }}
mode: ${{ needs.resolve_target.outputs.mode }}
secrets: inherit
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
OPENCLAW_DISCORD_SMOKE_BOT_TOKEN: ${{ secrets.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN }}
OPENCLAW_DISCORD_SMOKE_GUILD_ID: ${{ secrets.OPENCLAW_DISCORD_SMOKE_GUILD_ID }}
OPENCLAW_DISCORD_SMOKE_CHANNEL_ID: ${{ secrets.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID }}
live_and_e2e_release_checks:
needs: [resolve_target]
permissions:
contents: read
pull-requests: read
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
ref: ${{ needs.resolve_target.outputs.ref }}
@@ -143,4 +150,47 @@ jobs:
include_release_path_suites: true
include_openwebui: true
include_live_suites: true
secrets: inherit
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
FAL_KEY: ${{ secrets.FAL_KEY }}
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}

View File

@@ -7,6 +7,7 @@ on:
permissions:
contents: read
pull-requests: read
concurrency:
group: openclaw-scheduled-live-checks-${{ github.ref }}
@@ -19,6 +20,7 @@ jobs:
live_and_openwebui_checks:
permissions:
contents: read
pull-requests: read
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
ref: ${{ github.sha }}
@@ -26,4 +28,47 @@ jobs:
include_release_path_suites: false
include_openwebui: true
include_live_suites: true
secrets: inherit
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
FAL_KEY: ${{ secrets.FAL_KEY }}
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}

View File

@@ -30,7 +30,21 @@ Docs: https://docs.openclaw.ai
- OpenAI Codex/OAuth: keep OpenClaw as the canonical owner for imported Codex CLI OAuth sessions, stop writing refreshed credentials back into `.codex`, and prefer fresher OpenClaw credentials over stale imported CLI state so refresh recovery stays stable. Thanks @vincentkoc.
- OpenAI Codex/OAuth: treat the OpenAI TLS prerequisites probe as advisory instead of a hard blocker, so Codex sign-in can still proceed when the speculative Node/OpenSSL precheck fails but the real OAuth flow still works. Thanks @vincentkoc.
- Models status/OAuth health: align OAuth health reporting with the same effective credential view runtime uses, so expired refreshable sessions stop showing healthy by default and fresher imported Codex CLI credentials surface correctly in `models status`, doctor, and gateway auth status. Thanks @vincentkoc.
- OpenAI Codex/OAuth: keep external CLI OAuth imports runtime-only by overlaying fresher Codex CLI credentials without mutating `auth-profiles.json`, so `.codex` stays a bootstrap/runtime input instead of becoming durable OpenClaw state. Thanks @vincentkoc.
- OpenAI Codex/OAuth: drop legacy CLI-manager routing from the remaining bootstrap path so Codex and MiniMax CLI imports are matched by their canonical OpenClaw profile ids instead of stale `managedBy` metadata. Thanks @vincentkoc.
- OpenAI Codex/OAuth: only bootstrap from external CLI OAuth when the local OpenClaw profile is missing or unusable, so healthy local sessions are no longer overridden by fresher `.codex` tokens. Thanks @vincentkoc.
- OpenAI Codex/OAuth: rename the external CLI bootstrap helper, reuse the same usable-oauth check across runtime fallback paths, and add debug logs plus health coverage so bootstrap decisions stay legible. Thanks @vincentkoc.
- Twitch/setup: load Twitch through the bundled setup-entry discovery path and keep setup/status account detection aligned with runtime config. (#68008) Thanks @gumadeiras.
- Feishu/card actions: resolve card-action chat type from the Feishu chat API when stored context is missing, preferring `chat_mode` over `chat_type`, so DM-originated card actions no longer bypass `dmPolicy` by falling through to the group handling path. (#68201)
- Cron/isolated-agent: preserve `trusted: false` on isolated cron awareness events mirrored into the main session, and forward the optional `trusted` flag through the gateway cron wrapper so explicit trust downgrades survive session-key scoping. (#68210)
- Agents/fallback: recognize bare leading ZenMux `402 ...` quota-refresh errors without misclassifying plain numeric `402 ...` text, and keep the embedded fallback regression coverage stable. (#47579) Thanks @bwjoke.
- Failover/google: only treat `INTERNAL` status payloads as retryable timeouts when they also carry a `500` code, so malformed non-500 payloads do not enter the retry path. (#68238) Thanks @altaywtf and @Openbling.
- Agents/tools: filter bundled MCP/LSP tools through the final owner-only and tool-policy pipeline after merging them into the effective tool list, so existing allowlists, deny rules, sandbox policy, subagent policy, and owner-only restrictions apply to bundled tools the same way they apply to core tools. (#68195)
- Gateway/assistant media: require `operator.read` scope for assistant-media file and metadata requests on identity-bearing HTTP auth paths so callers without a read scope can no longer access assistant media. (#68175) Thanks @eleqtrizit.
- Exec approvals/display: escape raw control characters (including newline and carriage return) in the shared and macOS approval-prompt command sanitizers, so trailing command payloads no longer render on hidden extra lines in the approval UI. (#68198)
- OpenAI Codex/OAuth + Pi: keep imported Codex CLI OAuth bootstrap, Pi auth export, and runtime overlay handling aligned so Codex sessions survive refresh and health checks without leaking transient CLI state into saved auth files. Thanks @vincentkoc.
- Agents/TTS: report failed speech synthesis as a real tool error so unconfigured providers no longer feed successful TTS failure output back into agent loops. (#67980) Thanks @lawrence3699.
- Gateway/wake: allow unknown properties on wake payloads so external senders like Paperclip can attach opaque metadata without failing schema validation. (#68355) Thanks @kagura-agent.
## 2026.4.15

View File

@@ -22,7 +22,21 @@ enum ExecApprovalCommandDisplaySanitizer {
}
private static func shouldEscape(_ scalar: UnicodeScalar) -> Bool {
scalar.properties.generalCategory == .format || self.invisibleCodePoints.contains(scalar.value)
let category = scalar.properties.generalCategory
if category == .control
|| category == .format
|| category == .lineSeparator
|| category == .paragraphSeparator
{
return true
}
// Escape non-ASCII space separators (NBSP, narrow NBSP, ideographic space, etc.) so
// attackers cannot spoof token boundaries in the approval UI with spaces that render
// like a plain space but are handled differently by shells/parsers.
if category == .spaceSeparator, scalar.value != 0x20 {
return true
}
return self.invisibleCodePoints.contains(scalar.value)
}
private static func escape(_ scalar: UnicodeScalar) -> String {

View File

@@ -9,4 +9,37 @@ struct ExecApprovalCommandDisplaySanitizerTests {
ExecApprovalCommandDisplaySanitizer.sanitize(input) ==
"date\\u{200B}\\u{3164}\\u{FFA0}\\u{115F}\\u{1160}가")
}
@Test func `escapes control characters used to spoof line breaks`() {
let input = "echo safe\n\rcurl https://example.test"
#expect(
ExecApprovalCommandDisplaySanitizer.sanitize(input) ==
"echo safe\\u{A}\\u{D}curl https://example.test")
}
@Test func `escapes Unicode line and paragraph separators`() {
let lineInput = "echo ok\u{2028}curl https://example.test"
#expect(
ExecApprovalCommandDisplaySanitizer.sanitize(lineInput) ==
"echo ok\\u{2028}curl https://example.test")
let paragraphInput = "echo ok\u{2029}curl https://example.test"
#expect(
ExecApprovalCommandDisplaySanitizer.sanitize(paragraphInput) ==
"echo ok\\u{2029}curl https://example.test")
}
@Test func `escapes non-ASCII Unicode space separators while preserving ASCII space`() {
let nbspInput = "echo ok\u{00A0}curl"
#expect(
ExecApprovalCommandDisplaySanitizer.sanitize(nbspInput) == "echo ok\\u{A0}curl")
let narrowNbspInput = "echo ok\u{202F}curl"
#expect(
ExecApprovalCommandDisplaySanitizer.sanitize(narrowNbspInput) == "echo ok\\u{202F}curl")
let ideographicSpaceInput = "echo ok\u{3000}curl"
#expect(
ExecApprovalCommandDisplaySanitizer.sanitize(ideographicSpaceInput) ==
"echo ok\\u{3000}curl")
let asciiSpaceInput = "echo ok curl"
#expect(ExecApprovalCommandDisplaySanitizer.sanitize(asciiSpaceInput) == "echo ok curl")
}
}

View File

@@ -1,4 +1,4 @@
3c87ac2fc4c234348eb88812d1904724d7492890498f101d953bc761da8fdead config-baseline.json
eeed6fe659078632d9f95b3350b27103b4aba282d050ff38d3b0953a456d242d config-baseline.core.json
17d43e3c5dd6ac09fa6c7954e6923d2e0fba767483bac0a2b257fb7ec736f8a4 config-baseline.json
fdb7867bbc18792d3645ea36c31b64425d6f19c0b19a7460564f67eb97c0a71e config-baseline.core.json
99bb34fcf83ba6bb50a3fc11f170bd379bee5728b0938707fc39ebd7638e12eb config-baseline.channel.json
5f5d4e850df6e9854a85b5d008236854ce185c707fdbb566efcf00f8c08b36e3 config-baseline.plugin.json
b695cb31b4c0cf1d31f842f2892e99cc3ff8d84263ae72b72977cae844b81d6e config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
e3df4c13b4dcdc07809775c56eed15c3ab924db191a08fb5a7b48d6f73001966 plugin-sdk-api-baseline.json
2bb30ad45d5b382e92fd6b8a240a47f7679c59f9b524e54420879fadc28264b8 plugin-sdk-api-baseline.jsonl
e5c3d6eb56304164434a8b29670a6f02490f359eb7a5f3f4034e61a1b8421c54 plugin-sdk-api-baseline.json
14d07997a35902bbd3c94d528aafa53185bc66a0ddd9b518f87c85a352feb476 plugin-sdk-api-baseline.jsonl

View File

@@ -120,8 +120,8 @@ See [Memory](/concepts/memory) for the workflow and automatic memory flush.
If any bootstrap file is missing, OpenClaw injects a "missing file" marker into
the session and continues. Large bootstrap files are truncated when injected;
adjust limits with `agents.defaults.bootstrapMaxChars` (default: 20000) and
`agents.defaults.bootstrapTotalMaxChars` (default: 150000).
adjust limits with `agents.defaults.bootstrapMaxChars` (default: 12000) and
`agents.defaults.bootstrapTotalMaxChars` (default: 60000).
`openclaw setup` can recreate missing defaults without overwriting existing
files.

View File

@@ -38,7 +38,7 @@ Values vary by model, provider, tool policy, and whats in your workspace.
```
🧠 Context breakdown
Workspace: <workspaceDir>
Bootstrap max/file: 20,000 chars
Bootstrap max/file: 12,000 chars
Sandbox: mode=non-main sandboxed=false
System prompt (run): 38,412 chars (~9,603 tok) (Project Context 23,901 chars (~5,976 tok))
@@ -112,7 +112,7 @@ By default, OpenClaw injects a fixed set of workspace files (if present):
- `HEARTBEAT.md`
- `BOOTSTRAP.md` (first-run only)
Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (default `20000` chars). OpenClaw also enforces a total bootstrap injection cap across files with `agents.defaults.bootstrapTotalMaxChars` (default `150000` chars). `/context` shows **raw vs injected** sizes and whether truncation happened.
Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (default `12000` chars). OpenClaw also enforces a total bootstrap injection cap across files with `agents.defaults.bootstrapTotalMaxChars` (default `60000` chars). `/context` shows **raw vs injected** sizes and whether truncation happened.
When truncation occurs, the runtime can inject an in-prompt warning block under Project Context. Configure this with `agents.defaults.bootstrapPromptTruncationWarning` (`off`, `once`, `always`; default `once`).

View File

@@ -118,9 +118,9 @@ unexpectedly high context usage and more frequent compaction.
> as a one-shot startup-context block for that first turn.
Large files are truncated with a marker. The max per-file size is controlled by
`agents.defaults.bootstrapMaxChars` (default: 20000). Total injected bootstrap
`agents.defaults.bootstrapMaxChars` (default: 12000). Total injected bootstrap
content across files is capped by `agents.defaults.bootstrapTotalMaxChars`
(default: 150000). Missing files inject a short missing-file marker. When truncation
(default: 60000). Missing files inject a short missing-file marker. When truncation
occurs, OpenClaw can inject a warning block in Project Context; control this with
`agents.defaults.bootstrapPromptTruncationWarning` (`off`, `once`, `always`;
default: `once`).

View File

@@ -955,21 +955,21 @@ Controls when workspace bootstrap files are injected into the system prompt. Def
### `agents.defaults.bootstrapMaxChars`
Max characters per workspace bootstrap file before truncation. Default: `20000`.
Max characters per workspace bootstrap file before truncation. Default: `12000`.
```json5
{
agents: { defaults: { bootstrapMaxChars: 20000 } },
agents: { defaults: { bootstrapMaxChars: 12000 } },
}
```
### `agents.defaults.bootstrapTotalMaxChars`
Max total characters injected across all workspace bootstrap files. Default: `150000`.
Max total characters injected across all workspace bootstrap files. Default: `60000`.
```json5
{
agents: { defaults: { bootstrapTotalMaxChars: 150000 } },
agents: { defaults: { bootstrapTotalMaxChars: 60000 } },
}
```

View File

@@ -1,4 +1,5 @@
export { CLAUDE_CLI_BACKEND_ID, isClaudeCliProvider } from "./cli-shared.js";
export { buildAnthropicProvider } from "./register.runtime.js";
export {
createAnthropicBetaHeadersWrapper,
createAnthropicFastModeWrapper,

View File

@@ -0,0 +1,59 @@
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
const noopAuth = async () => ({ profiles: [] });
export function createAnthropicProvider(): ProviderPlugin {
return {
id: "anthropic",
label: "Anthropic",
docsPath: "/providers/models",
hookAliases: ["claude-cli"],
envVars: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
auth: [
{
id: "cli",
kind: "custom",
label: "Claude CLI",
hint: "Reuse a local Claude CLI login and switch model selection to claude-cli/*",
run: noopAuth,
wizard: {
choiceId: "anthropic-cli",
choiceLabel: "Anthropic Claude CLI",
choiceHint: "Reuse a local Claude CLI login on this host",
groupId: "anthropic",
groupLabel: "Anthropic",
groupHint: "Claude CLI + API key",
},
},
{
id: "setup-token",
kind: "token",
label: "Anthropic setup-token",
hint: "Manual bearer token path",
run: noopAuth,
wizard: {
choiceId: "setup-token",
choiceLabel: "Anthropic setup-token",
choiceHint: "Manual token path",
groupId: "anthropic",
groupLabel: "Anthropic",
groupHint: "Claude CLI + API key + token",
},
},
{
id: "api-key",
kind: "api_key",
label: "Anthropic API key",
hint: "Direct Anthropic API key",
run: noopAuth,
wizard: {
choiceId: "apiKey",
choiceLabel: "Anthropic API key",
groupId: "anthropic",
groupLabel: "Anthropic",
groupHint: "Claude CLI + API key",
},
},
],
};
}

View File

@@ -18,7 +18,10 @@ import {
upsertAuthProfile,
validateAnthropicSetupToken,
} from "openclaw/plugin-sdk/provider-auth";
import { cloneFirstTemplateModel } from "openclaw/plugin-sdk/provider-model-shared";
import {
cloneFirstTemplateModel,
type ProviderPlugin,
} from "openclaw/plugin-sdk/provider-model-shared";
import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import * as claudeCliAuth from "./cli-auth-seam.js";
@@ -395,11 +398,10 @@ async function runAnthropicCliMigrationNonInteractive(ctx: {
};
}
export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
export function buildAnthropicProvider(): ProviderPlugin {
const providerId = "anthropic";
const defaultAnthropicModel = DEFAULT_ANTHROPIC_MODEL;
api.registerCliBackend(buildAnthropicCliBackend());
api.registerProvider({
return {
id: providerId,
label: "Anthropic",
docsPath: "/providers/models",
@@ -505,6 +507,11 @@ export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
store: ctx.store,
profileId: ctx.profileId,
}),
});
};
}
export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
api.registerCliBackend(buildAnthropicCliBackend());
api.registerProvider(buildAnthropicProvider());
api.registerMediaUnderstandingProvider(anthropicMediaUnderstandingProvider);
}

View File

@@ -1,7 +1,8 @@
import fs from "node:fs";
import { afterEach, describe, expect, it, vi } from "vitest";
import { validateJsonSchemaValue } from "../../../src/plugins/schema-validator.js";
import { __testing, createBraveWebSearchProvider } from "./brave-web-search-provider.js";
import { __testing } from "../test-api.js";
import { createBraveWebSearchProvider } from "./brave-web-search-provider.js";
const braveManifest = JSON.parse(
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"),

View File

@@ -3,25 +3,59 @@ import type {
WebSearchProviderPlugin,
WebSearchProviderToolDefinition,
} from "openclaw/plugin-sdk/provider-web-search";
import { isRecord } from "openclaw/plugin-sdk/text-runtime";
import {
createBraveSchema,
mapBraveLlmContextResults,
normalizeBraveCountry,
normalizeBraveLanguageParams,
resolveBraveConfig,
resolveBraveMode,
} from "./brave-web-search-provider.shared.js";
import { createWebSearchProviderContractFields } from "openclaw/plugin-sdk/provider-web-search-config-contract";
type ConfigInput = Parameters<
NonNullable<WebSearchProviderPlugin["getConfiguredCredentialValue"]>
>[0];
type ConfigTarget = Parameters<
NonNullable<WebSearchProviderPlugin["setConfiguredCredentialValue"]>
>[0];
const BRAVE_CREDENTIAL_PATH = "plugins.entries.brave.config.webSearch.apiKey";
const BraveSearchSchema = {
type: "object",
properties: {
query: { type: "string", description: "Search query string." },
count: {
type: "number",
description: "Number of results to return (1-10).",
minimum: 1,
maximum: 10,
},
country: {
type: "string",
description:
"2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.",
},
language: {
type: "string",
description: "ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').",
},
freshness: {
type: "string",
description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.",
},
date_after: {
type: "string",
description: "Only results published after this date (YYYY-MM-DD).",
},
date_before: {
type: "string",
description: "Only results published before this date (YYYY-MM-DD).",
},
search_lang: {
type: "string",
description:
"Brave language code for search results (e.g., 'en', 'de', 'en-gb', 'zh-hans', 'zh-hant', 'pt-br').",
},
ui_lang: {
type: "string",
description:
"Locale code for UI elements in language-region format (e.g., 'en-US', 'de-DE', 'fr-FR', 'tr-TR'). Must include region subtag.",
},
},
} satisfies Record<string, unknown>;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function resolveProviderWebSearchPluginConfig(
config: ConfigInput,
config: unknown,
pluginId: string,
): Record<string, unknown> | undefined {
if (!isRecord(config)) {
@@ -34,40 +68,6 @@ function resolveProviderWebSearchPluginConfig(
return isRecord(pluginConfig?.webSearch) ? pluginConfig.webSearch : undefined;
}
function ensureObject(target: Record<string, unknown>, key: string): Record<string, unknown> {
const current = target[key];
if (isRecord(current)) {
return current;
}
const next: Record<string, unknown> = {};
target[key] = next;
return next;
}
function setProviderWebSearchPluginConfigValue(
configTarget: ConfigTarget,
pluginId: string,
key: string,
value: unknown,
): void {
const plugins = ensureObject(configTarget as Record<string, unknown>, "plugins");
const entries = ensureObject(plugins, "entries");
const entry = ensureObject(entries, pluginId);
if (entry.enabled === undefined) {
entry.enabled = true;
}
const config = ensureObject(entry, "config");
const webSearch = ensureObject(config, "webSearch");
webSearch[key] = value;
}
function setTopLevelCredentialValue(
searchConfigTarget: Record<string, unknown>,
value: unknown,
): void {
searchConfigTarget.apiKey = value;
}
function mergeScopedSearchConfig(
searchConfig: Record<string, unknown> | undefined,
key: string,
@@ -94,17 +94,22 @@ function mergeScopedSearchConfig(
return next;
}
function resolveBraveMode(searchConfig?: Record<string, unknown>): "web" | "llm-context" {
const brave = isRecord(searchConfig?.brave) ? searchConfig.brave : undefined;
return brave?.mode === "llm-context" ? "llm-context" : "web";
}
function createBraveToolDefinition(
searchConfig?: SearchConfigRecord,
): WebSearchProviderToolDefinition {
const braveMode = resolveBraveMode(resolveBraveConfig(searchConfig));
const braveMode = resolveBraveMode(searchConfig);
return {
description:
braveMode === "llm-context"
? "Search the web using Brave Search LLM Context API. Returns pre-extracted page content (text chunks, tables, code blocks) optimized for LLM grounding."
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.",
parameters: createBraveSchema(),
parameters: BraveSearchSchema,
execute: async (args) => {
const { executeBraveSearch } = await import("./brave-web-search-provider.runtime.js");
return await executeBraveSearch(args, searchConfig);
@@ -124,15 +129,12 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
signupUrl: "https://brave.com/search/api/",
docsUrl: "https://docs.openclaw.ai/brave-search",
autoDetectOrder: 10,
credentialPath: "plugins.entries.brave.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.brave.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => searchConfig?.apiKey,
setCredentialValue: setTopLevelCredentialValue,
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "brave")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "brave", "apiKey", value);
},
credentialPath: BRAVE_CREDENTIAL_PATH,
...createWebSearchProviderContractFields({
credentialPath: BRAVE_CREDENTIAL_PATH,
searchCredential: { type: "top-level" },
configuredCredential: { pluginId: "brave" },
}),
createTool: (ctx) =>
createBraveToolDefinition(
mergeScopedSearchConfig(
@@ -144,10 +146,3 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
),
};
}
export const __testing = {
normalizeBraveCountry,
normalizeBraveLanguageParams,
resolveBraveMode,
mapBraveLlmContextResults,
} as const;

View File

@@ -1 +1,13 @@
export { __testing } from "./src/brave-web-search-provider.js";
import {
mapBraveLlmContextResults,
normalizeBraveCountry,
normalizeBraveLanguageParams,
resolveBraveMode,
} from "./src/brave-web-search-provider.shared.js";
export const __testing = {
normalizeBraveCountry,
normalizeBraveLanguageParams,
resolveBraveMode,
mapBraveLlmContextResults,
} as const;

View File

@@ -1 +1 @@
export { __testing, createBraveWebSearchProvider } from "./src/brave-web-search-provider.js";
export { createBraveWebSearchProvider } from "./src/brave-web-search-provider.js";

View File

@@ -0,0 +1,4 @@
export {
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,
} from "./src/directory-config.js";

View File

@@ -2,7 +2,7 @@ import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
createResolvedDirectoryEntriesLister,
type DirectoryConfigParams,
} from "openclaw/plugin-sdk/directory-runtime";
} from "openclaw/plugin-sdk/directory-config-runtime";
import { mergeDiscordAccountConfig, resolveDefaultDiscordAccountId } from "./accounts.js";
function resolveDiscordDirectoryConfigAccount(

View File

@@ -1,17 +1,25 @@
import { ChannelType } from "discord-api-types/v10";
import type { NativeCommandSpec } from "openclaw/plugin-sdk/command-auth";
import { resolveDirectStatusReplyForSession } from "openclaw/plugin-sdk/command-status-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { clearPluginCommands, registerPluginCommand } from "openclaw/plugin-sdk/plugin-runtime";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
clearPluginCommands,
executePluginCommand,
matchPluginCommand,
registerPluginCommand,
} from "openclaw/plugin-sdk/plugin-runtime";
import { dispatchReplyWithDispatcher } from "openclaw/plugin-sdk/reply-dispatch-runtime";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
createTestRegistry,
setActivePluginRegistry,
} from "../../../../test/helpers/plugins/plugin-registry.js";
import { resolveDiscordNativeInteractionRouteState } from "./native-command-route.js";
import {
createMockCommandInteraction,
type MockCommandInteraction,
} from "./native-command.test-helpers.js";
import { createNoopThreadBindingManager } from "./thread-bindings.js";
import { createNoopThreadBindingManager } from "./thread-bindings.manager.js";
let createDiscordNativeCommand: typeof import("./native-command.js").createDiscordNativeCommand;
let discordNativeCommandTesting: typeof import("./native-command.js").__testing;
@@ -22,33 +30,6 @@ const runtimeModuleMocks = vi.hoisted(() => ({
resolveDirectStatusReplyForSession: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/plugin-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/plugin-runtime")>(
"openclaw/plugin-sdk/plugin-runtime",
);
return {
...actual,
matchPluginCommand: (...args: unknown[]) => runtimeModuleMocks.matchPluginCommand(...args),
executePluginCommand: (...args: unknown[]) => runtimeModuleMocks.executePluginCommand(...args),
};
});
vi.mock("openclaw/plugin-sdk/reply-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/reply-runtime")>(
"openclaw/plugin-sdk/reply-runtime",
);
return {
...actual,
dispatchReplyWithDispatcher: (...args: unknown[]) =>
runtimeModuleMocks.dispatchReplyWithDispatcher(...args),
};
});
vi.mock("openclaw/plugin-sdk/command-status-runtime", () => ({
resolveDirectStatusReplyForSession: (...args: unknown[]) =>
runtimeModuleMocks.resolveDirectStatusReplyForSession(...args),
}));
function createInteraction(params?: {
channelType?: ChannelType;
channelId?: string;
@@ -270,13 +251,16 @@ async function expectPairCommandReply(params: {
cfg: OpenClawConfig;
commandName: string;
interaction: MockCommandInteraction;
expectedRegisteredName?: string;
}) {
const command = await createPluginCommand({
cfg: params.cfg,
name: params.commandName,
});
const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher;
const executeSpy = runtimeModuleMocks.executePluginCommand.mockResolvedValue({
text: "paired:now",
});
await (command as { run: (interaction: unknown) => Promise<void> }).run(
Object.assign(params.interaction, {
options: {
@@ -288,6 +272,12 @@ async function expectPairCommandReply(params: {
);
expect(dispatchSpy).not.toHaveBeenCalled();
expect(executeSpy).toHaveBeenCalledWith(
expect.objectContaining({
command: expect.objectContaining({ name: params.expectedRegisteredName ?? "pair" }),
args: "now",
}),
);
expect(params.interaction.followUp).toHaveBeenCalledWith(
expect.objectContaining({ content: "paired:now" }),
);
@@ -338,21 +328,28 @@ describe("Discord native plugin command dispatch", () => {
await import("./native-command.js"));
});
beforeEach(async () => {
afterAll(() => {
clearPluginCommands();
setActivePluginRegistry(createTestRegistry());
discordNativeCommandTesting.setMatchPluginCommand(matchPluginCommand);
discordNativeCommandTesting.setExecutePluginCommand(executePluginCommand);
discordNativeCommandTesting.setDispatchReplyWithDispatcher(dispatchReplyWithDispatcher);
discordNativeCommandTesting.setResolveDirectStatusReplyForSession(
resolveDirectStatusReplyForSession,
);
discordNativeCommandTesting.setResolveDiscordNativeInteractionRouteState(
resolveDiscordNativeInteractionRouteState,
);
});
beforeEach(() => {
vi.clearAllMocks();
clearPluginCommands();
setActivePluginRegistry(createTestRegistry());
const actualPluginRuntime = await vi.importActual<
typeof import("openclaw/plugin-sdk/plugin-runtime")
>("openclaw/plugin-sdk/plugin-runtime");
runtimeModuleMocks.matchPluginCommand.mockReset();
runtimeModuleMocks.matchPluginCommand.mockImplementation(
actualPluginRuntime.matchPluginCommand,
);
runtimeModuleMocks.matchPluginCommand.mockImplementation(matchPluginCommand);
runtimeModuleMocks.executePluginCommand.mockReset();
runtimeModuleMocks.executePluginCommand.mockImplementation(
actualPluginRuntime.executePluginCommand,
);
runtimeModuleMocks.executePluginCommand.mockImplementation(executePluginCommand);
runtimeModuleMocks.dispatchReplyWithDispatcher.mockReset();
runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue({
counts: {
@@ -372,7 +369,10 @@ describe("Discord native plugin command dispatch", () => {
runtimeModuleMocks.executePluginCommand as typeof import("openclaw/plugin-sdk/plugin-runtime").executePluginCommand,
);
discordNativeCommandTesting.setDispatchReplyWithDispatcher(
runtimeModuleMocks.dispatchReplyWithDispatcher as typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithDispatcher,
runtimeModuleMocks.dispatchReplyWithDispatcher as typeof dispatchReplyWithDispatcher,
);
discordNativeCommandTesting.setResolveDirectStatusReplyForSession(
runtimeModuleMocks.resolveDirectStatusReplyForSession as typeof resolveDirectStatusReplyForSession,
);
discordNativeCommandTesting.setResolveDiscordNativeInteractionRouteState(async (params) =>
createUnboundRouteState({
@@ -436,7 +436,6 @@ describe("Discord native plugin command dispatch", () => {
description: "Pair",
acceptsArgs: true,
};
const command = await createNativeCommand(cfg, commandSpec);
const interaction = createInteraction({
channelType: ChannelType.GuildText,
channelId: "234567890123456789",
@@ -455,6 +454,7 @@ describe("Discord native plugin command dispatch", () => {
handler: async ({ args }) => ({ text: `open:${args ?? ""}` }),
}),
).toEqual({ ok: true });
const command = await createNativeCommand(cfg, commandSpec);
const executeSpy = runtimeModuleMocks.executePluginCommand;
const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue(

View File

@@ -94,6 +94,7 @@ const DISCORD_COMMAND_DESCRIPTION_MAX = 100;
let matchPluginCommandImpl = pluginRuntime.matchPluginCommand;
let executePluginCommandImpl = pluginRuntime.executePluginCommand;
let dispatchReplyWithDispatcherImpl = dispatchReplyWithDispatcher;
let resolveDirectStatusReplyForSessionImpl = resolveDirectStatusReplyForSession;
let resolveDiscordNativeInteractionRouteStateImpl = resolveDiscordNativeInteractionRouteState;
export const __testing = {
@@ -118,6 +119,13 @@ export const __testing = {
dispatchReplyWithDispatcherImpl = next;
return previous;
},
setResolveDirectStatusReplyForSession(
next: typeof resolveDirectStatusReplyForSession,
): typeof resolveDirectStatusReplyForSession {
const previous = resolveDirectStatusReplyForSessionImpl;
resolveDirectStatusReplyForSessionImpl = next;
return previous;
},
setResolveDiscordNativeInteractionRouteState(
next: typeof resolveDiscordNativeInteractionRouteState,
): typeof resolveDiscordNativeInteractionRouteState {
@@ -621,6 +629,19 @@ async function safeDiscordInteractionCall<T>(
}
}
function createNativeCommandDefinition(command: NativeCommandSpec): ChatCommandDefinition {
return {
key: command.name,
nativeName: command.name,
description: command.description,
textAliases: [],
acceptsArgs: command.acceptsArgs,
args: command.args,
argsParsing: "none",
scope: "native",
};
}
export function createDiscordNativeCommand(params: {
command: NativeCommandSpec;
cfg: ReturnType<typeof loadConfig>;
@@ -639,18 +660,13 @@ export function createDiscordNativeCommand(params: {
ephemeralDefault,
threadBindings,
} = params;
const fallbackCommandDefinition = createNativeCommandDefinition(command);
const commandDefinition =
findCommandByNativeName(command.name, "discord") ??
({
key: command.name,
nativeName: command.name,
description: command.description,
textAliases: [],
acceptsArgs: command.acceptsArgs,
args: command.args,
argsParsing: "none",
scope: "native",
} satisfies ChatCommandDefinition);
matchPluginCommandImpl(`/${command.name}`) !== null
? fallbackCommandDefinition
: (findCommandByNativeName(command.name, "discord", {
includeBundledChannelFallback: false,
}) ?? fallbackCommandDefinition);
const argDefinitions = commandDefinition.args ?? command.args;
const commandOptions = buildDiscordCommandOptions({
command: commandDefinition,
@@ -1130,7 +1146,7 @@ async function dispatchDiscordCommandInteraction(params: {
});
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, effectiveRoute.agentId);
if (!suppressReplies && commandName === "status") {
const statusReply = await resolveDirectStatusReplyForSession({
const statusReply = await resolveDirectStatusReplyForSessionImpl({
cfg,
sessionKey: commandTargetSessionKey?.trim() || sessionKey,
channel: "discord",

View File

@@ -18,14 +18,34 @@ import {
normalizeOptionalStringifiedId,
} from "openclaw/plugin-sdk/text-runtime";
import type { DiscordComponentMessageSpec } from "./components.js";
import { getThreadBindingManager, type ThreadBindingRecord } from "./monitor/thread-bindings.js";
import type { ThreadBindingRecord } from "./monitor/thread-bindings.js";
import { normalizeDiscordOutboundTarget } from "./normalize.js";
import { sendDiscordComponentMessage } from "./send.components.js";
import { sendMessageDiscord, sendPollDiscord, sendWebhookMessageDiscord } from "./send.js";
import { buildDiscordInteractiveComponents } from "./shared-interactive.js";
export const DISCORD_TEXT_CHUNK_LIMIT = 2000;
type DiscordSendRuntime = typeof import("./send.js");
type DiscordSendFn = DiscordSendRuntime["sendMessageDiscord"];
type DiscordComponentSendFn = typeof import("./send.components.js").sendDiscordComponentMessage;
let discordSendRuntimePromise: Promise<DiscordSendRuntime> | undefined;
let discordComponentSendPromise: Promise<DiscordComponentSendFn> | undefined;
async function loadDiscordSendRuntime(): Promise<DiscordSendRuntime> {
discordSendRuntimePromise ??= import("./send.js");
return await discordSendRuntimePromise;
}
async function sendDiscordComponentMessageLazy(
...args: Parameters<DiscordComponentSendFn>
): ReturnType<DiscordComponentSendFn> {
discordComponentSendPromise ??= import("./send.components.js").then(
(module) => module.sendDiscordComponentMessage,
);
return await (
await discordComponentSendPromise
)(...args);
}
function hasApprovalChannelData(payload: { channelData?: unknown }): boolean {
const channelData = payload.channelData;
if (!channelData || typeof channelData !== "object" || Array.isArray(channelData)) {
@@ -93,6 +113,7 @@ async function maybeSendDiscordWebhookText(params: {
if (!threadId) {
return null;
}
const { getThreadBindingManager } = await import("./monitor/thread-bindings.js");
const manager = getThreadBindingManager(params.accountId ?? undefined);
if (!manager) {
return null;
@@ -105,6 +126,7 @@ async function maybeSendDiscordWebhookText(params: {
identity: params.identity,
binding,
});
const { sendWebhookMessageDiscord } = await loadDiscordSendRuntime();
const result = await sendWebhookMessageDiscord(params.text, {
webhookId: binding.webhookId,
webhookToken: binding.webhookToken,
@@ -134,7 +156,12 @@ export const discordOutbound: ChannelOutboundAdapter = {
| { components?: DiscordComponentMessageSpec }
| undefined;
const rawComponentSpec =
discordData?.components ?? buildDiscordInteractiveComponents(payload.interactive);
discordData?.components ??
(payload.interactive
? (await import("./shared-interactive.js")).buildDiscordInteractiveComponents(
payload.interactive,
)
: undefined);
const componentSpec = rawComponentSpec
? rawComponentSpec.text
? rawComponentSpec
@@ -154,7 +181,8 @@ export const discordOutbound: ChannelOutboundAdapter = {
});
}
const send =
resolveOutboundSendDep<typeof sendMessageDiscord>(ctx.deps, "discord") ?? sendMessageDiscord;
resolveOutboundSendDep<DiscordSendFn>(ctx.deps, "discord") ??
(await loadDiscordSendRuntime()).sendMessageDiscord;
const target = resolveDiscordOutboundTarget({ to: ctx.to, threadId: ctx.threadId });
const mediaUrls = resolvePayloadMediaUrls(payload);
const result = await sendPayloadMediaSequenceOrFallback({
@@ -162,7 +190,7 @@ export const discordOutbound: ChannelOutboundAdapter = {
mediaUrls,
fallbackResult: { messageId: "", channelId: target },
sendNoMedia: async () =>
await sendDiscordComponentMessage(target, componentSpec, {
await sendDiscordComponentMessageLazy(target, componentSpec, {
replyTo: ctx.replyToId ?? undefined,
accountId: ctx.accountId ?? undefined,
silent: ctx.silent ?? undefined,
@@ -170,7 +198,7 @@ export const discordOutbound: ChannelOutboundAdapter = {
}),
send: async ({ text, mediaUrl, isFirst }) => {
if (isFirst) {
return await sendDiscordComponentMessage(target, componentSpec, {
return await sendDiscordComponentMessageLazy(target, componentSpec, {
mediaUrl,
mediaAccess: ctx.mediaAccess,
mediaLocalRoots: ctx.mediaLocalRoots,
@@ -213,7 +241,8 @@ export const discordOutbound: ChannelOutboundAdapter = {
}
}
const send =
resolveOutboundSendDep<typeof sendMessageDiscord>(deps, "discord") ?? sendMessageDiscord;
resolveOutboundSendDep<DiscordSendFn>(deps, "discord") ??
(await loadDiscordSendRuntime()).sendMessageDiscord;
return await send(resolveDiscordOutboundTarget({ to, threadId }), text, {
verbose: false,
replyTo: replyToId ?? undefined,
@@ -236,7 +265,8 @@ export const discordOutbound: ChannelOutboundAdapter = {
silent,
}) => {
const send =
resolveOutboundSendDep<typeof sendMessageDiscord>(deps, "discord") ?? sendMessageDiscord;
resolveOutboundSendDep<DiscordSendFn>(deps, "discord") ??
(await loadDiscordSendRuntime()).sendMessageDiscord;
return await send(resolveDiscordOutboundTarget({ to, threadId }), text, {
verbose: false,
mediaUrl,
@@ -249,7 +279,9 @@ export const discordOutbound: ChannelOutboundAdapter = {
});
},
sendPoll: async ({ cfg, to, poll, accountId, threadId, silent }) =>
await sendPollDiscord(resolveDiscordOutboundTarget({ to, threadId }), poll, {
await (
await loadDiscordSendRuntime()
).sendPollDiscord(resolveDiscordOutboundTarget({ to, threadId }), poll, {
accountId: accountId ?? undefined,
silent: silent ?? undefined,
cfg,

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { createDiscordPluginBase } from "./shared.js";
describe("createDiscordPluginBase", () => {
it("owns Discord native command name overrides", () => {
const plugin = createDiscordPluginBase({ setup: {} as never });
expect(
plugin.commands?.resolveNativeCommandName?.({
commandKey: "tts",
defaultName: "tts",
}),
).toBe("voice");
expect(
plugin.commands?.resolveNativeCommandName?.({
commandKey: "status",
defaultName: "status",
}),
).toBe("status");
});
});

View File

@@ -1,37 +1,29 @@
import { Type } from "@sinclair/typebox";
import {
enablePluginInConfig,
getScopedCredentialValue,
readNumberParam,
readStringParam,
setScopedCredentialValue,
createWebSearchProviderContractFields,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search";
import { runDuckDuckGoSearch } from "./ddg-client.js";
} from "openclaw/plugin-sdk/provider-web-search-contract";
const DuckDuckGoSearchSchema = Type.Object(
{
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Number({
description: "Number of results to return (1-10).",
minimum: 1,
maximum: 10,
}),
),
region: Type.Optional(
Type.String({
description: "Optional DuckDuckGo region code such as us-en, uk-en, or de-de.",
}),
),
safeSearch: Type.Optional(
Type.String({
description: "SafeSearch level: strict, moderate, or off.",
}),
),
const DuckDuckGoSearchSchema = {
type: "object",
properties: {
query: { type: "string", description: "Search query string." },
count: {
type: "number",
description: "Number of results to return (1-10).",
minimum: 1,
maximum: 10,
},
region: {
type: "string",
description: "Optional DuckDuckGo region code such as us-en, uk-en, or de-de.",
},
safeSearch: {
type: "string",
description: "SafeSearch level: strict, moderate, or off.",
},
},
{ additionalProperties: false },
);
additionalProperties: false,
} satisfies Record<string, unknown>;
export function createDuckDuckGoWebSearchProvider(): WebSearchProviderPlugin {
return {
@@ -45,17 +37,21 @@ export function createDuckDuckGoWebSearchProvider(): WebSearchProviderPlugin {
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 100,
credentialPath: "",
inactiveSecretPaths: [],
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "duckduckgo"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "duckduckgo", value),
applySelectionConfig: (config) => enablePluginInConfig(config, "duckduckgo").config,
...createWebSearchProviderContractFields({
credentialPath: "",
searchCredential: { type: "scoped", scopeId: "duckduckgo" },
selectionPluginId: "duckduckgo",
}),
createTool: (ctx) => ({
description:
"Search the web using DuckDuckGo. Returns titles, URLs, and snippets with no API key required.",
parameters: DuckDuckGoSearchSchema,
execute: async (args) =>
await runDuckDuckGoSearch({
execute: async (args) => {
const [{ runDuckDuckGoSearch }, { readNumberParam, readStringParam }] = await Promise.all([
import("./ddg-client.js"),
import("openclaw/plugin-sdk/provider-web-search"),
]);
return await runDuckDuckGoSearch({
config: ctx.config,
query: readStringParam(args, "query", { required: true }),
count: readNumberParam(args, "count", { integer: true }),
@@ -65,7 +61,8 @@ export function createDuckDuckGoWebSearchProvider(): WebSearchProviderPlugin {
| "moderate"
| "off"
| undefined,
}),
});
},
}),
};
}

View File

@@ -0,0 +1,525 @@
import {
buildSearchCacheKey,
DEFAULT_SEARCH_COUNT,
mergeScopedSearchConfig,
parseIsoDateRange,
readCachedSearchPayload,
readConfiguredSecretString,
readNumberParam,
readProviderEnvValue,
readStringParam,
resolveProviderWebSearchPluginConfig,
resolveSearchCacheTtlMs,
resolveSearchTimeoutSeconds,
resolveSiteName,
type SearchConfigRecord,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
const EXA_SEARCH_ENDPOINT = "https://api.exa.ai/search";
const EXA_SEARCH_TYPES = ["auto", "neural", "fast", "deep", "deep-reasoning", "instant"] as const;
const EXA_FRESHNESS_VALUES = ["day", "week", "month", "year"] as const;
const EXA_MAX_SEARCH_COUNT = 100;
type ExaConfig = {
apiKey?: string;
};
type ExaSearchType = (typeof EXA_SEARCH_TYPES)[number];
type ExaFreshness = (typeof EXA_FRESHNESS_VALUES)[number];
type ExaTextContentsOption = boolean | { maxCharacters?: number };
type ExaHighlightsContentsOption =
| boolean
| {
maxCharacters?: number;
query?: string;
numSentences?: number;
highlightsPerUrl?: number;
};
type ExaSummaryContentsOption = boolean | { query?: string };
type ExaContentsArgs = {
highlights?: ExaHighlightsContentsOption;
text?: ExaTextContentsOption;
summary?: ExaSummaryContentsOption;
};
type ExaSearchResult = {
title?: unknown;
url?: unknown;
publishedDate?: unknown;
highlights?: unknown;
highlightScores?: unknown;
summary?: unknown;
text?: unknown;
};
type ExaSearchResponse = {
results?: unknown;
};
function normalizeExaFreshness(value: string | undefined): ExaFreshness | undefined {
const trimmed = normalizeOptionalLowercaseString(value);
if (!trimmed) {
return undefined;
}
return EXA_FRESHNESS_VALUES.includes(trimmed as ExaFreshness)
? (trimmed as ExaFreshness)
: undefined;
}
function resolveExaConfig(searchConfig?: SearchConfigRecord): ExaConfig {
const exa = searchConfig?.exa;
return exa && typeof exa === "object" && !Array.isArray(exa) ? (exa as ExaConfig) : {};
}
function resolveExaApiKey(exa?: ExaConfig): string | undefined {
return (
readConfiguredSecretString(exa?.apiKey, "tools.web.search.exa.apiKey") ??
readProviderEnvValue(["EXA_API_KEY"])
);
}
function resolveExaDescription(result: ExaSearchResult): string {
const highlights = result.highlights;
if (Array.isArray(highlights)) {
const highlightText = highlights
.map((entry) => normalizeOptionalString(entry))
.filter((entry): entry is string => Boolean(entry))
.join("\n");
if (highlightText) {
return highlightText;
}
}
const summary = normalizeOptionalString(result.summary);
if (summary) {
return summary;
}
return normalizeOptionalString(result.text) ?? "";
}
function parsePositiveInteger(value: unknown): number | undefined {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
}
function invalidContentsPayload(message: string) {
return {
error: "invalid_contents",
message,
docs: "https://docs.openclaw.ai/tools/web",
};
}
function isErrorPayload(value: unknown): value is { error: string; message: string; docs: string } {
return Boolean(
value && typeof value === "object" && "error" in value && "message" in value && "docs" in value,
);
}
function resolveExaSearchCount(value: unknown, fallback: number): number {
const parsed = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(parsed)) {
return fallback;
}
return Math.max(1, Math.min(EXA_MAX_SEARCH_COUNT, Math.floor(parsed)));
}
function parseExaContents(
rawContents: unknown,
): { value?: ExaContentsArgs } | { error: string; message: string; docs: string } {
if (rawContents === undefined) {
return { value: undefined };
}
if (!rawContents || typeof rawContents !== "object" || Array.isArray(rawContents)) {
return invalidContentsPayload(
"contents must be an object with optional text, highlights, and summary fields.",
);
}
const raw = rawContents as Record<string, unknown>;
const allowedKeys = new Set(["text", "highlights", "summary"]);
for (const key of Object.keys(raw)) {
if (!allowedKeys.has(key)) {
return invalidContentsPayload(
`contents has unknown field "${key}". Only "text", "highlights", and "summary" are allowed.`,
);
}
}
const parsed: ExaContentsArgs = {};
const parseText = (
value: unknown,
): ExaTextContentsOption | { error: string; message: string; docs: string } => {
if (typeof value === "boolean") {
return value;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return invalidContentsPayload("contents.text must be a boolean or an object.");
}
const obj = value as Record<string, unknown>;
for (const key of Object.keys(obj)) {
if (key !== "maxCharacters") {
return invalidContentsPayload(
`contents.text has unknown field "${key}". Only "maxCharacters" is allowed.`,
);
}
}
if ("maxCharacters" in obj && parsePositiveInteger(obj.maxCharacters) === undefined) {
return invalidContentsPayload("contents.text.maxCharacters must be a positive integer.");
}
return parsePositiveInteger(obj.maxCharacters)
? { maxCharacters: parsePositiveInteger(obj.maxCharacters) }
: {};
};
const parseHighlights = (
value: unknown,
): ExaHighlightsContentsOption | { error: string; message: string; docs: string } => {
if (typeof value === "boolean") {
return value;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return invalidContentsPayload("contents.highlights must be a boolean or an object.");
}
const obj = value as Record<string, unknown>;
const allowed = new Set(["maxCharacters", "query", "numSentences", "highlightsPerUrl"]);
for (const key of Object.keys(obj)) {
if (!allowed.has(key)) {
return invalidContentsPayload(
`contents.highlights has unknown field "${key}". Allowed fields are "maxCharacters", "query", "numSentences", and "highlightsPerUrl".`,
);
}
}
if ("maxCharacters" in obj && parsePositiveInteger(obj.maxCharacters) === undefined) {
return invalidContentsPayload(
"contents.highlights.maxCharacters must be a positive integer.",
);
}
if ("numSentences" in obj && parsePositiveInteger(obj.numSentences) === undefined) {
return invalidContentsPayload("contents.highlights.numSentences must be a positive integer.");
}
if ("highlightsPerUrl" in obj && parsePositiveInteger(obj.highlightsPerUrl) === undefined) {
return invalidContentsPayload(
"contents.highlights.highlightsPerUrl must be a positive integer.",
);
}
if ("query" in obj && typeof obj.query !== "string") {
return invalidContentsPayload("contents.highlights.query must be a string.");
}
return {
...(parsePositiveInteger(obj.maxCharacters)
? { maxCharacters: parsePositiveInteger(obj.maxCharacters) }
: {}),
...(typeof obj.query === "string" ? { query: obj.query } : {}),
...(parsePositiveInteger(obj.numSentences)
? { numSentences: parsePositiveInteger(obj.numSentences) }
: {}),
...(parsePositiveInteger(obj.highlightsPerUrl)
? { highlightsPerUrl: parsePositiveInteger(obj.highlightsPerUrl) }
: {}),
};
};
const parseSummary = (
value: unknown,
): ExaSummaryContentsOption | { error: string; message: string; docs: string } => {
if (typeof value === "boolean") {
return value;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return invalidContentsPayload("contents.summary must be a boolean or an object.");
}
const obj = value as Record<string, unknown>;
for (const key of Object.keys(obj)) {
if (key !== "query") {
return invalidContentsPayload(
`contents.summary has unknown field "${key}". Only "query" is allowed.`,
);
}
}
if ("query" in obj && typeof obj.query !== "string") {
return invalidContentsPayload("contents.summary.query must be a string.");
}
return typeof obj.query === "string" ? { query: obj.query } : {};
};
if ("text" in raw) {
const parsedText = parseText(raw.text);
if (isErrorPayload(parsedText)) {
return parsedText;
}
parsed.text = parsedText;
}
if ("highlights" in raw) {
const parsedHighlights = parseHighlights(raw.highlights);
if (isErrorPayload(parsedHighlights)) {
return parsedHighlights;
}
parsed.highlights = parsedHighlights;
}
if ("summary" in raw) {
const parsedSummary = parseSummary(raw.summary);
if (isErrorPayload(parsedSummary)) {
return parsedSummary;
}
parsed.summary = parsedSummary;
}
return { value: parsed };
}
function normalizeExaResults(payload: unknown): ExaSearchResult[] {
if (!payload || typeof payload !== "object") {
return [];
}
const results = (payload as ExaSearchResponse).results;
if (!Array.isArray(results)) {
return [];
}
return results.filter((entry): entry is ExaSearchResult =>
Boolean(entry && typeof entry === "object" && !Array.isArray(entry)),
);
}
function resolveFreshnessStartDate(freshness: ExaFreshness): string {
const now = new Date();
if (freshness === "day") {
now.setUTCDate(now.getUTCDate() - 1);
return now.toISOString();
}
if (freshness === "week") {
now.setUTCDate(now.getUTCDate() - 7);
return now.toISOString();
}
if (freshness === "month") {
const currentDay = now.getUTCDate();
now.setUTCDate(1);
now.setUTCMonth(now.getUTCMonth() - 1);
const lastDayOfTargetMonth = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0),
).getUTCDate();
now.setUTCDate(Math.min(currentDay, lastDayOfTargetMonth));
return now.toISOString();
}
now.setUTCFullYear(now.getUTCFullYear() - 1);
return now.toISOString();
}
async function runExaSearch(params: {
apiKey: string;
query: string;
count: number;
freshness?: ExaFreshness;
dateAfter?: string;
dateBefore?: string;
type: ExaSearchType;
contents?: ExaContentsArgs;
timeoutSeconds: number;
}): Promise<ExaSearchResult[]> {
const body: Record<string, unknown> = {
query: params.query,
numResults: params.count,
type: params.type,
contents: params.contents ?? { highlights: true },
};
if (params.dateAfter) {
body.startPublishedDate = params.dateAfter;
} else if (params.freshness) {
body.startPublishedDate = resolveFreshnessStartDate(params.freshness);
}
if (params.dateBefore) {
body.endPublishedDate = params.dateBefore;
}
return withTrustedWebSearchEndpoint(
{
url: EXA_SEARCH_ENDPOINT,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"x-api-key": params.apiKey,
"x-exa-integration": "openclaw",
},
body: JSON.stringify(body),
},
},
async (res) => {
if (!res.ok) {
const detail = await res.text();
throw new Error(`Exa API error (${res.status}): ${detail || res.statusText}`);
}
try {
return normalizeExaResults(await res.json());
} catch (error) {
throw new Error(`Exa API returned invalid JSON: ${String(error)}`, { cause: error });
}
},
);
}
function missingExaKeyPayload() {
return {
error: "missing_exa_api_key",
message:
"web_search (exa) needs an Exa API key. Set EXA_API_KEY in the Gateway environment, or configure tools.web.search.exa.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
export async function executeExaWebSearchProviderTool(
ctx: { config?: Record<string, unknown>; searchConfig?: SearchConfigRecord },
args: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const searchConfig = mergeScopedSearchConfig(
ctx.searchConfig,
"exa",
resolveProviderWebSearchPluginConfig(ctx.config, "exa"),
) as SearchConfigRecord | undefined;
const params = args;
const exaConfig = resolveExaConfig(searchConfig);
const apiKey = resolveExaApiKey(exaConfig);
if (!apiKey) {
return missingExaKeyPayload();
}
const query = readStringParam(params, "query", { required: true });
const rawType = readStringParam(params, "type");
const type: ExaSearchType = EXA_SEARCH_TYPES.includes(rawType as ExaSearchType)
? (rawType as ExaSearchType)
: "auto";
const count =
readNumberParam(params, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined;
const rawFreshness = readStringParam(params, "freshness");
const freshness = normalizeExaFreshness(rawFreshness);
if (rawFreshness && !freshness) {
return {
error: "invalid_freshness",
message: 'freshness must be one of "day", "week", "month", or "year".',
docs: "https://docs.openclaw.ai/tools/web",
};
}
const rawDateAfter = readStringParam(params, "date_after");
const rawDateBefore = readStringParam(params, "date_before");
if (freshness && (rawDateAfter || rawDateBefore)) {
return {
error: "conflicting_time_filters",
message:
"freshness cannot be combined with date_after or date_before. Use one time-filter mode.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const parsedDateRange = parseIsoDateRange({
rawDateAfter,
rawDateBefore,
invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.",
invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.",
invalidDateRangeMessage: "date_after must be earlier than or equal to date_before.",
});
if ("error" in parsedDateRange) {
return parsedDateRange;
}
const { dateAfter, dateBefore } = parsedDateRange;
const parsedContents = parseExaContents(params.contents);
if (isErrorPayload(parsedContents)) {
return parsedContents;
}
const contents =
parsedContents.value && Object.keys(parsedContents.value).length > 0
? parsedContents.value
: undefined;
const cacheKey = buildSearchCacheKey([
"exa",
type,
query,
resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT),
freshness,
dateAfter,
dateBefore,
contents?.highlights ? JSON.stringify(contents.highlights) : undefined,
contents?.text ? JSON.stringify(contents.text) : undefined,
contents?.summary ? JSON.stringify(contents.summary) : undefined,
]);
const cached = readCachedSearchPayload(cacheKey);
if (cached) {
return cached;
}
const start = Date.now();
const results = await runExaSearch({
apiKey,
query,
count: resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT),
freshness,
dateAfter,
dateBefore,
type,
contents,
timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig),
});
const payload = {
query,
provider: "exa",
count: results.length,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "exa",
wrapped: true,
},
results: results.map((entry) => {
const title = typeof entry.title === "string" ? entry.title : "";
const url = typeof entry.url === "string" ? entry.url : "";
const description = resolveExaDescription(entry);
const summary = normalizeOptionalString(entry.summary) ?? "";
const highlightScores = Array.isArray(entry.highlightScores)
? entry.highlightScores.filter(
(score): score is number => typeof score === "number" && Number.isFinite(score),
)
: [];
const published =
typeof entry.publishedDate === "string" && entry.publishedDate
? entry.publishedDate
: undefined;
return {
title: title ? wrapWebContent(title, "web_search") : "",
url,
description: description ? wrapWebContent(description, "web_search") : "",
published,
siteName: resolveSiteName(url) || undefined,
...(summary ? { summary: wrapWebContent(summary, "web_search") } : {}),
...(highlightScores.length > 0 ? { highlightScores } : {}),
};
}),
};
writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
return payload;
}
export const __testing = {
normalizeExaResults,
normalizeExaFreshness,
parseExaContents,
resolveExaApiKey,
resolveExaConfig,
resolveExaDescription,
resolveExaSearchCount,
resolveFreshnessStartDate,
} as const;

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { __testing } from "../test-api.js";
import { createExaWebSearchProvider as createContractExaWebSearchProvider } from "../web-search-contract-api.js";
import { __testing, createExaWebSearchProvider } from "./exa-web-search-provider.js";
import { createExaWebSearchProvider } from "./exa-web-search-provider.js";
describe("exa web search provider", () => {
it("exposes the expected metadata and selection wiring", () => {

View File

@@ -1,594 +1,61 @@
import { Type } from "@sinclair/typebox";
import {
buildSearchCacheKey,
DEFAULT_SEARCH_COUNT,
enablePluginInConfig,
getScopedCredentialValue,
mergeScopedSearchConfig,
parseIsoDateRange,
readCachedSearchPayload,
readConfiguredSecretString,
readNumberParam,
readProviderEnvValue,
readStringParam,
resolveProviderWebSearchPluginConfig,
resolveSearchCacheTtlMs,
resolveSearchTimeoutSeconds,
resolveSiteName,
setProviderWebSearchPluginConfigValue,
setScopedCredentialValue,
type SearchConfigRecord,
createWebSearchProviderContractFields,
type WebSearchProviderPlugin,
type WebSearchProviderToolDefinition,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
} from "openclaw/plugin-sdk/provider-web-search-contract";
const EXA_SEARCH_ENDPOINT = "https://api.exa.ai/search";
const EXA_CREDENTIAL_PATH = "plugins.entries.exa.config.webSearch.apiKey";
const EXA_SEARCH_TYPES = ["auto", "neural", "fast", "deep", "deep-reasoning", "instant"] as const;
const EXA_FRESHNESS_VALUES = ["day", "week", "month", "year"] as const;
const EXA_MAX_SEARCH_COUNT = 100;
type ExaConfig = {
apiKey?: string;
};
type ExaSearchType = (typeof EXA_SEARCH_TYPES)[number];
type ExaFreshness = (typeof EXA_FRESHNESS_VALUES)[number];
type ExaTextContentsOption = boolean | { maxCharacters?: number };
type ExaHighlightsContentsOption =
| boolean
| {
maxCharacters?: number;
query?: string;
numSentences?: number;
highlightsPerUrl?: number;
};
type ExaSummaryContentsOption = boolean | { query?: string };
type ExaContentsArgs = {
highlights?: ExaHighlightsContentsOption;
text?: ExaTextContentsOption;
summary?: ExaSummaryContentsOption;
};
type ExaSearchResult = {
title?: unknown;
url?: unknown;
publishedDate?: unknown;
highlights?: unknown;
highlightScores?: unknown;
summary?: unknown;
text?: unknown;
};
type ExaSearchResponse = {
results?: unknown;
};
function normalizeExaFreshness(value: string | undefined): ExaFreshness | undefined {
const trimmed = normalizeOptionalLowercaseString(value);
if (!trimmed) {
return undefined;
}
return EXA_FRESHNESS_VALUES.includes(trimmed as ExaFreshness)
? (trimmed as ExaFreshness)
: undefined;
}
function optionalStringEnum<T extends readonly string[]>(values: T, description: string) {
return Type.Optional(
Type.Unsafe<T[number]>({
const ExaSearchSchema = {
type: "object",
properties: {
query: { type: "string", description: "Search query string." },
count: {
type: "number",
description: "Number of results to return (1-100, subject to Exa search-type limits).",
minimum: 1,
maximum: EXA_MAX_SEARCH_COUNT,
},
freshness: {
type: "string",
enum: [...values],
description,
}),
);
}
function resolveExaConfig(searchConfig?: SearchConfigRecord): ExaConfig {
const exa = searchConfig?.exa;
return exa && typeof exa === "object" && !Array.isArray(exa) ? (exa as ExaConfig) : {};
}
function resolveExaApiKey(exa?: ExaConfig): string | undefined {
return (
readConfiguredSecretString(exa?.apiKey, "tools.web.search.exa.apiKey") ??
readProviderEnvValue(["EXA_API_KEY"])
);
}
function resolveExaDescription(result: ExaSearchResult): string {
const highlights = result.highlights;
if (Array.isArray(highlights)) {
const highlightText = highlights
.map((entry) => normalizeOptionalString(entry))
.filter((entry): entry is string => Boolean(entry))
.join("\n");
if (highlightText) {
return highlightText;
}
}
const summary = normalizeOptionalString(result.summary);
if (summary) {
return summary;
}
return normalizeOptionalString(result.text) ?? "";
}
function parsePositiveInteger(value: unknown): number | undefined {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
}
function invalidContentsPayload(message: string) {
return {
error: "invalid_contents",
message,
docs: "https://docs.openclaw.ai/tools/web",
};
}
function isErrorPayload(value: unknown): value is { error: string; message: string; docs: string } {
return Boolean(
value && typeof value === "object" && "error" in value && "message" in value && "docs" in value,
);
}
function resolveExaSearchCount(value: unknown, fallback: number): number {
const parsed = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(parsed)) {
return fallback;
}
return Math.max(1, Math.min(EXA_MAX_SEARCH_COUNT, Math.floor(parsed)));
}
function parseExaContents(
rawContents: unknown,
): { value?: ExaContentsArgs } | { error: string; message: string; docs: string } {
if (rawContents === undefined) {
return { value: undefined };
}
if (!rawContents || typeof rawContents !== "object" || Array.isArray(rawContents)) {
return invalidContentsPayload(
"contents must be an object with optional text, highlights, and summary fields.",
);
}
const raw = rawContents as Record<string, unknown>;
const allowedKeys = new Set(["text", "highlights", "summary"]);
for (const key of Object.keys(raw)) {
if (!allowedKeys.has(key)) {
return invalidContentsPayload(
`contents has unknown field "${key}". Only "text", "highlights", and "summary" are allowed.`,
);
}
}
const parsed: ExaContentsArgs = {};
const parseText = (
value: unknown,
): ExaTextContentsOption | { error: string; message: string; docs: string } => {
if (typeof value === "boolean") {
return value;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return invalidContentsPayload("contents.text must be a boolean or an object.");
}
const obj = value as Record<string, unknown>;
for (const key of Object.keys(obj)) {
if (key !== "maxCharacters") {
return invalidContentsPayload(
`contents.text has unknown field "${key}". Only "maxCharacters" is allowed.`,
);
}
}
if ("maxCharacters" in obj && parsePositiveInteger(obj.maxCharacters) === undefined) {
return invalidContentsPayload("contents.text.maxCharacters must be a positive integer.");
}
return parsePositiveInteger(obj.maxCharacters)
? { maxCharacters: parsePositiveInteger(obj.maxCharacters) }
: {};
};
const parseHighlights = (
value: unknown,
): ExaHighlightsContentsOption | { error: string; message: string; docs: string } => {
if (typeof value === "boolean") {
return value;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return invalidContentsPayload("contents.highlights must be a boolean or an object.");
}
const obj = value as Record<string, unknown>;
const allowed = new Set(["maxCharacters", "query", "numSentences", "highlightsPerUrl"]);
for (const key of Object.keys(obj)) {
if (!allowed.has(key)) {
return invalidContentsPayload(
`contents.highlights has unknown field "${key}". Allowed fields are "maxCharacters", "query", "numSentences", and "highlightsPerUrl".`,
);
}
}
if ("maxCharacters" in obj && parsePositiveInteger(obj.maxCharacters) === undefined) {
return invalidContentsPayload(
"contents.highlights.maxCharacters must be a positive integer.",
);
}
if ("numSentences" in obj && parsePositiveInteger(obj.numSentences) === undefined) {
return invalidContentsPayload("contents.highlights.numSentences must be a positive integer.");
}
if ("highlightsPerUrl" in obj && parsePositiveInteger(obj.highlightsPerUrl) === undefined) {
return invalidContentsPayload(
"contents.highlights.highlightsPerUrl must be a positive integer.",
);
}
if ("query" in obj && typeof obj.query !== "string") {
return invalidContentsPayload("contents.highlights.query must be a string.");
}
return {
...(parsePositiveInteger(obj.maxCharacters)
? { maxCharacters: parsePositiveInteger(obj.maxCharacters) }
: {}),
...(typeof obj.query === "string" ? { query: obj.query } : {}),
...(parsePositiveInteger(obj.numSentences)
? { numSentences: parsePositiveInteger(obj.numSentences) }
: {}),
...(parsePositiveInteger(obj.highlightsPerUrl)
? { highlightsPerUrl: parsePositiveInteger(obj.highlightsPerUrl) }
: {}),
};
};
const parseSummary = (
value: unknown,
): ExaSummaryContentsOption | { error: string; message: string; docs: string } => {
if (typeof value === "boolean") {
return value;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return invalidContentsPayload("contents.summary must be a boolean or an object.");
}
const obj = value as Record<string, unknown>;
for (const key of Object.keys(obj)) {
if (key !== "query") {
return invalidContentsPayload(
`contents.summary has unknown field "${key}". Only "query" is allowed.`,
);
}
}
if ("query" in obj && typeof obj.query !== "string") {
return invalidContentsPayload("contents.summary.query must be a string.");
}
return typeof obj.query === "string" ? { query: obj.query } : {};
};
if ("text" in raw) {
const parsedText = parseText(raw.text);
if (isErrorPayload(parsedText)) {
return parsedText;
}
parsed.text = parsedText;
}
if ("highlights" in raw) {
const parsedHighlights = parseHighlights(raw.highlights);
if (isErrorPayload(parsedHighlights)) {
return parsedHighlights;
}
parsed.highlights = parsedHighlights;
}
if ("summary" in raw) {
const parsedSummary = parseSummary(raw.summary);
if (isErrorPayload(parsedSummary)) {
return parsedSummary;
}
parsed.summary = parsedSummary;
}
return { value: parsed };
}
function normalizeExaResults(payload: unknown): ExaSearchResult[] {
if (!payload || typeof payload !== "object") {
return [];
}
const results = (payload as ExaSearchResponse).results;
if (!Array.isArray(results)) {
return [];
}
return results.filter((entry): entry is ExaSearchResult =>
Boolean(entry && typeof entry === "object" && !Array.isArray(entry)),
);
}
function resolveFreshnessStartDate(freshness: ExaFreshness): string {
const now = new Date();
if (freshness === "day") {
now.setUTCDate(now.getUTCDate() - 1);
return now.toISOString();
}
if (freshness === "week") {
now.setUTCDate(now.getUTCDate() - 7);
return now.toISOString();
}
if (freshness === "month") {
const currentDay = now.getUTCDate();
now.setUTCDate(1);
now.setUTCMonth(now.getUTCMonth() - 1);
const lastDayOfTargetMonth = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0),
).getUTCDate();
now.setUTCDate(Math.min(currentDay, lastDayOfTargetMonth));
return now.toISOString();
}
now.setUTCFullYear(now.getUTCFullYear() - 1);
return now.toISOString();
}
async function runExaSearch(params: {
apiKey: string;
query: string;
count: number;
freshness?: ExaFreshness;
dateAfter?: string;
dateBefore?: string;
type: ExaSearchType;
contents?: ExaContentsArgs;
timeoutSeconds: number;
}): Promise<ExaSearchResult[]> {
const body: Record<string, unknown> = {
query: params.query,
numResults: params.count,
type: params.type,
contents: params.contents ?? { highlights: true },
};
if (params.dateAfter) {
body.startPublishedDate = params.dateAfter;
} else if (params.freshness) {
body.startPublishedDate = resolveFreshnessStartDate(params.freshness);
}
if (params.dateBefore) {
body.endPublishedDate = params.dateBefore;
}
return withTrustedWebSearchEndpoint(
{
url: EXA_SEARCH_ENDPOINT,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"x-api-key": params.apiKey,
"x-exa-integration": "openclaw",
},
body: JSON.stringify(body),
},
enum: [...EXA_FRESHNESS_VALUES],
description: 'Filter by time: "day", "week", "month", or "year".',
},
async (res) => {
if (!res.ok) {
const detail = await res.text();
throw new Error(`Exa API error (${res.status}): ${detail || res.statusText}`);
}
try {
return normalizeExaResults(await res.json());
} catch (error) {
throw new Error(`Exa API returned invalid JSON: ${String(error)}`, { cause: error });
}
date_after: {
type: "string",
description: "Only results published after this date (YYYY-MM-DD).",
},
);
}
function createExaSchema() {
return Type.Object(
{
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Number({
description: "Number of results to return (1-100, subject to Exa search-type limits).",
minimum: 1,
maximum: EXA_MAX_SEARCH_COUNT,
}),
),
freshness: optionalStringEnum(
EXA_FRESHNESS_VALUES,
'Filter by time: "day", "week", "month", or "year".',
),
date_after: Type.Optional(
Type.String({
description: "Only results published after this date (YYYY-MM-DD).",
}),
),
date_before: Type.Optional(
Type.String({
description: "Only results published before this date (YYYY-MM-DD).",
}),
),
type: optionalStringEnum(
EXA_SEARCH_TYPES,
date_before: {
type: "string",
description: "Only results published before this date (YYYY-MM-DD).",
},
type: {
type: "string",
enum: [...EXA_SEARCH_TYPES],
description:
'Exa search mode: "auto", "neural", "fast", "deep", "deep-reasoning", or "instant".',
),
contents: Type.Optional(
Type.Object(
{
highlights: Type.Optional(
Type.Unsafe<ExaHighlightsContentsOption>({
description:
"Highlights config: true, or an object with maxCharacters, query, numSentences, or highlightsPerUrl.",
}),
),
text: Type.Optional(
Type.Unsafe<ExaTextContentsOption>({
description: "Text config: true, or an object with maxCharacters.",
}),
),
summary: Type.Optional(
Type.Unsafe<ExaSummaryContentsOption>({
description: "Summary config: true, or an object with query.",
}),
),
},
{ additionalProperties: false },
),
),
},
{ additionalProperties: false },
);
}
function missingExaKeyPayload() {
return {
error: "missing_exa_api_key",
message:
"web_search (exa) needs an Exa API key. Set EXA_API_KEY in the Gateway environment, or configure tools.web.search.exa.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
function createExaToolDefinition(
searchConfig?: SearchConfigRecord,
): WebSearchProviderToolDefinition {
return {
description:
"Search the web using Exa AI. Supports neural or keyword search, publication date filters, and optional highlights or text extraction.",
parameters: createExaSchema(),
execute: async (args) => {
const params = args;
const exaConfig = resolveExaConfig(searchConfig);
const apiKey = resolveExaApiKey(exaConfig);
if (!apiKey) {
return missingExaKeyPayload();
}
const query = readStringParam(params, "query", { required: true });
const rawType = readStringParam(params, "type");
const type: ExaSearchType = EXA_SEARCH_TYPES.includes(rawType as ExaSearchType)
? (rawType as ExaSearchType)
: "auto";
const count =
readNumberParam(params, "count", { integer: true }) ??
searchConfig?.maxResults ??
undefined;
const rawFreshness = readStringParam(params, "freshness");
const freshness = normalizeExaFreshness(rawFreshness);
if (rawFreshness && !freshness) {
return {
error: "invalid_freshness",
message: 'freshness must be one of "day", "week", "month", or "year".',
docs: "https://docs.openclaw.ai/tools/web",
};
}
const rawDateAfter = readStringParam(params, "date_after");
const rawDateBefore = readStringParam(params, "date_before");
if (freshness && (rawDateAfter || rawDateBefore)) {
return {
error: "conflicting_time_filters",
message:
"freshness cannot be combined with date_after or date_before. Use one time-filter mode.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const parsedDateRange = parseIsoDateRange({
rawDateAfter,
rawDateBefore,
invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.",
invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.",
invalidDateRangeMessage: "date_after must be earlier than or equal to date_before.",
});
if ("error" in parsedDateRange) {
return parsedDateRange;
}
const { dateAfter, dateBefore } = parsedDateRange;
const parsedContents = parseExaContents(params.contents);
if (isErrorPayload(parsedContents)) {
return parsedContents;
}
const contents =
parsedContents.value && Object.keys(parsedContents.value).length > 0
? parsedContents.value
: undefined;
const cacheKey = buildSearchCacheKey([
"exa",
type,
query,
resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT),
freshness,
dateAfter,
dateBefore,
contents?.highlights ? JSON.stringify(contents.highlights) : undefined,
contents?.text ? JSON.stringify(contents.text) : undefined,
contents?.summary ? JSON.stringify(contents.summary) : undefined,
]);
const cached = readCachedSearchPayload(cacheKey);
if (cached) {
return cached;
}
const start = Date.now();
const results = await runExaSearch({
apiKey,
query,
count: resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT),
freshness,
dateAfter,
dateBefore,
type,
contents,
timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig),
});
const payload = {
query,
provider: "exa",
count: results.length,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "exa",
wrapped: true,
contents: {
type: "object",
properties: {
highlights: {
description:
"Highlights config: true, or an object with maxCharacters, query, numSentences, or highlightsPerUrl.",
},
results: results.map((entry) => {
const title = typeof entry.title === "string" ? entry.title : "";
const url = typeof entry.url === "string" ? entry.url : "";
const description = resolveExaDescription(entry);
const summary = normalizeOptionalString(entry.summary) ?? "";
const highlightScores = Array.isArray(entry.highlightScores)
? entry.highlightScores.filter(
(score): score is number => typeof score === "number" && Number.isFinite(score),
)
: [];
const published =
typeof entry.publishedDate === "string" && entry.publishedDate
? entry.publishedDate
: undefined;
return {
title: title ? wrapWebContent(title, "web_search") : "",
url,
description: description ? wrapWebContent(description, "web_search") : "",
published,
siteName: resolveSiteName(url) || undefined,
...(summary ? { summary: wrapWebContent(summary, "web_search") } : {}),
...(highlightScores.length > 0 ? { highlightScores } : {}),
};
}),
};
writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
return payload;
text: {
description: "Text config: true, or an object with maxCharacters.",
},
summary: {
description: "Summary config: true, or an object with query.",
},
},
additionalProperties: false,
},
};
}
},
additionalProperties: false,
} satisfies Record<string, unknown>;
export function createExaWebSearchProvider(): WebSearchProviderPlugin {
return {
@@ -602,35 +69,22 @@ export function createExaWebSearchProvider(): WebSearchProviderPlugin {
signupUrl: "https://exa.ai/",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 65,
credentialPath: "plugins.entries.exa.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.exa.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "exa"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "exa", value),
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "exa")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "exa", "apiKey", value);
},
applySelectionConfig: (config) => enablePluginInConfig(config, "exa").config,
createTool: (ctx) =>
createExaToolDefinition(
mergeScopedSearchConfig(
ctx.searchConfig,
"exa",
resolveProviderWebSearchPluginConfig(ctx.config, "exa"),
),
),
credentialPath: EXA_CREDENTIAL_PATH,
...createWebSearchProviderContractFields({
credentialPath: EXA_CREDENTIAL_PATH,
searchCredential: { type: "scoped", scopeId: "exa" },
configuredCredential: { pluginId: "exa" },
selectionPluginId: "exa",
}),
createTool: (ctx) => ({
description:
"Search the web using Exa AI. Supports neural or keyword search, publication date filters, and optional highlights or text extraction.",
parameters: ExaSearchSchema,
execute: async (args) => {
const { executeExaWebSearchProviderTool } =
await import("./exa-web-search-provider.runtime.js");
return await executeExaWebSearchProviderTool(ctx, args);
},
}),
};
}
export const __testing = {
normalizeExaResults,
normalizeExaFreshness,
parseExaContents,
resolveExaApiKey,
resolveExaConfig,
resolveExaDescription,
resolveExaSearchCount,
resolveFreshnessStartDate,
} as const;

View File

@@ -0,0 +1 @@
export { __testing } from "./src/exa-web-search-provider.runtime.js";

View File

@@ -1 +1 @@
export { __testing, createExaWebSearchProvider } from "./src/exa-web-search-provider.js";
export { createExaWebSearchProvider } from "./src/exa-web-search-provider.js";

View File

@@ -1,7 +1,6 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import { buildFalImageGenerationProvider } from "./image-generation-provider.js";
import { applyFalConfig, FAL_DEFAULT_IMAGE_MODEL_REF } from "./onboard.js";
import { createFalProvider } from "./provider-registration.js";
import { buildFalVideoGenerationProvider } from "./video-generation-provider.js";
const PROVIDER_ID = "fal";
@@ -11,36 +10,7 @@ export default definePluginEntry({
name: "fal Provider",
description: "Bundled fal image and video generation provider",
register(api) {
api.registerProvider({
id: PROVIDER_ID,
label: "fal",
docsPath: "/providers/models",
envVars: ["FAL_KEY"],
auth: [
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: "fal API key",
hint: "Image and video generation API key",
optionKey: "falApiKey",
flagName: "--fal-api-key",
envVar: "FAL_KEY",
promptMessage: "Enter fal API key",
defaultModel: FAL_DEFAULT_IMAGE_MODEL_REF,
expectedProviders: ["fal"],
applyConfig: (cfg) => applyFalConfig(cfg),
wizard: {
choiceId: "fal-api-key",
choiceLabel: "fal API key",
choiceHint: "Image and video generation API key",
groupId: "fal",
groupLabel: "fal",
groupHint: "Image and video generation",
onboardingScopes: ["image-generation"],
},
}),
],
});
api.registerProvider(createFalProvider());
api.registerImageGenerationProvider(buildFalImageGenerationProvider());
api.registerVideoGenerationProvider(buildFalVideoGenerationProvider());
},

View File

@@ -0,0 +1,31 @@
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
const PROVIDER_ID = "fal";
const FAL_DEFAULT_IMAGE_MODEL_REF = "fal/fal-ai/flux/dev";
export function createFalProvider(): ProviderPlugin {
return {
id: PROVIDER_ID,
label: "fal",
docsPath: "/providers/models",
envVars: ["FAL_KEY"],
auth: [
{
id: "api-key",
kind: "api_key",
label: "fal API key",
hint: "Image and video generation API key",
run: async () => ({ profiles: [], defaultModel: FAL_DEFAULT_IMAGE_MODEL_REF }),
wizard: {
choiceId: "fal-api-key",
choiceLabel: "fal API key",
choiceHint: "Image and video generation API key",
groupId: "fal",
groupLabel: "fal",
groupHint: "Image and video generation",
onboardingScopes: ["image-generation"],
},
},
],
};
}

View File

@@ -0,0 +1,38 @@
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
import { applyFalConfig, FAL_DEFAULT_IMAGE_MODEL_REF } from "./onboard.js";
const PROVIDER_ID = "fal";
export function createFalProvider(): ProviderPlugin {
return {
id: PROVIDER_ID,
label: "fal",
docsPath: "/providers/models",
envVars: ["FAL_KEY"],
auth: [
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: "fal API key",
hint: "Image and video generation API key",
optionKey: "falApiKey",
flagName: "--fal-api-key",
envVar: "FAL_KEY",
promptMessage: "Enter fal API key",
defaultModel: FAL_DEFAULT_IMAGE_MODEL_REF,
expectedProviders: ["fal"],
applyConfig: (cfg) => applyFalConfig(cfg),
wizard: {
choiceId: "fal-api-key",
choiceLabel: "fal API key",
choiceHint: "Image and video generation API key",
groupId: "fal",
groupLabel: "fal",
groupHint: "Image and video generation",
onboardingScopes: ["image-generation"],
},
}),
],
};
}

View File

@@ -25,9 +25,14 @@ vi.mock("./bot.js", () => ({
handleFeishuMessage: vi.fn(),
}));
const createFeishuClientMock = vi.hoisted(() => vi.fn());
const sendCardFeishuMock = vi.hoisted(() => vi.fn());
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
vi.mock("./client.js", () => ({
createFeishuClient: createFeishuClientMock,
}));
vi.mock("./send.js", () => ({
sendCardFeishu: sendCardFeishuMock,
sendMessageFeishu: sendMessageFeishuMock,
@@ -89,6 +94,13 @@ describe("Feishu Card Action Handler", () => {
beforeEach(() => {
vi.clearAllMocks();
createFeishuClientMock.mockReset().mockReturnValue({
im: {
chat: {
get: vi.fn().mockResolvedValue({ code: 0, data: { chat_type: "group" } }),
},
},
});
vi.mocked(handleFeishuMessage)
.mockReset()
.mockResolvedValue(undefined as never);
@@ -354,6 +366,142 @@ describe("Feishu Card Action Handler", () => {
);
});
it("resolves DM chat type from the Feishu chat API when card context omits it", async () => {
createFeishuClientMock.mockReturnValueOnce({
im: {
chat: {
get: vi.fn().mockResolvedValue({ code: 0, data: { chat_type: "p2p" } }),
},
},
});
const event = createCardActionEvent({
token: "tok9b",
chatId: "oc_dm_chat_123",
actionValue: { text: "/help" },
});
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
chat_id: "oc_dm_chat_123",
chat_type: "p2p",
}),
}),
}),
);
expect(createFeishuClientMock).toHaveBeenCalledTimes(1);
});
it("uses resolved DM chat type when building approval cards without stored context", async () => {
createFeishuClientMock.mockReturnValueOnce({
im: {
chat: {
get: vi.fn().mockResolvedValue({ code: 0, data: { chat_mode: "p2p" } }),
},
},
});
const event = createCardActionEvent({
token: "tok9c",
chatId: "oc_dm_chat_234",
actionValue: createFeishuCardInteractionEnvelope({
k: "meta",
a: FEISHU_APPROVAL_REQUEST_ACTION,
m: {
command: "/new",
prompt: "Start a fresh session?",
},
c: {
u: "u123",
h: "oc_dm_chat_234",
e: Date.now() + 60_000,
},
}),
});
await handleFeishuCardAction({ cfg, event, runtime, accountId: "main" });
expect(sendCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
card: expect.objectContaining({
body: expect.objectContaining({
elements: expect.arrayContaining([
expect.objectContaining({
tag: "action",
actions: expect.arrayContaining([
expect.objectContaining({
value: expect.objectContaining({
c: expect.objectContaining({
t: "p2p",
}),
}),
}),
]),
}),
]),
}),
}),
}),
);
expect(createFeishuClientMock).toHaveBeenCalledTimes(1);
});
it("falls back to p2p when Feishu chat API returns an error", async () => {
createFeishuClientMock.mockReturnValueOnce({
im: {
chat: {
get: vi.fn().mockResolvedValue({ code: 99, msg: "not found" }),
},
},
});
const event = createCardActionEvent({
token: "tok9d",
chatId: "oc_unknown_chat_456",
actionValue: { text: "/help" },
});
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
chat_type: "p2p",
}),
}),
}),
);
});
it("falls back to p2p when Feishu chat API throws", async () => {
createFeishuClientMock.mockReturnValueOnce({
im: {
chat: {
get: vi.fn().mockRejectedValue(new Error("network failure")),
},
},
});
const event = createCardActionEvent({
token: "tok9e",
chatId: "oc_broken_chat_789",
actionValue: { text: "/help" },
});
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
chat_type: "p2p",
}),
}),
}),
);
});
it("drops duplicate structured callback tokens", async () => {
const event = createStructuredQuickActionEvent({
token: "tok10",

View File

@@ -8,6 +8,7 @@ import {
FEISHU_APPROVAL_CONFIRM_ACTION,
FEISHU_APPROVAL_REQUEST_ACTION,
} from "./card-ux-approval.js";
import { createFeishuClient } from "./client.js";
import { sendCardFeishu, sendMessageFeishu } from "./send.js";
export type FeishuCardActionEvent = {
@@ -104,7 +105,7 @@ function releaseFeishuCardActionToken(params: { token: string; accountId: string
function buildSyntheticMessageEvent(
event: FeishuCardActionEvent,
content: string,
chatType?: "p2p" | "group",
chatType: "p2p" | "group",
): FeishuMessageEvent {
return {
sender: {
@@ -117,7 +118,7 @@ function buildSyntheticMessageEvent(
message: {
message_id: `card-action-${event.token}`,
chat_id: event.context.chat_id || event.operator.open_id,
chat_type: chatType ?? (event.context.chat_id ? "group" : "p2p"),
chat_type: chatType,
message_type: "text",
content: JSON.stringify({ text: content }),
},
@@ -136,20 +137,127 @@ async function dispatchSyntheticCommand(params: {
cfg: ClawdbotConfig;
event: FeishuCardActionEvent;
command: string;
account: ReturnType<typeof resolveFeishuRuntimeAccount>;
botOpenId?: string;
runtime?: RuntimeEnv;
accountId?: string;
chatType?: "p2p" | "group";
}): Promise<void> {
const resolvedChatType = await resolveCardActionChatType({
event: params.event,
account: params.account,
chatType: params.chatType,
log: params.runtime?.log ?? console.log,
});
await handleFeishuMessage({
cfg: params.cfg,
event: buildSyntheticMessageEvent(params.event, params.command, params.chatType),
event: buildSyntheticMessageEvent(params.event, params.command, resolvedChatType),
botOpenId: params.botOpenId,
runtime: params.runtime,
accountId: params.accountId,
});
}
// Feishu's im.chat.get returns two fields:
// chat_mode: conversation type — "p2p" | "group" | "topic"
// chat_type: privacy classification — "private" | "public"
// We check chat_mode first because it directly indicates conversation type.
// "private" maps to "p2p" as the safe-failure direction (restrictive DM
// policy) — a private group chat misclassified as p2p is safer than the
// reverse. "topic" and "public" are treated as group semantics.
function normalizeResolvedCardActionChatType(value: unknown): "p2p" | "group" | undefined {
if (value === "group" || value === "topic" || value === "public") {
return "group";
}
if (value === "p2p" || value === "private") {
return "p2p";
}
return undefined;
}
const resolvedChatTypeCache = new Map<string, { value: "p2p" | "group"; expiresAt: number }>();
const CHAT_TYPE_CACHE_TTL_MS = 30 * 60_000;
const CHAT_TYPE_CACHE_MAX_SIZE = 5_000;
function pruneChatTypeCache(now: number): void {
for (const [key, entry] of resolvedChatTypeCache.entries()) {
if (entry.expiresAt <= now) {
resolvedChatTypeCache.delete(key);
}
}
if (resolvedChatTypeCache.size > CHAT_TYPE_CACHE_MAX_SIZE) {
const excess = resolvedChatTypeCache.size - CHAT_TYPE_CACHE_MAX_SIZE;
const iter = resolvedChatTypeCache.keys();
for (let i = 0; i < excess; i++) {
const key = iter.next().value;
if (key !== undefined) {
resolvedChatTypeCache.delete(key);
}
}
}
}
function sanitizeLogValue(v: string): string {
return v.replace(/[\r\n]/g, " ").slice(0, 500);
}
async function resolveCardActionChatType(params: {
event: FeishuCardActionEvent;
account: ReturnType<typeof resolveFeishuRuntimeAccount>;
chatType?: "p2p" | "group";
log: (message: string) => void;
}): Promise<"p2p" | "group"> {
const explicitChatType = normalizeResolvedCardActionChatType(params.chatType);
if (explicitChatType) {
return explicitChatType;
}
const chatId = params.event.context.chat_id?.trim();
if (!chatId) {
return "p2p";
}
const cacheKey = `${params.account.accountId}:${chatId}`;
const now = Date.now();
pruneChatTypeCache(now);
const cached = resolvedChatTypeCache.get(cacheKey);
if (cached) {
return cached.value;
}
try {
const response = (await createFeishuClient(params.account).im.chat.get({
path: { chat_id: chatId },
})) as { code?: number; msg?: string; data?: { chat_type?: unknown; chat_mode?: unknown } };
if (response.code === 0) {
const resolvedChatType =
normalizeResolvedCardActionChatType(response.data?.chat_mode) ??
normalizeResolvedCardActionChatType(response.data?.chat_type);
if (resolvedChatType) {
resolvedChatTypeCache.set(cacheKey, {
value: resolvedChatType,
expiresAt: now + CHAT_TYPE_CACHE_TTL_MS,
});
return resolvedChatType;
}
params.log(
`feishu[${params.account.accountId}]: card action missing chat type for chat; defaulting to p2p`,
);
} else {
params.log(
`feishu[${params.account.accountId}]: failed to resolve chat type: ${sanitizeLogValue(response.msg ?? "unknown error")}; defaulting to p2p`,
);
}
} catch (err) {
const message = err instanceof Error ? err.message : "unknown";
params.log(
`feishu[${params.account.accountId}]: failed to resolve chat type: ${sanitizeLogValue(message)}; defaulting to p2p`,
);
}
return "p2p";
}
async function sendInvalidInteractionNotice(params: {
cfg: ClawdbotConfig;
event: FeishuCardActionEvent;
@@ -246,7 +354,12 @@ export async function handleFeishuCardAction(params: {
prompt,
sessionKey: envelope.c?.s,
expiresAt: Date.now() + FEISHU_APPROVAL_CARD_TTL_MS,
chatType: envelope.c?.t ?? (event.context.chat_id ? "group" : "p2p"),
chatType: await resolveCardActionChatType({
event,
account,
chatType: envelope.c?.t,
log,
}),
confirmLabel: command === "/reset" ? "Reset" : "Confirm",
}),
accountId,
@@ -282,10 +395,11 @@ export async function handleFeishuCardAction(params: {
cfg,
event,
command,
account,
botOpenId: params.botOpenId,
runtime,
accountId,
chatType: envelope.c?.t ?? (event.context.chat_id ? "group" : "p2p"),
chatType: envelope.c?.t,
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
@@ -311,6 +425,7 @@ export async function handleFeishuCardAction(params: {
cfg,
event,
command: content,
account,
botOpenId: params.botOpenId,
runtime,
accountId,

View File

@@ -1,27 +1,22 @@
import { Type } from "@sinclair/typebox";
import {
enablePluginInConfig,
getScopedCredentialValue,
resolveProviderWebSearchPluginConfig,
setScopedCredentialValue,
setProviderWebSearchPluginConfigValue,
createWebSearchProviderContractFields,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search";
import { runFirecrawlSearch } from "./firecrawl-client.js";
} from "openclaw/plugin-sdk/provider-web-search-contract";
const GenericFirecrawlSearchSchema = Type.Object(
{
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Number({
description: "Number of results to return (1-10).",
minimum: 1,
maximum: 10,
}),
),
const FIRECRAWL_CREDENTIAL_PATH = "plugins.entries.firecrawl.config.webSearch.apiKey";
const GenericFirecrawlSearchSchema = {
type: "object",
properties: {
query: { type: "string", description: "Search query string." },
count: {
type: "number",
description: "Number of results to return (1-10).",
minimum: 1,
maximum: 10,
},
},
{ additionalProperties: false },
);
additionalProperties: false,
} satisfies Record<string, unknown>;
export function createFirecrawlWebSearchProvider(): WebSearchProviderPlugin {
return {
@@ -35,27 +30,25 @@ export function createFirecrawlWebSearchProvider(): WebSearchProviderPlugin {
signupUrl: "https://www.firecrawl.dev/",
docsUrl: "https://docs.openclaw.ai/tools/firecrawl",
autoDetectOrder: 60,
credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.firecrawl.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "firecrawl"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "firecrawl", value),
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "firecrawl")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "firecrawl", "apiKey", value);
},
applySelectionConfig: (config) => enablePluginInConfig(config, "firecrawl").config,
credentialPath: FIRECRAWL_CREDENTIAL_PATH,
...createWebSearchProviderContractFields({
credentialPath: FIRECRAWL_CREDENTIAL_PATH,
searchCredential: { type: "scoped", scopeId: "firecrawl" },
configuredCredential: { pluginId: "firecrawl" },
selectionPluginId: "firecrawl",
}),
createTool: (ctx) => ({
description:
"Search the web using Firecrawl. Returns structured results with snippets from Firecrawl Search. Use firecrawl_search for Firecrawl-specific knobs like sources or categories.",
parameters: GenericFirecrawlSearchSchema,
execute: async (args) =>
await runFirecrawlSearch({
execute: async (args) => {
const { runFirecrawlSearch } = await import("./firecrawl-client.js");
return await runFirecrawlSearch({
cfg: ctx.config,
query: typeof args.query === "string" ? args.query : "",
count: typeof args.count === "number" ? args.count : undefined,
}),
});
},
}),
};
}

View File

@@ -2,11 +2,8 @@ import {
resolveProviderHttpRequestConfig,
type ProviderRequestTransportOverrides,
} from "openclaw/plugin-sdk/provider-http";
import {
applyAgentDefaultModelPrimary,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
import { parseGoogleOauthApiKey } from "./oauth-token-shared.js";
export { applyGoogleGeminiModelDefault, GOOGLE_GEMINI_DEFAULT_MODEL } from "./onboard.js";
import {
DEFAULT_GOOGLE_API_BASE_URL,
normalizeGoogleApiBaseUrl,
@@ -24,6 +21,8 @@ export {
shouldNormalizeGoogleGenerativeAiProviderConfig,
shouldNormalizeGoogleProviderConfig,
} from "./provider-policy.js";
export { buildGoogleGeminiCliProvider } from "./gemini-cli-provider.js";
export { buildGoogleProvider } from "./provider-registration.js";
export function parseGeminiAuth(apiKey: string): { headers: Record<string, string> } {
const parsed = apiKey.startsWith("{") ? parseGoogleOauthApiKey(apiKey) : null;
@@ -88,27 +87,3 @@ export function resolveGoogleGenerativeAiHttpRequestConfig(params: {
transport: params.transport,
});
}
export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3.1-pro-preview";
export function applyGoogleGeminiModelDefault(cfg: OpenClawConfig): {
next: OpenClawConfig;
changed: boolean;
} {
const current = cfg.agents?.defaults?.model as unknown;
const currentPrimary =
typeof current === "string"
? current.trim() || undefined
: current &&
typeof current === "object" &&
typeof (current as { primary?: unknown }).primary === "string"
? ((current as { primary: string }).primary || "").trim() || undefined
: undefined;
if (currentPrimary === GOOGLE_GEMINI_DEFAULT_MODEL) {
return { next: cfg, changed: false };
}
return {
next: applyAgentDefaultModelPrimary(cfg, GOOGLE_GEMINI_DEFAULT_MODEL),
changed: true,
};
}

View File

@@ -4,6 +4,7 @@ import type {
ProviderFetchUsageSnapshotContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth-result";
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools";
import { fetchGeminiUsage } from "openclaw/plugin-sdk/provider-usage";
import { formatGoogleOauthApiKey, parseGoogleUsageToken } from "./oauth-token-shared.js";
@@ -29,8 +30,8 @@ async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) {
return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID);
}
export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) {
api.registerProvider({
export function buildGoogleGeminiCliProvider(): ProviderPlugin {
return {
id: PROVIDER_ID,
label: PROVIDER_LABEL,
docsPath: "/providers/models",
@@ -128,5 +129,9 @@ export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) {
};
},
fetchUsageSnapshot: async (ctx) => await fetchGeminiCliUsage(ctx),
});
};
}
export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) {
api.registerProvider(buildGoogleGeminiCliProvider());
}

View File

@@ -0,0 +1,28 @@
import {
applyAgentDefaultModelPrimary,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3.1-pro-preview";
export function applyGoogleGeminiModelDefault(cfg: OpenClawConfig): {
next: OpenClawConfig;
changed: boolean;
} {
const current = cfg.agents?.defaults?.model as unknown;
const currentPrimary =
typeof current === "string"
? current.trim() || undefined
: current &&
typeof current === "object" &&
typeof (current as { primary?: unknown }).primary === "string"
? ((current as { primary: string }).primary || "").trim() || undefined
: undefined;
if (currentPrimary === GOOGLE_GEMINI_DEFAULT_MODEL) {
return { next: cfg, changed: false };
}
return {
next: applyAgentDefaultModelPrimary(cfg, GOOGLE_GEMINI_DEFAULT_MODEL),
changed: true,
};
}

View File

@@ -0,0 +1,61 @@
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
const noopAuth = async () => ({ profiles: [] });
export function createGoogleProvider(): ProviderPlugin {
return {
id: "google",
label: "Google AI Studio",
docsPath: "/providers/models",
hookAliases: ["google-antigravity", "google-vertex"],
envVars: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
auth: [
{
id: "api-key",
kind: "api_key",
label: "Google Gemini API key",
hint: "AI Studio / Gemini API key",
run: noopAuth,
wizard: {
choiceId: "gemini-api-key",
choiceLabel: "Google Gemini API key",
groupId: "google",
groupLabel: "Google",
groupHint: "Gemini API key + OAuth",
},
},
],
};
}
export function createGoogleGeminiCliProvider(): ProviderPlugin {
return {
id: "google-gemini-cli",
label: "Gemini CLI OAuth",
docsPath: "/providers/models",
aliases: ["gemini-cli"],
envVars: [
"OPENCLAW_GEMINI_OAUTH_CLIENT_ID",
"OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET",
"GEMINI_CLI_OAUTH_CLIENT_ID",
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
],
auth: [
{
id: "oauth",
kind: "oauth",
label: "Google OAuth",
hint: "PKCE + localhost callback",
run: noopAuth,
},
],
wizard: {
setup: {
choiceId: "google-gemini-cli",
choiceLabel: "Gemini CLI OAuth",
choiceHint: "Google OAuth with project-aware token payload",
methodId: "oauth",
},
},
};
}

View File

@@ -1,17 +1,17 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import {
GOOGLE_GEMINI_DEFAULT_MODEL,
applyGoogleGeminiModelDefault,
normalizeGoogleProviderConfig,
normalizeGoogleModelId,
resolveGoogleGenerativeAiTransport,
} from "./api.js";
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeGoogleModelId } from "./model-id.js";
import { GOOGLE_GEMINI_DEFAULT_MODEL, applyGoogleGeminiModelDefault } from "./onboard.js";
import { GOOGLE_GEMINI_PROVIDER_HOOKS } from "./provider-hooks.js";
import { isModernGoogleModel, resolveGoogleGeminiForwardCompatModel } from "./provider-models.js";
import {
normalizeGoogleProviderConfig,
resolveGoogleGenerativeAiTransport,
} from "./provider-policy.js";
export function registerGoogleProvider(api: OpenClawPluginApi) {
api.registerProvider({
export function buildGoogleProvider(): ProviderPlugin {
return {
id: "google",
label: "Google AI Studio",
docsPath: "/providers/models",
@@ -50,5 +50,9 @@ export function registerGoogleProvider(api: OpenClawPluginApi) {
}),
...GOOGLE_GEMINI_PROVIDER_HOOKS,
isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId),
});
};
}
export function registerGoogleProvider(api: OpenClawPluginApi) {
api.registerProvider(buildGoogleProvider());
}

View File

@@ -0,0 +1,194 @@
import {
buildSearchCacheKey,
buildUnsupportedSearchFilterResponse,
DEFAULT_SEARCH_COUNT,
readCachedSearchPayload,
readConfiguredSecretString,
readNumberParam,
readProviderEnvValue,
readStringParam,
resolveCitationRedirectUrl,
resolveSearchCacheTtlMs,
resolveSearchCount,
resolveSearchTimeoutSeconds,
type SearchConfigRecord,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
import { DEFAULT_GOOGLE_API_BASE_URL } from "../api.js";
import {
resolveGeminiConfig,
resolveGeminiModel,
type GeminiConfig,
} from "./gemini-web-search-provider.shared.js";
const GEMINI_API_BASE = DEFAULT_GOOGLE_API_BASE_URL;
type GeminiGroundingResponse = {
candidates?: Array<{
content?: {
parts?: Array<{
text?: string;
}>;
};
groundingMetadata?: {
groundingChunks?: Array<{
web?: {
uri?: string;
title?: string;
};
}>;
};
}>;
error?: {
code?: number;
message?: string;
status?: string;
};
};
export function resolveGeminiRuntimeApiKey(gemini?: GeminiConfig): string | undefined {
return (
readConfiguredSecretString(gemini?.apiKey, "tools.web.search.gemini.apiKey") ??
readProviderEnvValue(["GEMINI_API_KEY"])
);
}
async function runGeminiSearch(params: {
query: string;
apiKey: string;
model: string;
timeoutSeconds: number;
}): Promise<{ content: string; citations: Array<{ url: string; title?: string }> }> {
const endpoint = `${GEMINI_API_BASE}/models/${params.model}:generateContent`;
return withTrustedWebSearchEndpoint(
{
url: endpoint,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-goog-api-key": params.apiKey,
},
body: JSON.stringify({
contents: [{ parts: [{ text: params.query }] }],
tools: [{ google_search: {} }],
}),
},
},
async (res) => {
if (!res.ok) {
const safeDetail = ((await res.text()) || res.statusText).replace(
/key=[^&\s]+/giu,
"key=***",
);
throw new Error(`Gemini API error (${res.status}): ${safeDetail}`);
}
let data: GeminiGroundingResponse;
try {
data = (await res.json()) as GeminiGroundingResponse;
} catch (error) {
const safeError = String(error).replace(/key=[^&\s]+/giu, "key=***");
throw new Error(`Gemini API returned invalid JSON: ${safeError}`, { cause: error });
}
if (data.error) {
const rawMessage = data.error.message || data.error.status || "unknown";
throw new Error(
`Gemini API error (${data.error.code}): ${rawMessage.replace(/key=[^&\s]+/giu, "key=***")}`,
);
}
const candidate = data.candidates?.[0];
const content =
candidate?.content?.parts
?.map((part) => part.text)
.filter(Boolean)
.join("\n") ?? "No response";
const rawCitations = (candidate?.groundingMetadata?.groundingChunks ?? [])
.filter((chunk) => chunk.web?.uri)
.map((chunk) => ({
url: chunk.web!.uri!,
title: chunk.web?.title || undefined,
}));
const citations: Array<{ url: string; title?: string }> = [];
for (let index = 0; index < rawCitations.length; index += 10) {
const batch = rawCitations.slice(index, index + 10);
const resolved = await Promise.all(
batch.map(async (citation) => ({
...citation,
url: await resolveCitationRedirectUrl(citation.url),
})),
);
citations.push(...resolved);
}
return { content, citations };
},
);
}
export async function executeGeminiSearch(
args: Record<string, unknown>,
searchConfig?: SearchConfigRecord,
): Promise<Record<string, unknown>> {
const unsupportedResponse = buildUnsupportedSearchFilterResponse(args, "gemini");
if (unsupportedResponse) {
return unsupportedResponse;
}
const geminiConfig = resolveGeminiConfig(searchConfig);
const apiKey = resolveGeminiRuntimeApiKey(geminiConfig);
if (!apiKey) {
return {
error: "missing_gemini_api_key",
message:
"web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure tools.web.search.gemini.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const query = readStringParam(args, "query", { required: true });
const count =
readNumberParam(args, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined;
const model = resolveGeminiModel(geminiConfig);
const cacheKey = buildSearchCacheKey([
"gemini",
query,
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
model,
]);
const cached = readCachedSearchPayload(cacheKey);
if (cached) {
return cached;
}
const start = Date.now();
const result = await runGeminiSearch({
query,
apiKey,
model,
timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig),
});
const payload = {
query,
provider: "gemini",
model,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "gemini",
wrapped: true,
},
content: wrapWebContent(result.content),
citations: result.citations,
};
writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
return payload;
}

View File

@@ -0,0 +1,30 @@
export const DEFAULT_GEMINI_WEB_SEARCH_MODEL = "gemini-2.5-flash";
export type GeminiConfig = {
apiKey?: unknown;
model?: unknown;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function trimToUndefined(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
export function resolveGeminiConfig(searchConfig?: Record<string, unknown>): GeminiConfig {
const gemini = searchConfig?.gemini;
return isRecord(gemini) ? gemini : {};
}
export function resolveGeminiApiKey(
gemini?: GeminiConfig,
env: Record<string, string | undefined> = process.env,
): string | undefined {
return trimToUndefined(gemini?.apiKey) ?? trimToUndefined(env.GEMINI_API_KEY);
}
export function resolveGeminiModel(gemini?: GeminiConfig): string {
return trimToUndefined(gemini?.model) ?? DEFAULT_GEMINI_WEB_SEARCH_MODEL;
}

View File

@@ -1,244 +1,42 @@
import { Type } from "@sinclair/typebox";
import {
buildSearchCacheKey,
buildUnsupportedSearchFilterResponse,
DEFAULT_SEARCH_COUNT,
getScopedCredentialValue,
MAX_SEARCH_COUNT,
createWebSearchProviderContractFields,
mergeScopedSearchConfig,
readCachedSearchPayload,
readConfiguredSecretString,
readNumberParam,
readProviderEnvValue,
readStringParam,
resolveCitationRedirectUrl,
resolveProviderWebSearchPluginConfig,
resolveSearchCacheTtlMs,
resolveSearchCount,
resolveSearchTimeoutSeconds,
setScopedCredentialValue,
setProviderWebSearchPluginConfigValue,
type SearchConfigRecord,
type WebSearchProviderPlugin,
type WebSearchProviderToolDefinition,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import { DEFAULT_GOOGLE_API_BASE_URL } from "../api.js";
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
import { resolveGeminiApiKey, resolveGeminiModel } from "./gemini-web-search-provider.shared.js";
const DEFAULT_GEMINI_MODEL = "gemini-2.5-flash";
const GEMINI_API_BASE = DEFAULT_GOOGLE_API_BASE_URL;
type GeminiConfig = {
apiKey?: string;
model?: string;
};
type GeminiGroundingResponse = {
candidates?: Array<{
content?: {
parts?: Array<{
text?: string;
}>;
};
groundingMetadata?: {
groundingChunks?: Array<{
web?: {
uri?: string;
title?: string;
};
}>;
};
}>;
error?: {
code?: number;
message?: string;
status?: string;
};
};
function resolveGeminiConfig(searchConfig?: SearchConfigRecord): GeminiConfig {
const gemini = searchConfig?.gemini;
return gemini && typeof gemini === "object" && !Array.isArray(gemini)
? (gemini as GeminiConfig)
: {};
}
function resolveGeminiApiKey(gemini?: GeminiConfig): string | undefined {
return (
readConfiguredSecretString(gemini?.apiKey, "tools.web.search.gemini.apiKey") ??
readProviderEnvValue(["GEMINI_API_KEY"])
);
}
function resolveGeminiModel(gemini?: GeminiConfig): string {
const model = normalizeOptionalString(gemini?.model) ?? "";
return model || DEFAULT_GEMINI_MODEL;
}
async function runGeminiSearch(params: {
query: string;
apiKey: string;
model: string;
timeoutSeconds: number;
}): Promise<{ content: string; citations: Array<{ url: string; title?: string }> }> {
const endpoint = `${GEMINI_API_BASE}/models/${params.model}:generateContent`;
return withTrustedWebSearchEndpoint(
{
url: endpoint,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-goog-api-key": params.apiKey,
},
body: JSON.stringify({
contents: [{ parts: [{ text: params.query }] }],
tools: [{ google_search: {} }],
}),
},
const GEMINI_CREDENTIAL_PATH = "plugins.entries.google.config.webSearch.apiKey";
const GEMINI_TOOL_PARAMETERS = {
type: "object",
properties: {
query: { type: "string", description: "Search query string." },
count: {
type: "number",
description: "Number of results to return (1-10).",
minimum: 1,
maximum: 10,
},
async (res) => {
if (!res.ok) {
const safeDetail = ((await res.text()) || res.statusText).replace(
/key=[^&\s]+/gi,
"key=***",
);
throw new Error(`Gemini API error (${res.status}): ${safeDetail}`);
}
let data: GeminiGroundingResponse;
try {
data = (await res.json()) as GeminiGroundingResponse;
} catch (error) {
const safeError = String(error).replace(/key=[^&\s]+/gi, "key=***");
throw new Error(`Gemini API returned invalid JSON: ${safeError}`, { cause: error });
}
if (data.error) {
const rawMessage = data.error.message || data.error.status || "unknown";
throw new Error(
`Gemini API error (${data.error.code}): ${rawMessage.replace(/key=[^&\s]+/gi, "key=***")}`,
);
}
const candidate = data.candidates?.[0];
const content =
candidate?.content?.parts
?.map((part) => part.text)
.filter(Boolean)
.join("\n") ?? "No response";
const rawCitations = (candidate?.groundingMetadata?.groundingChunks ?? [])
.filter((chunk) => chunk.web?.uri)
.map((chunk) => ({
url: chunk.web!.uri!,
title: chunk.web?.title || undefined,
}));
const citations: Array<{ url: string; title?: string }> = [];
for (let index = 0; index < rawCitations.length; index += 10) {
const batch = rawCitations.slice(index, index + 10);
const resolved = await Promise.all(
batch.map(async (citation) => ({
...citation,
url: await resolveCitationRedirectUrl(citation.url),
})),
);
citations.push(...resolved);
}
return { content, citations };
},
);
}
function createGeminiSchema() {
return Type.Object({
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Number({
description: "Number of results to return (1-10).",
minimum: 1,
maximum: MAX_SEARCH_COUNT,
}),
),
country: Type.Optional(Type.String({ description: "Not supported by Gemini." })),
language: Type.Optional(Type.String({ description: "Not supported by Gemini." })),
freshness: Type.Optional(Type.String({ description: "Not supported by Gemini." })),
date_after: Type.Optional(Type.String({ description: "Not supported by Gemini." })),
date_before: Type.Optional(Type.String({ description: "Not supported by Gemini." })),
});
}
country: { type: "string", description: "Not supported by Gemini." },
language: { type: "string", description: "Not supported by Gemini." },
freshness: { type: "string", description: "Not supported by Gemini." },
date_after: { type: "string", description: "Not supported by Gemini." },
date_before: { type: "string", description: "Not supported by Gemini." },
},
required: ["query"],
} satisfies Record<string, unknown>;
function createGeminiToolDefinition(
searchConfig?: SearchConfigRecord,
searchConfig?: Record<string, unknown>,
): WebSearchProviderToolDefinition {
return {
description:
"Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search.",
parameters: createGeminiSchema(),
parameters: GEMINI_TOOL_PARAMETERS,
execute: async (args) => {
const params = args;
const unsupportedResponse = buildUnsupportedSearchFilterResponse(params, "gemini");
if (unsupportedResponse) {
return unsupportedResponse;
}
const geminiConfig = resolveGeminiConfig(searchConfig);
const apiKey = resolveGeminiApiKey(geminiConfig);
if (!apiKey) {
return {
error: "missing_gemini_api_key",
message:
"web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure tools.web.search.gemini.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const query = readStringParam(params, "query", { required: true });
const count =
readNumberParam(params, "count", { integer: true }) ??
searchConfig?.maxResults ??
undefined;
const model = resolveGeminiModel(geminiConfig);
const cacheKey = buildSearchCacheKey([
"gemini",
query,
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
model,
]);
const cached = readCachedSearchPayload(cacheKey);
if (cached) {
return cached;
}
const start = Date.now();
const result = await runGeminiSearch({
query,
apiKey,
model,
timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig),
});
const payload = {
query,
provider: "gemini",
model,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "gemini",
wrapped: true,
},
content: wrapWebContent(result.content),
citations: result.citations,
};
writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
return payload;
const { executeGeminiSearch } = await import("./gemini-web-search-provider.runtime.js");
return await executeGeminiSearch(args, searchConfig);
},
};
}
@@ -255,23 +53,19 @@ export function createGeminiWebSearchProvider(): WebSearchProviderPlugin {
signupUrl: "https://aistudio.google.com/apikey",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 20,
credentialPath: "plugins.entries.google.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.google.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "gemini"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "gemini", value),
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "google")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "google", "apiKey", value);
},
credentialPath: GEMINI_CREDENTIAL_PATH,
...createWebSearchProviderContractFields({
credentialPath: GEMINI_CREDENTIAL_PATH,
searchCredential: { type: "scoped", scopeId: "gemini" },
configuredCredential: { pluginId: "google" },
}),
createTool: (ctx) =>
createGeminiToolDefinition(
mergeScopedSearchConfig(
ctx.searchConfig as SearchConfigRecord | undefined,
ctx.searchConfig,
"gemini",
resolveProviderWebSearchPluginConfig(ctx.config, "google"),
) as SearchConfigRecord | undefined,
),
),
};
}

View File

@@ -1,28 +1 @@
import {
createWebSearchProviderContractFields,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
export function createGeminiWebSearchProvider(): WebSearchProviderPlugin {
const credentialPath = "plugins.entries.google.config.webSearch.apiKey";
return {
id: "gemini",
label: "Gemini (Google Search)",
hint: "Requires Google Gemini API key · Google Search grounding",
onboardingScopes: ["text-inference"],
credentialLabel: "Google Gemini API key",
envVars: ["GEMINI_API_KEY"],
placeholder: "AIza...",
signupUrl: "https://aistudio.google.com/apikey",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 20,
credentialPath,
...createWebSearchProviderContractFields({
credentialPath,
searchCredential: { type: "scoped", scopeId: "gemini" },
configuredCredential: { pluginId: "google" },
}),
createTool: () => null,
};
}
export { createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js";

View File

@@ -1,12 +1,10 @@
export {
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
} from "./src/media-contract.js";
export {
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
resolveIMessageAttachmentRoots,
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
} from "./src/media-contract.js";
} from "./media-contract-api.js";
export {
__testing as imessageConversationBindingTesting,
createIMessageConversationBindingManager,

View File

@@ -0,0 +1,7 @@
export {
DEFAULT_IMESSAGE_ATTACHMENT_ROOTS,
resolveIMessageAttachmentRoots,
resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots,
resolveIMessageRemoteAttachmentRoots,
resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots,
} from "./src/media-contract.js";

View File

@@ -0,0 +1,84 @@
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
const noopAuth = async () => ({ profiles: [] });
const wizardGroup = {
groupId: "minimax",
groupLabel: "MiniMax",
groupHint: "M2.7 (recommended)",
} as const;
export function createMinimaxProvider(): ProviderPlugin {
return {
id: "minimax",
label: "MiniMax",
hookAliases: ["minimax-cn"],
docsPath: "/providers/minimax",
envVars: ["MINIMAX_API_KEY"],
auth: [
{
id: "api-global",
kind: "api_key",
label: "MiniMax API key (Global)",
hint: "Global endpoint - api.minimax.io",
run: noopAuth,
wizard: {
choiceId: "minimax-global-api",
choiceLabel: "MiniMax API key (Global)",
choiceHint: "Global endpoint - api.minimax.io",
...wizardGroup,
},
},
{
id: "api-cn",
kind: "api_key",
label: "MiniMax API key (CN)",
hint: "CN endpoint - api.minimaxi.com",
run: noopAuth,
wizard: {
choiceId: "minimax-cn-api",
choiceLabel: "MiniMax API key (CN)",
choiceHint: "CN endpoint - api.minimaxi.com",
...wizardGroup,
},
},
],
};
}
export function createMinimaxPortalProvider(): ProviderPlugin {
return {
id: "minimax-portal",
label: "MiniMax",
hookAliases: ["minimax-portal-cn"],
docsPath: "/providers/minimax",
envVars: ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"],
auth: [
{
id: "oauth",
kind: "device_code",
label: "MiniMax OAuth (Global)",
hint: "Global endpoint - api.minimax.io",
run: noopAuth,
wizard: {
choiceId: "minimax-global-oauth",
choiceLabel: "MiniMax OAuth (Global)",
choiceHint: "Global endpoint - api.minimax.io",
...wizardGroup,
},
},
{
id: "oauth-cn",
kind: "device_code",
label: "MiniMax OAuth (CN)",
hint: "CN endpoint - api.minimaxi.com",
run: noopAuth,
wizard: {
choiceId: "minimax-cn-oauth",
choiceLabel: "MiniMax OAuth (CN)",
choiceHint: "CN endpoint - api.minimaxi.com",
...wizardGroup,
},
},
],
};
}

View File

@@ -0,0 +1,253 @@
import {
DEFAULT_SEARCH_COUNT,
buildSearchCacheKey,
formatCliCommand,
mergeScopedSearchConfig,
readCachedSearchPayload,
readConfiguredSecretString,
readNumberParam,
readProviderEnvValue,
readStringParam,
resolveProviderWebSearchPluginConfig,
resolveSearchCacheTtlMs,
resolveSearchCount,
resolveSearchTimeoutSeconds,
resolveSiteName,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCachedSearchPayload,
type SearchConfigRecord,
} from "openclaw/plugin-sdk/provider-web-search";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
const MINIMAX_SEARCH_ENDPOINT_GLOBAL = "https://api.minimax.io/v1/coding_plan/search";
const MINIMAX_SEARCH_ENDPOINT_CN = "https://api.minimaxi.com/v1/coding_plan/search";
const MINIMAX_CODING_PLAN_ENV_VARS = ["MINIMAX_CODE_PLAN_KEY", "MINIMAX_CODING_API_KEY"] as const;
type MiniMaxSearchResult = {
title?: string;
link?: string;
snippet?: string;
date?: string;
};
type MiniMaxRelatedSearch = {
query?: string;
};
type MiniMaxSearchResponse = {
organic?: MiniMaxSearchResult[];
related_searches?: MiniMaxRelatedSearch[];
base_resp?: {
status_code?: number;
status_msg?: string;
};
};
function resolveMiniMaxApiKey(searchConfig?: SearchConfigRecord): string | undefined {
return (
readConfiguredSecretString(searchConfig?.apiKey, "tools.web.search.apiKey") ??
readProviderEnvValue([...MINIMAX_CODING_PLAN_ENV_VARS, "MINIMAX_API_KEY"])
);
}
function isMiniMaxCnHost(value: string | undefined): boolean {
const trimmed = normalizeOptionalString(value);
if (!trimmed) {
return false;
}
try {
return new URL(trimmed).hostname.endsWith("minimaxi.com");
} catch {
return trimmed.includes("minimaxi.com");
}
}
function resolveMiniMaxRegion(
searchConfig?: SearchConfigRecord,
config?: Record<string, unknown>,
): "cn" | "global" {
// 1. Explicit region in search config takes priority
const minimax =
typeof searchConfig?.minimax === "object" &&
searchConfig.minimax !== null &&
!Array.isArray(searchConfig.minimax)
? (searchConfig.minimax as Record<string, unknown>)
: undefined;
const configuredRegion =
typeof minimax?.region === "string" ? normalizeOptionalString(minimax.region) : undefined;
if (configuredRegion) {
return configuredRegion === "cn" ? "cn" : "global";
}
// 2. Infer from the shared MiniMax host override.
if (isMiniMaxCnHost(process.env.MINIMAX_API_HOST)) {
return "cn";
}
// 3. Infer from model provider base URL (set by CN onboarding)
const models = config?.models as Record<string, unknown> | undefined;
const providers = models?.providers as Record<string, unknown> | undefined;
const minimaxProvider = providers?.minimax as Record<string, unknown> | undefined;
const portalProvider = providers?.["minimax-portal"] as Record<string, unknown> | undefined;
const baseUrl = typeof minimaxProvider?.baseUrl === "string" ? minimaxProvider.baseUrl : "";
const portalBaseUrl = typeof portalProvider?.baseUrl === "string" ? portalProvider.baseUrl : "";
if (isMiniMaxCnHost(baseUrl) || isMiniMaxCnHost(portalBaseUrl)) {
return "cn";
}
return "global";
}
function resolveMiniMaxEndpoint(
searchConfig?: SearchConfigRecord,
config?: Record<string, unknown>,
): string {
return resolveMiniMaxRegion(searchConfig, config) === "cn"
? MINIMAX_SEARCH_ENDPOINT_CN
: MINIMAX_SEARCH_ENDPOINT_GLOBAL;
}
async function runMiniMaxSearch(params: {
query: string;
count: number;
apiKey: string;
endpoint: string;
timeoutSeconds: number;
}): Promise<{
results: Array<Record<string, unknown>>;
relatedSearches?: string[];
}> {
return withTrustedWebSearchEndpoint(
{
url: params.endpoint,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "POST",
headers: {
Authorization: `Bearer ${params.apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ q: params.query }),
},
},
async (res) => {
if (!res.ok) {
const detail = await res.text();
throw new Error(`MiniMax Search API error (${res.status}): ${detail || res.statusText}`);
}
const data = (await res.json()) as MiniMaxSearchResponse;
if (data.base_resp?.status_code && data.base_resp.status_code !== 0) {
throw new Error(
`MiniMax Search API error (${data.base_resp.status_code}): ${data.base_resp.status_msg || "unknown error"}`,
);
}
const organic = Array.isArray(data.organic) ? data.organic : [];
const results = organic.slice(0, params.count).map((entry) => {
const title = entry.title ?? "";
const url = entry.link ?? "";
const snippet = entry.snippet ?? "";
return {
title: title ? wrapWebContent(title, "web_search") : "",
url,
description: snippet ? wrapWebContent(snippet, "web_search") : "",
published: entry.date || undefined,
siteName: resolveSiteName(url) || undefined,
};
});
const relatedSearches = Array.isArray(data.related_searches)
? data.related_searches
.map((r) => r.query)
.filter((q): q is string => typeof q === "string" && q.length > 0)
.map((q) => wrapWebContent(q, "web_search"))
: undefined;
return { results, relatedSearches };
},
);
}
function missingMiniMaxKeyPayload() {
return {
error: "missing_minimax_api_key",
message: `web_search (minimax) needs a MiniMax Coding Plan key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set MINIMAX_CODE_PLAN_KEY, MINIMAX_CODING_API_KEY, or MINIMAX_API_KEY in the Gateway environment.`,
docs: "https://docs.openclaw.ai/tools/web",
};
}
export async function executeMiniMaxWebSearchProviderTool(
ctx: { config?: Record<string, unknown>; searchConfig?: SearchConfigRecord },
args: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const searchConfig = mergeScopedSearchConfig(
ctx.searchConfig,
"minimax",
resolveProviderWebSearchPluginConfig(ctx.config, "minimax"),
{ mirrorApiKeyToTopLevel: true },
) as SearchConfigRecord | undefined;
const config = ctx.config;
const apiKey = resolveMiniMaxApiKey(searchConfig);
if (!apiKey) {
return missingMiniMaxKeyPayload();
}
const params = args;
const query = readStringParam(params, "query", { required: true });
const count =
readNumberParam(params, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined;
const resolvedCount = resolveSearchCount(count, DEFAULT_SEARCH_COUNT);
const endpoint = resolveMiniMaxEndpoint(searchConfig, config);
const cacheKey = buildSearchCacheKey(["minimax", endpoint, query, resolvedCount]);
const cached = readCachedSearchPayload(cacheKey);
if (cached) {
return cached;
}
const start = Date.now();
const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig);
const { results, relatedSearches } = await runMiniMaxSearch({
query,
count: resolvedCount,
apiKey,
endpoint,
timeoutSeconds,
});
const payload: Record<string, unknown> = {
query,
provider: "minimax",
count: results.length,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "minimax",
wrapped: true,
},
results,
};
if (relatedSearches && relatedSearches.length > 0) {
payload.relatedSearches = relatedSearches;
}
writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
return payload;
}
export const __testing = {
MINIMAX_SEARCH_ENDPOINT_GLOBAL,
MINIMAX_SEARCH_ENDPOINT_CN,
resolveMiniMaxApiKey,
resolveMiniMaxEndpoint,
resolveMiniMaxRegion,
} as const;

View File

@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { __testing } from "./minimax-web-search-provider.js";
import { minimaxWebSearchTesting } from "../test-api.js";
const {
MINIMAX_SEARCH_ENDPOINT_GLOBAL,
@@ -7,7 +7,7 @@ const {
resolveMiniMaxApiKey,
resolveMiniMaxEndpoint,
resolveMiniMaxRegion,
} = __testing;
} = minimaxWebSearchTesting;
describe("minimax web search provider", () => {
const originalApiHost = process.env.MINIMAX_API_HOST;

View File

@@ -1,275 +1,23 @@
import { Type } from "@sinclair/typebox";
import {
DEFAULT_SEARCH_COUNT,
MAX_SEARCH_COUNT,
buildSearchCacheKey,
formatCliCommand,
mergeScopedSearchConfig,
readCachedSearchPayload,
readConfiguredSecretString,
readNumberParam,
readProviderEnvValue,
readStringParam,
resolveProviderWebSearchPluginConfig,
resolveSearchCacheTtlMs,
resolveSearchCount,
resolveSearchTimeoutSeconds,
resolveSiteName,
setProviderWebSearchPluginConfigValue,
setTopLevelCredentialValue,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCachedSearchPayload,
type SearchConfigRecord,
createWebSearchProviderContractFields,
type WebSearchProviderPlugin,
type WebSearchProviderToolDefinition,
} from "openclaw/plugin-sdk/provider-web-search";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
const MINIMAX_SEARCH_ENDPOINT_GLOBAL = "https://api.minimax.io/v1/coding_plan/search";
const MINIMAX_SEARCH_ENDPOINT_CN = "https://api.minimaxi.com/v1/coding_plan/search";
const MINIMAX_CREDENTIAL_PATH = "plugins.entries.minimax.config.webSearch.apiKey";
const MINIMAX_CODING_PLAN_ENV_VARS = ["MINIMAX_CODE_PLAN_KEY", "MINIMAX_CODING_API_KEY"] as const;
type MiniMaxSearchResult = {
title?: string;
link?: string;
snippet?: string;
date?: string;
};
type MiniMaxRelatedSearch = {
query?: string;
};
type MiniMaxSearchResponse = {
organic?: MiniMaxSearchResult[];
related_searches?: MiniMaxRelatedSearch[];
base_resp?: {
status_code?: number;
status_msg?: string;
};
};
function resolveMiniMaxApiKey(searchConfig?: SearchConfigRecord): string | undefined {
return (
readConfiguredSecretString(searchConfig?.apiKey, "tools.web.search.apiKey") ??
readProviderEnvValue([...MINIMAX_CODING_PLAN_ENV_VARS, "MINIMAX_API_KEY"])
);
}
function isMiniMaxCnHost(value: string | undefined): boolean {
const trimmed = normalizeOptionalString(value);
if (!trimmed) {
return false;
}
try {
return new URL(trimmed).hostname.endsWith("minimaxi.com");
} catch {
return trimmed.includes("minimaxi.com");
}
}
function resolveMiniMaxRegion(
searchConfig?: SearchConfigRecord,
config?: Record<string, unknown>,
): "cn" | "global" {
// 1. Explicit region in search config takes priority
const minimax =
typeof searchConfig?.minimax === "object" &&
searchConfig.minimax !== null &&
!Array.isArray(searchConfig.minimax)
? (searchConfig.minimax as Record<string, unknown>)
: undefined;
const configuredRegion =
typeof minimax?.region === "string" ? normalizeOptionalString(minimax.region) : undefined;
if (configuredRegion) {
return configuredRegion === "cn" ? "cn" : "global";
}
// 2. Infer from the shared MiniMax host override.
if (isMiniMaxCnHost(process.env.MINIMAX_API_HOST)) {
return "cn";
}
// 3. Infer from model provider base URL (set by CN onboarding)
const models = config?.models as Record<string, unknown> | undefined;
const providers = models?.providers as Record<string, unknown> | undefined;
const minimaxProvider = providers?.minimax as Record<string, unknown> | undefined;
const portalProvider = providers?.["minimax-portal"] as Record<string, unknown> | undefined;
const baseUrl = typeof minimaxProvider?.baseUrl === "string" ? minimaxProvider.baseUrl : "";
const portalBaseUrl = typeof portalProvider?.baseUrl === "string" ? portalProvider.baseUrl : "";
if (isMiniMaxCnHost(baseUrl) || isMiniMaxCnHost(portalBaseUrl)) {
return "cn";
}
return "global";
}
function resolveMiniMaxEndpoint(
searchConfig?: SearchConfigRecord,
config?: Record<string, unknown>,
): string {
return resolveMiniMaxRegion(searchConfig, config) === "cn"
? MINIMAX_SEARCH_ENDPOINT_CN
: MINIMAX_SEARCH_ENDPOINT_GLOBAL;
}
async function runMiniMaxSearch(params: {
query: string;
count: number;
apiKey: string;
endpoint: string;
timeoutSeconds: number;
}): Promise<{
results: Array<Record<string, unknown>>;
relatedSearches?: string[];
}> {
return withTrustedWebSearchEndpoint(
{
url: params.endpoint,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "POST",
headers: {
Authorization: `Bearer ${params.apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ q: params.query }),
},
},
async (res) => {
if (!res.ok) {
const detail = await res.text();
throw new Error(`MiniMax Search API error (${res.status}): ${detail || res.statusText}`);
}
const data = (await res.json()) as MiniMaxSearchResponse;
if (data.base_resp?.status_code && data.base_resp.status_code !== 0) {
throw new Error(
`MiniMax Search API error (${data.base_resp.status_code}): ${data.base_resp.status_msg || "unknown error"}`,
);
}
const organic = Array.isArray(data.organic) ? data.organic : [];
const results = organic.slice(0, params.count).map((entry) => {
const title = entry.title ?? "";
const url = entry.link ?? "";
const snippet = entry.snippet ?? "";
return {
title: title ? wrapWebContent(title, "web_search") : "",
url,
description: snippet ? wrapWebContent(snippet, "web_search") : "",
published: entry.date || undefined,
siteName: resolveSiteName(url) || undefined,
};
});
const relatedSearches = Array.isArray(data.related_searches)
? data.related_searches
.map((r) => r.query)
.filter((q): q is string => typeof q === "string" && q.length > 0)
.map((q) => wrapWebContent(q, "web_search"))
: undefined;
return { results, relatedSearches };
},
);
}
const MiniMaxSearchSchema = Type.Object({
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Number({
const MiniMaxSearchSchema = {
type: "object",
properties: {
query: { type: "string", description: "Search query string." },
count: {
type: "number",
description: "Number of results to return (1-10).",
minimum: 1,
maximum: MAX_SEARCH_COUNT,
}),
),
});
function missingMiniMaxKeyPayload() {
return {
error: "missing_minimax_api_key",
message: `web_search (minimax) needs a MiniMax Coding Plan key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set MINIMAX_CODE_PLAN_KEY, MINIMAX_CODING_API_KEY, or MINIMAX_API_KEY in the Gateway environment.`,
docs: "https://docs.openclaw.ai/tools/web",
};
}
function createMiniMaxToolDefinition(
searchConfig?: SearchConfigRecord,
config?: Record<string, unknown>,
): WebSearchProviderToolDefinition {
return {
description:
"Search the web using MiniMax Search API. Returns titles, URLs, snippets, and related search suggestions.",
parameters: MiniMaxSearchSchema,
execute: async (args) => {
const apiKey = resolveMiniMaxApiKey(searchConfig);
if (!apiKey) {
return missingMiniMaxKeyPayload();
}
const params = args;
const query = readStringParam(params, "query", { required: true });
const count =
readNumberParam(params, "count", { integer: true }) ??
searchConfig?.maxResults ??
undefined;
const resolvedCount = resolveSearchCount(count, DEFAULT_SEARCH_COUNT);
const endpoint = resolveMiniMaxEndpoint(searchConfig, config);
const cacheKey = buildSearchCacheKey(["minimax", endpoint, query, resolvedCount]);
const cached = readCachedSearchPayload(cacheKey);
if (cached) {
return cached;
}
const start = Date.now();
const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig);
const { results, relatedSearches } = await runMiniMaxSearch({
query,
count: resolvedCount,
apiKey,
endpoint,
timeoutSeconds,
});
const payload: Record<string, unknown> = {
query,
provider: "minimax",
count: results.length,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "minimax",
wrapped: true,
},
results,
};
if (relatedSearches && relatedSearches.length > 0) {
payload.relatedSearches = relatedSearches;
}
writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
return payload;
maximum: 10,
},
};
}
export const __testing = {
MINIMAX_SEARCH_ENDPOINT_GLOBAL,
MINIMAX_SEARCH_ENDPOINT_CN,
resolveMiniMaxApiKey,
resolveMiniMaxEndpoint,
resolveMiniMaxRegion,
} as const;
},
} satisfies Record<string, unknown>;
export function createMiniMaxWebSearchProvider(): WebSearchProviderPlugin {
return {
@@ -282,24 +30,21 @@ export function createMiniMaxWebSearchProvider(): WebSearchProviderPlugin {
signupUrl: "https://platform.minimax.io/user-center/basic-information/interface-key",
docsUrl: "https://docs.openclaw.ai/tools/minimax-search",
autoDetectOrder: 15,
credentialPath: "plugins.entries.minimax.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.minimax.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => searchConfig?.apiKey,
setCredentialValue: setTopLevelCredentialValue,
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "minimax")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "minimax", "apiKey", value);
},
createTool: (ctx) =>
createMiniMaxToolDefinition(
mergeScopedSearchConfig(
ctx.searchConfig as SearchConfigRecord | undefined,
"minimax",
resolveProviderWebSearchPluginConfig(ctx.config, "minimax"),
{ mirrorApiKeyToTopLevel: true },
) as SearchConfigRecord | undefined,
ctx.config as Record<string, unknown> | undefined,
),
credentialPath: MINIMAX_CREDENTIAL_PATH,
...createWebSearchProviderContractFields({
credentialPath: MINIMAX_CREDENTIAL_PATH,
searchCredential: { type: "top-level" },
configuredCredential: { pluginId: "minimax" },
}),
createTool: (ctx) => ({
description:
"Search the web using MiniMax Search API. Returns titles, URLs, snippets, and related search suggestions.",
parameters: MiniMaxSearchSchema,
execute: async (args) => {
const { executeMiniMaxWebSearchProviderTool } =
await import("./minimax-web-search-provider.runtime.js");
return await executeMiniMaxWebSearchProviderTool(ctx, args);
},
}),
};
}

View File

@@ -7,4 +7,5 @@ export {
minimaxMediaUnderstandingProvider,
minimaxPortalMediaUnderstandingProvider,
} from "./media-understanding-provider.js";
export { __testing as minimaxWebSearchTesting } from "./src/minimax-web-search-provider.runtime.js";
export { buildMinimaxVideoGenerationProvider } from "./video-generation-provider.js";

View File

@@ -0,0 +1,33 @@
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
const noopAuth = async () => ({ profiles: [] });
export function createMoonshotProvider(): ProviderPlugin {
return {
id: "moonshot",
label: "Moonshot",
docsPath: "/providers/moonshot",
auth: [
{
id: "api-key",
kind: "api_key",
label: "Kimi API key (.ai)",
hint: "Kimi K2.5 + Kimi",
run: noopAuth,
wizard: {
groupLabel: "Moonshot AI (Kimi K2.5)",
},
},
{
id: "api-key-cn",
kind: "api_key",
label: "Kimi API key (.cn)",
hint: "Kimi K2.5 + Kimi",
run: noopAuth,
wizard: {
groupLabel: "Moonshot AI (Kimi K2.5)",
},
},
],
};
}

View File

@@ -0,0 +1,414 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
import {
buildSearchCacheKey,
buildUnsupportedSearchFilterResponse,
DEFAULT_SEARCH_COUNT,
mergeScopedSearchConfig,
readCachedSearchPayload,
readConfiguredSecretString,
readNumberParam,
readProviderEnvValue,
readStringParam,
resolveProviderWebSearchPluginConfig,
resolveSearchCacheTtlMs,
resolveSearchCount,
resolveSearchTimeoutSeconds,
setProviderWebSearchPluginConfigValue,
type SearchConfigRecord,
type WebSearchProviderSetupContext,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
isNativeMoonshotBaseUrl,
MOONSHOT_BASE_URL,
MOONSHOT_CN_BASE_URL,
MOONSHOT_DEFAULT_MODEL_ID,
} from "../provider-catalog.js";
const DEFAULT_KIMI_BASE_URL = MOONSHOT_BASE_URL;
const DEFAULT_KIMI_SEARCH_MODEL = MOONSHOT_DEFAULT_MODEL_ID;
/** Models that require explicit thinking disablement for web search.
* Reasoning variants (kimi-k2-thinking, kimi-k2-thinking-turbo) are excluded
* because they default to thinking-enabled and disabling it would defeat their
* purpose; they are also unlikely to be used for web search. */
const KIMI_THINKING_MODELS = new Set(["kimi-k2.5"]);
const KIMI_WEB_SEARCH_TOOL = {
type: "builtin_function",
function: { name: "$web_search" },
} as const;
type KimiConfig = {
apiKey?: string;
baseUrl?: string;
model?: string;
};
type KimiToolCall = {
id?: string;
type?: string;
function?: {
name?: string;
arguments?: string;
};
};
type KimiMessage = {
role?: string;
content?: string;
reasoning_content?: string;
tool_calls?: KimiToolCall[];
};
type KimiSearchResponse = {
choices?: Array<{
finish_reason?: string;
message?: KimiMessage;
}>;
search_results?: Array<{
title?: string;
url?: string;
content?: string;
}>;
};
function resolveKimiConfig(searchConfig?: SearchConfigRecord): KimiConfig {
const kimi = searchConfig?.kimi;
return kimi && typeof kimi === "object" && !Array.isArray(kimi) ? (kimi as KimiConfig) : {};
}
function resolveKimiApiKey(kimi?: KimiConfig): string | undefined {
return (
readConfiguredSecretString(kimi?.apiKey, "tools.web.search.kimi.apiKey") ??
readProviderEnvValue(["KIMI_API_KEY", "MOONSHOT_API_KEY"])
);
}
function resolveKimiModel(kimi?: KimiConfig): string {
const model = normalizeOptionalString(kimi?.model) ?? "";
return model || DEFAULT_KIMI_SEARCH_MODEL;
}
function trimTrailingSlashes(url: string): string {
return url.replace(/\/+$/, "");
}
function resolveKimiBaseUrl(kimi?: KimiConfig, openClawConfig?: OpenClawConfig): string {
const explicitBaseUrl = normalizeOptionalString(kimi?.baseUrl) ?? "";
if (explicitBaseUrl) {
return trimTrailingSlashes(explicitBaseUrl) || DEFAULT_KIMI_BASE_URL;
}
const moonshotBaseUrl = openClawConfig?.models?.providers?.moonshot?.baseUrl;
if (typeof moonshotBaseUrl === "string") {
const normalizedMoonshotBaseUrl = trimTrailingSlashes(moonshotBaseUrl.trim());
if (normalizedMoonshotBaseUrl && isNativeMoonshotBaseUrl(normalizedMoonshotBaseUrl)) {
return normalizedMoonshotBaseUrl;
}
}
return DEFAULT_KIMI_BASE_URL;
}
function extractKimiMessageText(message: KimiMessage | undefined): string | undefined {
const content = message?.content?.trim();
if (content) {
return content;
}
const reasoning = message?.reasoning_content?.trim();
return reasoning || undefined;
}
function extractKimiCitations(data: KimiSearchResponse): string[] {
const citations = (data.search_results ?? [])
.map((entry) => entry.url?.trim())
.filter((url): url is string => Boolean(url));
for (const toolCall of data.choices?.[0]?.message?.tool_calls ?? []) {
const rawArguments = toolCall.function?.arguments;
if (!rawArguments) {
continue;
}
try {
const parsed = JSON.parse(rawArguments) as {
search_results?: Array<{ url?: string }>;
url?: string;
};
const parsedUrl = normalizeOptionalString(parsed.url);
if (parsedUrl) {
citations.push(parsedUrl);
}
for (const result of parsed.search_results ?? []) {
const resultUrl = normalizeOptionalString(result.url);
if (resultUrl) {
citations.push(resultUrl);
}
}
} catch {
// ignore malformed tool arguments
}
}
return [...new Set(citations)];
}
function extractKimiToolResultContent(toolCall: KimiToolCall): string | undefined {
const rawArguments = toolCall.function?.arguments;
if (typeof rawArguments !== "string" || rawArguments.trim().length === 0) {
return undefined;
}
return rawArguments;
}
async function runKimiSearch(params: {
query: string;
apiKey: string;
baseUrl: string;
model: string;
timeoutSeconds: number;
}): Promise<{ content: string; citations: string[] }> {
const endpoint = `${params.baseUrl.trim().replace(/\/$/, "")}/chat/completions`;
const messages: Array<Record<string, unknown>> = [{ role: "user", content: params.query }];
const collectedCitations = new Set<string>();
for (let round = 0; round < 3; round += 1) {
const next = await withTrustedWebSearchEndpoint(
{
url: endpoint,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${params.apiKey}`,
},
body: JSON.stringify({
model: params.model,
...(KIMI_THINKING_MODELS.has(params.model) ? { thinking: { type: "disabled" } } : {}),
messages,
tools: [KIMI_WEB_SEARCH_TOOL],
}),
},
},
async (
res,
): Promise<{ done: true; content: string; citations: string[] } | { done: false }> => {
if (!res.ok) {
const detail = await res.text();
throw new Error(`Kimi API error (${res.status}): ${detail || res.statusText}`);
}
const data = (await res.json()) as KimiSearchResponse;
for (const citation of extractKimiCitations(data)) {
collectedCitations.add(citation);
}
const choice = data.choices?.[0];
const message = choice?.message;
const text = extractKimiMessageText(message);
const toolCalls = message?.tool_calls ?? [];
if (choice?.finish_reason !== "tool_calls" || toolCalls.length === 0) {
return { done: true, content: text ?? "No response", citations: [...collectedCitations] };
}
messages.push({
role: "assistant",
content: message?.content ?? "",
...(message?.reasoning_content ? { reasoning_content: message.reasoning_content } : {}),
tool_calls: toolCalls,
});
let pushed = false;
for (const toolCall of toolCalls) {
const toolCallId = toolCall.id?.trim();
const toolCallName = toolCall.function?.name?.trim();
const toolContent = extractKimiToolResultContent(toolCall);
if (!toolCallId || !toolCallName || !toolContent) {
continue;
}
pushed = true;
messages.push({
role: "tool",
tool_call_id: toolCallId,
name: toolCallName,
content: toolContent,
});
}
if (!pushed) {
return { done: true, content: text ?? "No response", citations: [...collectedCitations] };
}
return { done: false };
},
);
if (next.done) {
return { content: next.content, citations: next.citations };
}
}
return {
content: "Search completed but no final answer was produced.",
citations: [...collectedCitations],
};
}
export async function executeKimiWebSearchProviderTool(
ctx: { config?: OpenClawConfig; searchConfig?: SearchConfigRecord },
args: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const searchConfig = mergeScopedSearchConfig(
ctx.searchConfig,
"kimi",
resolveProviderWebSearchPluginConfig(ctx.config, "moonshot"),
) as SearchConfigRecord | undefined;
const unsupportedResponse = buildUnsupportedSearchFilterResponse(args, "kimi");
if (unsupportedResponse) {
return unsupportedResponse;
}
const kimiConfig = resolveKimiConfig(searchConfig);
const apiKey = resolveKimiApiKey(kimiConfig);
if (!apiKey) {
return {
error: "missing_kimi_api_key",
message:
"web_search (kimi) needs a Moonshot API key. Set KIMI_API_KEY or MOONSHOT_API_KEY in the Gateway environment, or configure tools.web.search.kimi.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const query = readStringParam(args, "query", { required: true });
const count =
readNumberParam(args, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined;
const model = resolveKimiModel(kimiConfig);
const baseUrl = resolveKimiBaseUrl(kimiConfig, ctx.config);
const cacheKey = buildSearchCacheKey([
"kimi",
query,
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
baseUrl,
model,
]);
const cached = readCachedSearchPayload(cacheKey);
if (cached) {
return cached;
}
const start = Date.now();
const result = await runKimiSearch({
query,
apiKey,
baseUrl,
model,
timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig),
});
const payload = {
query,
provider: "kimi",
model,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "kimi",
wrapped: true,
},
content: wrapWebContent(result.content),
citations: result.citations,
};
writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
return payload;
}
export async function runKimiSearchProviderSetup(
ctx: WebSearchProviderSetupContext,
): Promise<WebSearchProviderSetupContext["config"]> {
const existingPluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "moonshot");
const existingBaseUrl = normalizeOptionalString(existingPluginConfig?.baseUrl) ?? "";
// Normalize trailing slashes so initialValue matches canonical option values.
const normalizedBaseUrl = existingBaseUrl.replace(/\/+$/, "");
const existingModel = normalizeOptionalString(existingPluginConfig?.model) ?? "";
// Region selection (baseUrl)
const isCustomBaseUrl = normalizedBaseUrl && !isNativeMoonshotBaseUrl(normalizedBaseUrl);
const regionOptions: Array<{ value: string; label: string; hint?: string }> = [];
if (isCustomBaseUrl) {
regionOptions.push({
value: normalizedBaseUrl,
label: `Keep current (${normalizedBaseUrl})`,
hint: "custom endpoint",
});
}
regionOptions.push(
{
value: MOONSHOT_BASE_URL,
label: "Moonshot API key (.ai)",
hint: "api.moonshot.ai",
},
{
value: MOONSHOT_CN_BASE_URL,
label: "Moonshot API key (.cn)",
hint: "api.moonshot.cn",
},
);
const regionChoice = await ctx.prompter.select<string>({
message: "Kimi API region",
options: regionOptions,
initialValue: normalizedBaseUrl || MOONSHOT_BASE_URL,
});
const baseUrl = regionChoice;
// Model selection
const currentModelLabel = existingModel
? `Keep current (moonshot/${existingModel})`
: `Use default (moonshot/${DEFAULT_KIMI_SEARCH_MODEL})`;
const modelChoice = await ctx.prompter.select<string>({
message: "Kimi web search model",
options: [
{
value: "__keep__",
label: currentModelLabel,
},
{
value: "__custom__",
label: "Enter model manually",
},
{
value: DEFAULT_KIMI_SEARCH_MODEL,
label: `moonshot/${DEFAULT_KIMI_SEARCH_MODEL}`,
},
],
initialValue: "__keep__",
});
let model: string;
if (modelChoice === "__keep__") {
model = existingModel || DEFAULT_KIMI_SEARCH_MODEL;
} else if (modelChoice === "__custom__") {
const customModel = await ctx.prompter.text({
message: "Kimi model name",
initialValue: existingModel || DEFAULT_KIMI_SEARCH_MODEL,
placeholder: DEFAULT_KIMI_SEARCH_MODEL,
});
model = customModel?.trim() || DEFAULT_KIMI_SEARCH_MODEL;
} else {
model = modelChoice;
}
// Write baseUrl and model into plugins.entries.moonshot.config.webSearch
const next = { ...ctx.config };
setProviderWebSearchPluginConfigValue(next, "moonshot", "baseUrl", baseUrl);
setProviderWebSearchPluginConfigValue(next, "moonshot", "model", model);
return next;
}
export const __testing = {
resolveKimiApiKey,
resolveKimiModel,
resolveKimiBaseUrl,
extractKimiCitations,
extractKimiToolResultContent,
} as const;

View File

@@ -1,7 +1,7 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
import { withEnv } from "openclaw/plugin-sdk/testing";
import { describe, expect, it } from "vitest";
import { __testing } from "./kimi-web-search-provider.js";
import { __testing } from "../test-api.js";
const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_");

View File

@@ -1,437 +1,33 @@
import { Type } from "@sinclair/typebox";
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
import {
buildSearchCacheKey,
buildUnsupportedSearchFilterResponse,
DEFAULT_SEARCH_COUNT,
getScopedCredentialValue,
MAX_SEARCH_COUNT,
mergeScopedSearchConfig,
readCachedSearchPayload,
readConfiguredSecretString,
readNumberParam,
readProviderEnvValue,
readStringParam,
resolveProviderWebSearchPluginConfig,
resolveSearchCacheTtlMs,
resolveSearchCount,
resolveSearchTimeoutSeconds,
setScopedCredentialValue,
setProviderWebSearchPluginConfigValue,
type SearchConfigRecord,
createWebSearchProviderContractFields,
type WebSearchProviderPlugin,
type WebSearchProviderSetupContext,
type WebSearchProviderToolDefinition,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
isNativeMoonshotBaseUrl,
MOONSHOT_BASE_URL,
MOONSHOT_CN_BASE_URL,
MOONSHOT_DEFAULT_MODEL_ID,
} from "../provider-catalog.js";
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
const DEFAULT_KIMI_BASE_URL = MOONSHOT_BASE_URL;
const DEFAULT_KIMI_SEARCH_MODEL = MOONSHOT_DEFAULT_MODEL_ID;
/** Models that require explicit thinking disablement for web search.
* Reasoning variants (kimi-k2-thinking, kimi-k2-thinking-turbo) are excluded
* because they default to thinking-enabled and disabling it would defeat their
* purpose; they are also unlikely to be used for web search. */
const KIMI_THINKING_MODELS = new Set(["kimi-k2.5"]);
const KIMI_WEB_SEARCH_TOOL = {
type: "builtin_function",
function: { name: "$web_search" },
} as const;
type KimiConfig = {
apiKey?: string;
baseUrl?: string;
model?: string;
};
type KimiToolCall = {
id?: string;
type?: string;
function?: {
name?: string;
arguments?: string;
};
};
type KimiMessage = {
role?: string;
content?: string;
reasoning_content?: string;
tool_calls?: KimiToolCall[];
};
type KimiSearchResponse = {
choices?: Array<{
finish_reason?: string;
message?: KimiMessage;
}>;
search_results?: Array<{
title?: string;
url?: string;
content?: string;
}>;
};
function resolveKimiConfig(searchConfig?: SearchConfigRecord): KimiConfig {
const kimi = searchConfig?.kimi;
return kimi && typeof kimi === "object" && !Array.isArray(kimi) ? (kimi as KimiConfig) : {};
}
function resolveKimiApiKey(kimi?: KimiConfig): string | undefined {
return (
readConfiguredSecretString(kimi?.apiKey, "tools.web.search.kimi.apiKey") ??
readProviderEnvValue(["KIMI_API_KEY", "MOONSHOT_API_KEY"])
);
}
function resolveKimiModel(kimi?: KimiConfig): string {
const model = normalizeOptionalString(kimi?.model) ?? "";
return model || DEFAULT_KIMI_SEARCH_MODEL;
}
function trimTrailingSlashes(url: string): string {
return url.replace(/\/+$/, "");
}
function resolveKimiBaseUrl(kimi?: KimiConfig, openClawConfig?: OpenClawConfig): string {
const explicitBaseUrl = normalizeOptionalString(kimi?.baseUrl) ?? "";
if (explicitBaseUrl) {
return trimTrailingSlashes(explicitBaseUrl) || DEFAULT_KIMI_BASE_URL;
}
const moonshotBaseUrl = openClawConfig?.models?.providers?.moonshot?.baseUrl;
if (typeof moonshotBaseUrl === "string") {
const normalizedMoonshotBaseUrl = trimTrailingSlashes(moonshotBaseUrl.trim());
if (normalizedMoonshotBaseUrl && isNativeMoonshotBaseUrl(normalizedMoonshotBaseUrl)) {
return normalizedMoonshotBaseUrl;
}
}
return DEFAULT_KIMI_BASE_URL;
}
function extractKimiMessageText(message: KimiMessage | undefined): string | undefined {
const content = message?.content?.trim();
if (content) {
return content;
}
const reasoning = message?.reasoning_content?.trim();
return reasoning || undefined;
}
function extractKimiCitations(data: KimiSearchResponse): string[] {
const citations = (data.search_results ?? [])
.map((entry) => entry.url?.trim())
.filter((url): url is string => Boolean(url));
for (const toolCall of data.choices?.[0]?.message?.tool_calls ?? []) {
const rawArguments = toolCall.function?.arguments;
if (!rawArguments) {
continue;
}
try {
const parsed = JSON.parse(rawArguments) as {
search_results?: Array<{ url?: string }>;
url?: string;
};
const parsedUrl = normalizeOptionalString(parsed.url);
if (parsedUrl) {
citations.push(parsedUrl);
}
for (const result of parsed.search_results ?? []) {
const resultUrl = normalizeOptionalString(result.url);
if (resultUrl) {
citations.push(resultUrl);
}
}
} catch {
// ignore malformed tool arguments
}
}
return [...new Set(citations)];
}
function extractKimiToolResultContent(toolCall: KimiToolCall): string | undefined {
const rawArguments = toolCall.function?.arguments;
if (typeof rawArguments !== "string" || rawArguments.trim().length === 0) {
return undefined;
}
return rawArguments;
}
async function runKimiSearch(params: {
query: string;
apiKey: string;
baseUrl: string;
model: string;
timeoutSeconds: number;
}): Promise<{ content: string; citations: string[] }> {
const endpoint = `${params.baseUrl.trim().replace(/\/$/, "")}/chat/completions`;
const messages: Array<Record<string, unknown>> = [{ role: "user", content: params.query }];
const collectedCitations = new Set<string>();
for (let round = 0; round < 3; round += 1) {
const next = await withTrustedWebSearchEndpoint(
{
url: endpoint,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${params.apiKey}`,
},
body: JSON.stringify({
model: params.model,
...(KIMI_THINKING_MODELS.has(params.model) ? { thinking: { type: "disabled" } } : {}),
messages,
tools: [KIMI_WEB_SEARCH_TOOL],
}),
},
},
async (
res,
): Promise<{ done: true; content: string; citations: string[] } | { done: false }> => {
if (!res.ok) {
const detail = await res.text();
throw new Error(`Kimi API error (${res.status}): ${detail || res.statusText}`);
}
const data = (await res.json()) as KimiSearchResponse;
for (const citation of extractKimiCitations(data)) {
collectedCitations.add(citation);
}
const choice = data.choices?.[0];
const message = choice?.message;
const text = extractKimiMessageText(message);
const toolCalls = message?.tool_calls ?? [];
if (choice?.finish_reason !== "tool_calls" || toolCalls.length === 0) {
return { done: true, content: text ?? "No response", citations: [...collectedCitations] };
}
messages.push({
role: "assistant",
content: message?.content ?? "",
...(message?.reasoning_content ? { reasoning_content: message.reasoning_content } : {}),
tool_calls: toolCalls,
});
let pushed = false;
for (const toolCall of toolCalls) {
const toolCallId = toolCall.id?.trim();
const toolCallName = toolCall.function?.name?.trim();
const toolContent = extractKimiToolResultContent(toolCall);
if (!toolCallId || !toolCallName || !toolContent) {
continue;
}
pushed = true;
messages.push({
role: "tool",
tool_call_id: toolCallId,
name: toolCallName,
content: toolContent,
});
}
if (!pushed) {
return { done: true, content: text ?? "No response", citations: [...collectedCitations] };
}
return { done: false };
},
);
if (next.done) {
return { content: next.content, citations: next.citations };
}
}
return {
content: "Search completed but no final answer was produced.",
citations: [...collectedCitations],
};
}
function createKimiSchema() {
return Type.Object({
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Number({
description: "Number of results to return (1-10).",
minimum: 1,
maximum: MAX_SEARCH_COUNT,
}),
),
country: Type.Optional(Type.String({ description: "Not supported by Kimi." })),
language: Type.Optional(Type.String({ description: "Not supported by Kimi." })),
freshness: Type.Optional(Type.String({ description: "Not supported by Kimi." })),
date_after: Type.Optional(Type.String({ description: "Not supported by Kimi." })),
date_before: Type.Optional(Type.String({ description: "Not supported by Kimi." })),
});
}
function createKimiToolDefinition(
searchConfig: SearchConfigRecord | undefined,
openClawConfig: OpenClawConfig | undefined,
): WebSearchProviderToolDefinition {
return {
description:
"Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search.",
parameters: createKimiSchema(),
execute: async (args) => {
const params = args;
const unsupportedResponse = buildUnsupportedSearchFilterResponse(params, "kimi");
if (unsupportedResponse) {
return unsupportedResponse;
}
const kimiConfig = resolveKimiConfig(searchConfig);
const apiKey = resolveKimiApiKey(kimiConfig);
if (!apiKey) {
return {
error: "missing_kimi_api_key",
message:
"web_search (kimi) needs a Moonshot API key. Set KIMI_API_KEY or MOONSHOT_API_KEY in the Gateway environment, or configure tools.web.search.kimi.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const query = readStringParam(params, "query", { required: true });
const count =
readNumberParam(params, "count", { integer: true }) ??
searchConfig?.maxResults ??
undefined;
const model = resolveKimiModel(kimiConfig);
const baseUrl = resolveKimiBaseUrl(kimiConfig, openClawConfig);
const cacheKey = buildSearchCacheKey([
"kimi",
query,
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
baseUrl,
model,
]);
const cached = readCachedSearchPayload(cacheKey);
if (cached) {
return cached;
}
const start = Date.now();
const result = await runKimiSearch({
query,
apiKey,
baseUrl,
model,
timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig),
});
const payload = {
query,
provider: "kimi",
model,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "kimi",
wrapped: true,
},
content: wrapWebContent(result.content),
citations: result.citations,
};
writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
return payload;
const KIMI_CREDENTIAL_PATH = "plugins.entries.moonshot.config.webSearch.apiKey";
const KimiSearchSchema = {
type: "object",
properties: {
query: { type: "string", description: "Search query string." },
count: {
type: "number",
description: "Number of results to return (1-10).",
minimum: 1,
maximum: 10,
},
};
}
country: { type: "string", description: "Not supported by Kimi." },
language: { type: "string", description: "Not supported by Kimi." },
freshness: { type: "string", description: "Not supported by Kimi." },
date_after: { type: "string", description: "Not supported by Kimi." },
date_before: { type: "string", description: "Not supported by Kimi." },
},
} satisfies Record<string, unknown>;
async function runKimiSearchProviderSetup(
ctx: WebSearchProviderSetupContext,
): Promise<WebSearchProviderSetupContext["config"]> {
const existingPluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "moonshot");
const existingBaseUrl = normalizeOptionalString(existingPluginConfig?.baseUrl) ?? "";
// Normalize trailing slashes so initialValue matches canonical option values.
const normalizedBaseUrl = existingBaseUrl.replace(/\/+$/, "");
const existingModel = normalizeOptionalString(existingPluginConfig?.model) ?? "";
// Region selection (baseUrl)
const isCustomBaseUrl = normalizedBaseUrl && !isNativeMoonshotBaseUrl(normalizedBaseUrl);
const regionOptions: Array<{ value: string; label: string; hint?: string }> = [];
if (isCustomBaseUrl) {
regionOptions.push({
value: normalizedBaseUrl,
label: `Keep current (${normalizedBaseUrl})`,
hint: "custom endpoint",
});
}
regionOptions.push(
{
value: MOONSHOT_BASE_URL,
label: "Moonshot API key (.ai)",
hint: "api.moonshot.ai",
},
{
value: MOONSHOT_CN_BASE_URL,
label: "Moonshot API key (.cn)",
hint: "api.moonshot.cn",
},
);
const regionChoice = await ctx.prompter.select<string>({
message: "Kimi API region",
options: regionOptions,
initialValue: normalizedBaseUrl || MOONSHOT_BASE_URL,
});
const baseUrl = regionChoice;
// Model selection
const currentModelLabel = existingModel
? `Keep current (moonshot/${existingModel})`
: `Use default (moonshot/${DEFAULT_KIMI_SEARCH_MODEL})`;
const modelChoice = await ctx.prompter.select<string>({
message: "Kimi web search model",
options: [
{
value: "__keep__",
label: currentModelLabel,
},
{
value: "__custom__",
label: "Enter model manually",
},
{
value: DEFAULT_KIMI_SEARCH_MODEL,
label: `moonshot/${DEFAULT_KIMI_SEARCH_MODEL}`,
},
],
initialValue: "__keep__",
});
let model: string;
if (modelChoice === "__keep__") {
model = existingModel || DEFAULT_KIMI_SEARCH_MODEL;
} else if (modelChoice === "__custom__") {
const customModel = await ctx.prompter.text({
message: "Kimi model name",
initialValue: existingModel || DEFAULT_KIMI_SEARCH_MODEL,
placeholder: DEFAULT_KIMI_SEARCH_MODEL,
});
model = customModel?.trim() || DEFAULT_KIMI_SEARCH_MODEL;
} else {
model = modelChoice;
}
// Write baseUrl and model into plugins.entries.moonshot.config.webSearch
const next = { ...ctx.config };
setProviderWebSearchPluginConfigValue(next, "moonshot", "baseUrl", baseUrl);
setProviderWebSearchPluginConfigValue(next, "moonshot", "model", model);
return next;
const runtime = await import("./kimi-web-search-provider.runtime.js");
return await runtime.runKimiSearchProviderSetup(ctx);
}
export function createKimiWebSearchProvider(): WebSearchProviderPlugin {
@@ -446,33 +42,22 @@ export function createKimiWebSearchProvider(): WebSearchProviderPlugin {
signupUrl: "https://platform.moonshot.cn/",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 40,
credentialPath: "plugins.entries.moonshot.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.moonshot.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "kimi"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "kimi", value),
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "moonshot")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "moonshot", "apiKey", value);
},
credentialPath: KIMI_CREDENTIAL_PATH,
...createWebSearchProviderContractFields({
credentialPath: KIMI_CREDENTIAL_PATH,
searchCredential: { type: "scoped", scopeId: "kimi" },
configuredCredential: { pluginId: "moonshot" },
}),
runSetup: runKimiSearchProviderSetup,
createTool: (ctx) =>
createKimiToolDefinition(
mergeScopedSearchConfig(
ctx.searchConfig as SearchConfigRecord | undefined,
"kimi",
resolveProviderWebSearchPluginConfig(ctx.config, "moonshot"),
) as SearchConfigRecord | undefined,
ctx.config,
),
createTool: (ctx) => ({
description:
"Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search.",
parameters: KimiSearchSchema,
execute: async (args) => {
const { executeKimiWebSearchProviderTool } =
await import("./kimi-web-search-provider.runtime.js");
return await executeKimiWebSearchProviderTool(ctx, args);
},
}),
};
}
export const __testing = {
resolveKimiApiKey,
resolveKimiModel,
resolveKimiBaseUrl,
extractKimiCitations,
extractKimiToolResultContent,
} as const;

View File

@@ -1,2 +1,2 @@
export { __testing } from "./src/kimi-web-search-provider.js";
export { __testing } from "./src/kimi-web-search-provider.runtime.js";
export { moonshotMediaUnderstandingProvider } from "./media-understanding-provider.js";

View File

@@ -10,6 +10,7 @@ export {
OPENAI_DEFAULT_TTS_VOICE,
} from "./default-models.js";
export { buildOpenAICodexProvider } from "./openai-codex-catalog.js";
export { buildOpenAICodexProviderPlugin } from "./openai-codex-provider.js";
export { buildOpenAIProvider } from "./openai-provider.js";
export { buildOpenAIRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js";
export { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js";

View File

@@ -96,6 +96,49 @@ describe("readOpenAICodexCliOAuthProfile", () => {
expect(parsed).toBeNull();
});
it("allows the runtime-only Codex CLI profile when the stored default already matches", () => {
const accessToken = buildJwt({
exp: Math.floor(Date.now() / 1000) + 600,
"https://api.openai.com/profile": {
email: "codex@example.com",
},
});
vi.spyOn(fs, "readFileSync").mockReturnValue(
JSON.stringify({
auth_mode: "chatgpt",
tokens: {
access_token: accessToken,
refresh_token: "refresh-token",
account_id: "acct_123",
},
}),
);
const firstParse = readOpenAICodexCliOAuthProfile({
store: { version: 1, profiles: {} },
});
expect(firstParse).not.toBeNull();
const parsed = readOpenAICodexCliOAuthProfile({
store: {
version: 1,
profiles: {
[OPENAI_CODEX_DEFAULT_PROFILE_ID]: firstParse!.credential,
},
},
});
expect(parsed).toMatchObject({
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
credential: {
access: accessToken,
refresh: "refresh-token",
accountId: "acct_123",
email: "codex@example.com",
},
});
});
it("returns null without logging when the Codex CLI auth file is missing", () => {
const error = Object.assign(new Error("missing"), {
code: "ENOENT",

View File

@@ -67,7 +67,6 @@ function oauthCredentialMatches(a: OAuthCredential, b: OAuthCredential): boolean
a.provider === b.provider &&
a.access === b.access &&
a.refresh === b.refresh &&
a.expires === b.expires &&
a.clientId === b.clientId &&
a.email === b.email &&
a.displayName === b.displayName &&

View File

@@ -241,8 +241,9 @@ describeLive("openai plugin live", () => {
});
const text = (transcription?.text ?? "").toLowerCase();
const collapsedText = text.replace(/[\s-]+/g, "");
expect(text.length).toBeGreaterThan(0);
expect(text).toContain("openclaw");
expect(collapsedText).toContain("openclaw");
expect(text).toMatch(/\bok\b/);
}, 45_000);

View File

@@ -0,0 +1,54 @@
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
const noopAuth = async () => ({ profiles: [] });
export function createOpenAICodexProvider(): ProviderPlugin {
return {
id: "openai-codex",
label: "OpenAI Codex",
docsPath: "/providers/models",
auth: [
{
id: "oauth",
kind: "oauth",
label: "ChatGPT OAuth",
hint: "Browser sign-in",
run: noopAuth,
},
],
wizard: {
setup: {
choiceId: "openai-codex",
choiceLabel: "OpenAI Codex (ChatGPT OAuth)",
choiceHint: "Browser sign-in",
methodId: "oauth",
},
},
};
}
export function createOpenAIProvider(): ProviderPlugin {
return {
id: "openai",
label: "OpenAI",
hookAliases: ["azure-openai", "azure-openai-responses"],
docsPath: "/providers/models",
envVars: ["OPENAI_API_KEY"],
auth: [
{
id: "api-key",
kind: "api_key",
label: "OpenAI API key",
hint: "Direct OpenAI API key",
run: noopAuth,
wizard: {
choiceId: "openai-api-key",
choiceLabel: "OpenAI API key",
groupId: "openai",
groupLabel: "OpenAI",
groupHint: "Codex OAuth + API key",
},
},
],
};
}

View File

@@ -0,0 +1,26 @@
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
export function createOpenrouterProvider(): ProviderPlugin {
return {
id: "openrouter",
label: "OpenRouter",
docsPath: "/providers/models",
envVars: ["OPENROUTER_API_KEY"],
auth: [
{
id: "api-key",
kind: "api_key",
label: "OpenRouter API key",
hint: "API key",
run: async () => ({ profiles: [] }),
wizard: {
choiceId: "openrouter-api-key",
choiceLabel: "OpenRouter API key",
groupId: "openrouter",
groupLabel: "OpenRouter",
groupHint: "API key",
},
},
],
};
}

View File

@@ -0,0 +1,539 @@
import {
readNumberParam,
readStringArrayParam,
readStringParam,
} from "openclaw/plugin-sdk/provider-web-search";
import {
buildSearchCacheKey,
DEFAULT_SEARCH_COUNT,
isoToPerplexityDate,
normalizeFreshness,
normalizeToIsoDate,
readCachedSearchPayload,
readConfiguredSecretString,
readProviderEnvValue,
resolveSearchCacheTtlMs,
resolveSearchCount,
resolveSearchTimeoutSeconds,
resolveSiteName,
throwWebSearchApiError,
type SearchConfigRecord,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
DEFAULT_PERPLEXITY_BASE_URL,
inferPerplexityBaseUrlFromApiKey,
isDirectPerplexityBaseUrl,
PERPLEXITY_DIRECT_BASE_URL,
type PerplexityTransport,
} from "./perplexity-web-search-provider.shared.js";
const PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search";
const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro";
type PerplexityConfig = {
apiKey?: string;
baseUrl?: string;
model?: string;
};
type PerplexitySearchResponse = {
choices?: Array<{
message?: {
content?: string;
annotations?: Array<{
type?: string;
url?: string;
url_citation?: {
url?: string;
};
}>;
};
}>;
citations?: string[];
};
type PerplexitySearchApiResponse = {
results?: Array<{
title?: string;
url?: string;
snippet?: string;
date?: string;
}>;
};
function resolvePerplexityConfig(searchConfig?: SearchConfigRecord): PerplexityConfig {
const perplexity = searchConfig?.perplexity;
return perplexity && typeof perplexity === "object" && !Array.isArray(perplexity)
? (perplexity as PerplexityConfig)
: {};
}
function resolvePerplexityApiKey(perplexity?: PerplexityConfig): {
apiKey?: string;
source: "config" | "perplexity_env" | "openrouter_env" | "none";
} {
const fromConfig = readConfiguredSecretString(
perplexity?.apiKey,
"tools.web.search.perplexity.apiKey",
);
if (fromConfig) {
return { apiKey: fromConfig, source: "config" };
}
const fromPerplexityEnv = readProviderEnvValue(["PERPLEXITY_API_KEY"]);
if (fromPerplexityEnv) {
return { apiKey: fromPerplexityEnv, source: "perplexity_env" };
}
const fromOpenRouterEnv = readProviderEnvValue(["OPENROUTER_API_KEY"]);
if (fromOpenRouterEnv) {
return { apiKey: fromOpenRouterEnv, source: "openrouter_env" };
}
return { apiKey: undefined, source: "none" };
}
function resolvePerplexityBaseUrl(
perplexity?: PerplexityConfig,
authSource: "config" | "perplexity_env" | "openrouter_env" | "none" = "none",
configuredKey?: string,
): string {
const fromConfig = normalizeOptionalString(perplexity?.baseUrl) ?? "";
if (fromConfig) {
return fromConfig;
}
if (authSource === "perplexity_env") {
return PERPLEXITY_DIRECT_BASE_URL;
}
if (authSource === "openrouter_env") {
return DEFAULT_PERPLEXITY_BASE_URL;
}
if (authSource === "config") {
return inferPerplexityBaseUrlFromApiKey(configuredKey) === "openrouter"
? DEFAULT_PERPLEXITY_BASE_URL
: PERPLEXITY_DIRECT_BASE_URL;
}
return DEFAULT_PERPLEXITY_BASE_URL;
}
function resolvePerplexityModel(perplexity?: PerplexityConfig): string {
const model = normalizeOptionalString(perplexity?.model) ?? "";
return model || DEFAULT_PERPLEXITY_MODEL;
}
function resolvePerplexityRequestModel(baseUrl: string, model: string): string {
if (!isDirectPerplexityBaseUrl(baseUrl)) {
return model;
}
return model.startsWith("perplexity/") ? model.slice("perplexity/".length) : model;
}
function resolvePerplexityTransport(perplexity?: PerplexityConfig): {
apiKey?: string;
source: "config" | "perplexity_env" | "openrouter_env" | "none";
baseUrl: string;
model: string;
transport: PerplexityTransport;
} {
const auth = resolvePerplexityApiKey(perplexity);
const baseUrl = resolvePerplexityBaseUrl(perplexity, auth.source, auth.apiKey);
const model = resolvePerplexityModel(perplexity);
const hasLegacyOverride = Boolean(
normalizeOptionalString(perplexity?.baseUrl) || normalizeOptionalString(perplexity?.model),
);
return {
...auth,
baseUrl,
model,
transport:
hasLegacyOverride || !isDirectPerplexityBaseUrl(baseUrl) ? "chat_completions" : "search_api",
};
}
function extractPerplexityCitations(data: PerplexitySearchResponse): string[] {
const topLevel = (data.citations ?? []).filter((url): url is string =>
Boolean(normalizeOptionalString(url)),
);
if (topLevel.length > 0) {
return [...new Set(topLevel)];
}
const citations: string[] = [];
for (const choice of data.choices ?? []) {
for (const annotation of choice.message?.annotations ?? []) {
if (annotation.type !== "url_citation") {
continue;
}
const url =
typeof annotation.url_citation?.url === "string"
? annotation.url_citation.url
: typeof annotation.url === "string"
? annotation.url
: undefined;
const normalizedUrl = normalizeOptionalString(url);
if (normalizedUrl) {
citations.push(normalizedUrl);
}
}
}
return [...new Set(citations)];
}
async function runPerplexitySearchApi(params: {
query: string;
apiKey: string;
count: number;
timeoutSeconds: number;
country?: string;
searchDomainFilter?: string[];
searchRecencyFilter?: string;
searchLanguageFilter?: string[];
searchAfterDate?: string;
searchBeforeDate?: string;
maxTokens?: number;
maxTokensPerPage?: number;
}): Promise<Array<Record<string, unknown>>> {
const body: Record<string, unknown> = {
query: params.query,
max_results: params.count,
};
if (params.country) {
body.country = params.country;
}
if (params.searchDomainFilter?.length) {
body.search_domain_filter = params.searchDomainFilter;
}
if (params.searchRecencyFilter) {
body.search_recency_filter = params.searchRecencyFilter;
}
if (params.searchLanguageFilter?.length) {
body.search_language_filter = params.searchLanguageFilter;
}
if (params.searchAfterDate) {
body.search_after_date = params.searchAfterDate;
}
if (params.searchBeforeDate) {
body.search_before_date = params.searchBeforeDate;
}
if (params.maxTokens !== undefined) {
body.max_tokens = params.maxTokens;
}
if (params.maxTokensPerPage !== undefined) {
body.max_tokens_per_page = params.maxTokensPerPage;
}
return withTrustedWebSearchEndpoint(
{
url: PERPLEXITY_SEARCH_ENDPOINT,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${params.apiKey}`,
"HTTP-Referer": "https://openclaw.ai",
"X-Title": "OpenClaw Web Search",
},
body: JSON.stringify(body),
},
},
async (res) => {
if (!res.ok) {
return await throwWebSearchApiError(res, "Perplexity Search");
}
const data = (await res.json()) as PerplexitySearchApiResponse;
return (data.results ?? []).map((entry) => ({
title: entry.title ? wrapWebContent(entry.title, "web_search") : "",
url: entry.url ?? "",
description: entry.snippet ? wrapWebContent(entry.snippet, "web_search") : "",
published: entry.date ?? undefined,
siteName: resolveSiteName(entry.url) || undefined,
}));
},
);
}
async function runPerplexitySearch(params: {
query: string;
apiKey: string;
baseUrl: string;
model: string;
timeoutSeconds: number;
freshness?: string;
}): Promise<{ content: string; citations: string[] }> {
const endpoint = `${params.baseUrl.trim().replace(/\/$/, "")}/chat/completions`;
const body: Record<string, unknown> = {
model: resolvePerplexityRequestModel(params.baseUrl, params.model),
messages: [{ role: "user", content: params.query }],
};
if (params.freshness) {
body.search_recency_filter = params.freshness;
}
return withTrustedWebSearchEndpoint(
{
url: endpoint,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${params.apiKey}`,
"HTTP-Referer": "https://openclaw.ai",
"X-Title": "OpenClaw Web Search",
},
body: JSON.stringify(body),
},
},
async (res) => {
if (!res.ok) {
return await throwWebSearchApiError(res, "Perplexity");
}
const data = (await res.json()) as PerplexitySearchResponse;
return {
content: data.choices?.[0]?.message?.content ?? "No response",
citations: extractPerplexityCitations(data),
};
},
);
}
export async function executePerplexitySearch(
args: Record<string, unknown>,
searchConfig?: SearchConfigRecord,
): Promise<Record<string, unknown>> {
const perplexityConfig = resolvePerplexityConfig(searchConfig);
const runtime = resolvePerplexityTransport(perplexityConfig);
if (!runtime.apiKey) {
return {
error: "missing_perplexity_api_key",
message:
"web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const query = readStringParam(args, "query", { required: true });
const count =
readNumberParam(args, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined;
const rawFreshness = readStringParam(args, "freshness");
const freshness = rawFreshness ? normalizeFreshness(rawFreshness, "perplexity") : undefined;
if (rawFreshness && !freshness) {
return {
error: "invalid_freshness",
message: "freshness must be day, week, month, or year.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const structured = runtime.transport === "search_api";
const country = readStringParam(args, "country");
const language = readStringParam(args, "language");
const rawDateAfter = readStringParam(args, "date_after");
const rawDateBefore = readStringParam(args, "date_before");
const domainFilter = readStringArrayParam(args, "domain_filter");
const maxTokens = readNumberParam(args, "max_tokens", { integer: true });
const maxTokensPerPage = readNumberParam(args, "max_tokens_per_page", { integer: true });
if (!structured) {
if (country) {
return {
error: "unsupported_country",
message:
"country filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (language) {
return {
error: "unsupported_language",
message:
"language filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (rawDateAfter || rawDateBefore) {
return {
error: "unsupported_date_filter",
message:
"date_after/date_before are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (domainFilter?.length) {
return {
error: "unsupported_domain_filter",
message:
"domain_filter is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (maxTokens !== undefined || maxTokensPerPage !== undefined) {
return {
error: "unsupported_content_budget",
message:
"max_tokens and max_tokens_per_page are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
}
if (language && !/^[a-z]{2}$/iu.test(language)) {
return {
error: "invalid_language",
message: "language must be a 2-letter ISO 639-1 code like 'en', 'de', or 'fr'.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (rawFreshness && (rawDateAfter || rawDateBefore)) {
return {
error: "conflicting_time_filters",
message:
"freshness and date_after/date_before cannot be used together. Use either freshness (day/week/month/year) or a date range (date_after/date_before), not both.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined;
const dateBefore = rawDateBefore ? normalizeToIsoDate(rawDateBefore) : undefined;
if (rawDateAfter && !dateAfter) {
return {
error: "invalid_date",
message: "date_after must be YYYY-MM-DD format.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (rawDateBefore && !dateBefore) {
return {
error: "invalid_date",
message: "date_before must be YYYY-MM-DD format.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (dateAfter && dateBefore && dateAfter > dateBefore) {
return {
error: "invalid_date_range",
message: "date_after must be before date_before.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (domainFilter?.length) {
const hasDeny = domainFilter.some((entry) => entry.startsWith("-"));
const hasAllow = domainFilter.some((entry) => !entry.startsWith("-"));
if (hasDeny && hasAllow) {
return {
error: "invalid_domain_filter",
message:
"domain_filter cannot mix allowlist and denylist entries. Use either all positive entries (allowlist) or all entries prefixed with '-' (denylist).",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (domainFilter.length > 20) {
return {
error: "invalid_domain_filter",
message: "domain_filter supports a maximum of 20 domains.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
}
const cacheKey = buildSearchCacheKey([
"perplexity",
runtime.transport,
runtime.baseUrl,
runtime.model,
query,
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
country,
language,
freshness,
dateAfter,
dateBefore,
domainFilter?.join(","),
maxTokens,
maxTokensPerPage,
]);
const cached = readCachedSearchPayload(cacheKey);
if (cached) {
return cached;
}
const start = Date.now();
const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
const payload =
runtime.transport === "chat_completions"
? {
query,
provider: "perplexity",
model: runtime.model,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "perplexity",
wrapped: true,
},
...(await (async () => {
const result = await runPerplexitySearch({
query,
apiKey: runtime.apiKey!,
baseUrl: runtime.baseUrl,
model: runtime.model,
timeoutSeconds,
freshness,
});
return {
content: wrapWebContent(result.content, "web_search"),
citations: result.citations,
};
})()),
}
: {
query,
provider: "perplexity",
count: 0,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "perplexity",
wrapped: true,
},
results: await runPerplexitySearchApi({
query,
apiKey: runtime.apiKey,
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
timeoutSeconds,
country: country ?? undefined,
searchDomainFilter: domainFilter,
searchRecencyFilter: freshness,
searchLanguageFilter: language ? [language] : undefined,
searchAfterDate: dateAfter ? isoToPerplexityDate(dateAfter) : undefined,
searchBeforeDate: dateBefore ? isoToPerplexityDate(dateBefore) : undefined,
maxTokens: maxTokens ?? undefined,
maxTokensPerPage: maxTokensPerPage ?? undefined,
}),
};
if (Array.isArray((payload as { results?: unknown[] }).results)) {
(payload as { count: number }).count = (payload as { results: unknown[] }).results.length;
(payload as { tookMs: number }).tookMs = Date.now() - start;
} else {
(payload as { tookMs: number }).tookMs = Date.now() - start;
}
writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
return payload;
}
export const __testing = {
inferPerplexityBaseUrlFromApiKey,
resolvePerplexityBaseUrl,
resolvePerplexityModel,
resolvePerplexityTransport,
isDirectPerplexityBaseUrl,
resolvePerplexityRequestModel,
resolvePerplexityApiKey,
normalizeToIsoDate,
isoToPerplexityDate,
} as const;

View File

@@ -0,0 +1,82 @@
export const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1";
export const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai";
const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
export type PerplexityTransport = "search_api" | "chat_completions";
export type PerplexityBaseUrlHint = "direct" | "openrouter";
export type PerplexityRuntimeTransportContext = {
searchConfig?: Record<string, unknown>;
resolvedKey?: string;
keySource: "config" | "secretRef" | "env" | "missing";
fallbackEnvVar?: string;
};
function trimToUndefined(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return trimToUndefined(value)?.toLowerCase() ?? "";
}
export function inferPerplexityBaseUrlFromApiKey(
apiKey?: string,
): PerplexityBaseUrlHint | undefined {
if (!apiKey) {
return undefined;
}
const normalized = normalizeLowercaseStringOrEmpty(apiKey);
if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return "direct";
}
if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return "openrouter";
}
return undefined;
}
export function isDirectPerplexityBaseUrl(baseUrl: string): boolean {
try {
return (
normalizeLowercaseStringOrEmpty(new URL(baseUrl.trim()).hostname) === "api.perplexity.ai"
);
} catch {
return false;
}
}
export function resolvePerplexityRuntimeTransport(
params: PerplexityRuntimeTransportContext,
): PerplexityTransport | undefined {
const perplexity = params.searchConfig?.perplexity;
const scoped =
perplexity && typeof perplexity === "object" && !Array.isArray(perplexity)
? (perplexity as { baseUrl?: string; model?: string })
: undefined;
const configuredBaseUrl = trimToUndefined(scoped?.baseUrl) ?? "";
const configuredModel = trimToUndefined(scoped?.model) ?? "";
const baseUrl = (() => {
if (configuredBaseUrl) {
return configuredBaseUrl;
}
if (params.keySource === "env") {
if (params.fallbackEnvVar === "PERPLEXITY_API_KEY") {
return PERPLEXITY_DIRECT_BASE_URL;
}
if (params.fallbackEnvVar === "OPENROUTER_API_KEY") {
return DEFAULT_PERPLEXITY_BASE_URL;
}
}
if ((params.keySource === "config" || params.keySource === "secretRef") && params.resolvedKey) {
return inferPerplexityBaseUrlFromApiKey(params.resolvedKey) === "openrouter"
? DEFAULT_PERPLEXITY_BASE_URL
: PERPLEXITY_DIRECT_BASE_URL;
}
return DEFAULT_PERPLEXITY_BASE_URL;
})();
return configuredBaseUrl || configuredModel || !isDirectPerplexityBaseUrl(baseUrl)
? "chat_completions"
: "search_api";
}

View File

@@ -1,6 +1,6 @@
import { withEnv } from "openclaw/plugin-sdk/testing";
import { describe, expect, it } from "vitest";
import { __testing } from "./perplexity-web-search-provider.js";
import { __testing } from "./perplexity-web-search-provider.runtime.js";
const openRouterApiKeyEnv = ["OPENROUTER_API", "KEY"].join("_");
const perplexityApiKeyEnv = ["PERPLEXITY_API", "KEY"].join("_");

View File

@@ -1,675 +1,103 @@
import { Type } from "@sinclair/typebox";
import {
readNumberParam,
readStringArrayParam,
readStringParam,
} from "openclaw/plugin-sdk/provider-web-search";
import {
buildSearchCacheKey,
DEFAULT_SEARCH_COUNT,
getScopedCredentialValue,
MAX_SEARCH_COUNT,
isoToPerplexityDate,
createWebSearchProviderContractFields,
mergeScopedSearchConfig,
normalizeFreshness,
normalizeToIsoDate,
readCachedSearchPayload,
readConfiguredSecretString,
readProviderEnvValue,
resolveProviderWebSearchPluginConfig,
resolveSearchCacheTtlMs,
resolveSearchCount,
resolveSearchTimeoutSeconds,
resolveSiteName,
setScopedCredentialValue,
setProviderWebSearchPluginConfigValue,
throwWebSearchApiError,
type SearchConfigRecord,
type WebSearchCredentialResolutionSource,
type WebSearchProviderPlugin,
type WebSearchProviderToolDefinition,
withTrustedWebSearchEndpoint,
wrapWebContent,
writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
import { resolvePerplexityRuntimeTransport } from "./perplexity-web-search-provider.shared.js";
const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1";
const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai";
const PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search";
const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro";
const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
const PERPLEXITY_CREDENTIAL_PATH = "plugins.entries.perplexity.config.webSearch.apiKey";
type PerplexityConfig = {
apiKey?: string;
baseUrl?: string;
model?: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
type PerplexityTransport = "search_api" | "chat_completions";
type PerplexityBaseUrlHint = "direct" | "openrouter";
function createPerplexityParameters(transport?: string): Record<string, unknown> {
const properties: Record<string, unknown> = {
query: { type: "string", description: "Search query string." },
count: {
type: "number",
description: "Number of results to return (1-10).",
minimum: 1,
maximum: 10,
},
freshness: {
type: "string",
description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.",
},
};
type PerplexitySearchResponse = {
choices?: Array<{
message?: {
content?: string;
annotations?: Array<{
type?: string;
url?: string;
url_citation?: {
url?: string;
};
}>;
if (transport !== "chat_completions") {
properties.country = {
type: "string",
description: "Native Perplexity Search API only. 2-letter country code.",
};
}>;
citations?: string[];
};
properties.language = {
type: "string",
description: "Native Perplexity Search API only. ISO 639-1 language code.",
};
properties.date_after = {
type: "string",
description:
"Native Perplexity Search API only. Only results published after this date (YYYY-MM-DD).",
};
properties.date_before = {
type: "string",
description:
"Native Perplexity Search API only. Only results published before this date (YYYY-MM-DD).",
};
properties.domain_filter = {
type: "array",
items: { type: "string" },
description: "Native Perplexity Search API only. Domain filter (max 20).",
};
properties.max_tokens = {
type: "number",
description: "Native Perplexity Search API only. Total content budget across all results.",
minimum: 1,
maximum: 1000000,
};
properties.max_tokens_per_page = {
type: "number",
description: "Native Perplexity Search API only. Max tokens extracted per page.",
minimum: 1,
};
}
type PerplexitySearchApiResponse = {
results?: Array<{
title?: string;
url?: string;
snippet?: string;
date?: string;
}>;
};
function resolvePerplexityConfig(searchConfig?: SearchConfigRecord): PerplexityConfig {
const perplexity = searchConfig?.perplexity;
return perplexity && typeof perplexity === "object" && !Array.isArray(perplexity)
? (perplexity as PerplexityConfig)
: {};
}
function inferPerplexityBaseUrlFromApiKey(apiKey?: string): PerplexityBaseUrlHint | undefined {
if (!apiKey) {
return undefined;
}
const normalized = normalizeLowercaseStringOrEmpty(apiKey);
if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return "direct";
}
if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return "openrouter";
}
return undefined;
}
function resolvePerplexityApiKey(perplexity?: PerplexityConfig): {
apiKey?: string;
source: "config" | "perplexity_env" | "openrouter_env" | "none";
} {
const fromConfig = readConfiguredSecretString(
perplexity?.apiKey,
"tools.web.search.perplexity.apiKey",
);
if (fromConfig) {
return { apiKey: fromConfig, source: "config" };
}
const fromPerplexityEnv = readProviderEnvValue(["PERPLEXITY_API_KEY"]);
if (fromPerplexityEnv) {
return { apiKey: fromPerplexityEnv, source: "perplexity_env" };
}
const fromOpenRouterEnv = readProviderEnvValue(["OPENROUTER_API_KEY"]);
if (fromOpenRouterEnv) {
return { apiKey: fromOpenRouterEnv, source: "openrouter_env" };
}
return { apiKey: undefined, source: "none" };
}
function resolvePerplexityBaseUrl(
perplexity?: PerplexityConfig,
authSource: "config" | "perplexity_env" | "openrouter_env" | "none" = "none",
configuredKey?: string,
): string {
const fromConfig = normalizeOptionalString(perplexity?.baseUrl) ?? "";
if (fromConfig) {
return fromConfig;
}
if (authSource === "perplexity_env") {
return PERPLEXITY_DIRECT_BASE_URL;
}
if (authSource === "openrouter_env") {
return DEFAULT_PERPLEXITY_BASE_URL;
}
if (authSource === "config") {
return inferPerplexityBaseUrlFromApiKey(configuredKey) === "openrouter"
? DEFAULT_PERPLEXITY_BASE_URL
: PERPLEXITY_DIRECT_BASE_URL;
}
return DEFAULT_PERPLEXITY_BASE_URL;
}
function resolvePerplexityModel(perplexity?: PerplexityConfig): string {
const model = normalizeOptionalString(perplexity?.model) ?? "";
return model || DEFAULT_PERPLEXITY_MODEL;
}
function isDirectPerplexityBaseUrl(baseUrl: string): boolean {
try {
return (
normalizeLowercaseStringOrEmpty(new URL(baseUrl.trim()).hostname) === "api.perplexity.ai"
);
} catch {
return false;
}
}
function resolvePerplexityRequestModel(baseUrl: string, model: string): string {
if (!isDirectPerplexityBaseUrl(baseUrl)) {
return model;
}
return model.startsWith("perplexity/") ? model.slice("perplexity/".length) : model;
}
function resolvePerplexityTransport(perplexity?: PerplexityConfig): {
apiKey?: string;
source: "config" | "perplexity_env" | "openrouter_env" | "none";
baseUrl: string;
model: string;
transport: PerplexityTransport;
} {
const auth = resolvePerplexityApiKey(perplexity);
const baseUrl = resolvePerplexityBaseUrl(perplexity, auth.source, auth.apiKey);
const model = resolvePerplexityModel(perplexity);
const hasLegacyOverride = Boolean(
normalizeOptionalString(perplexity?.baseUrl) || normalizeOptionalString(perplexity?.model),
);
return {
...auth,
baseUrl,
model,
transport:
hasLegacyOverride || !isDirectPerplexityBaseUrl(baseUrl) ? "chat_completions" : "search_api",
type: "object",
properties,
required: ["query"],
};
}
function extractPerplexityCitations(data: PerplexitySearchResponse): string[] {
const topLevel = (data.citations ?? []).filter((url): url is string =>
Boolean(normalizeOptionalString(url)),
function hasPerplexityLegacyOverride(searchConfig?: Record<string, unknown>): boolean {
const perplexity = isRecord(searchConfig?.perplexity) ? searchConfig.perplexity : undefined;
return (
(typeof perplexity?.baseUrl === "string" && perplexity.baseUrl.trim().length > 0) ||
(typeof perplexity?.model === "string" && perplexity.model.trim().length > 0)
);
if (topLevel.length > 0) {
return [...new Set(topLevel)];
}
const citations: string[] = [];
for (const choice of data.choices ?? []) {
for (const annotation of choice.message?.annotations ?? []) {
if (annotation.type !== "url_citation") {
continue;
}
const url =
typeof annotation.url_citation?.url === "string"
? annotation.url_citation.url
: typeof annotation.url === "string"
? annotation.url
: undefined;
const normalizedUrl = normalizeOptionalString(url);
if (normalizedUrl) {
citations.push(normalizedUrl);
}
}
}
return [...new Set(citations)];
}
async function runPerplexitySearchApi(params: {
query: string;
apiKey: string;
count: number;
timeoutSeconds: number;
country?: string;
searchDomainFilter?: string[];
searchRecencyFilter?: string;
searchLanguageFilter?: string[];
searchAfterDate?: string;
searchBeforeDate?: string;
maxTokens?: number;
maxTokensPerPage?: number;
}): Promise<Array<Record<string, unknown>>> {
const body: Record<string, unknown> = {
query: params.query,
max_results: params.count,
};
if (params.country) {
body.country = params.country;
}
if (params.searchDomainFilter?.length) {
body.search_domain_filter = params.searchDomainFilter;
}
if (params.searchRecencyFilter) {
body.search_recency_filter = params.searchRecencyFilter;
}
if (params.searchLanguageFilter?.length) {
body.search_language_filter = params.searchLanguageFilter;
}
if (params.searchAfterDate) {
body.search_after_date = params.searchAfterDate;
}
if (params.searchBeforeDate) {
body.search_before_date = params.searchBeforeDate;
}
if (params.maxTokens !== undefined) {
body.max_tokens = params.maxTokens;
}
if (params.maxTokensPerPage !== undefined) {
body.max_tokens_per_page = params.maxTokensPerPage;
}
return withTrustedWebSearchEndpoint(
{
url: PERPLEXITY_SEARCH_ENDPOINT,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${params.apiKey}`,
"HTTP-Referer": "https://openclaw.ai",
"X-Title": "OpenClaw Web Search",
},
body: JSON.stringify(body),
},
},
async (res) => {
if (!res.ok) {
return await throwWebSearchApiError(res, "Perplexity Search");
}
const data = (await res.json()) as PerplexitySearchApiResponse;
return (data.results ?? []).map((entry) => ({
title: entry.title ? wrapWebContent(entry.title, "web_search") : "",
url: entry.url ?? "",
description: entry.snippet ? wrapWebContent(entry.snippet, "web_search") : "",
published: entry.date ?? undefined,
siteName: resolveSiteName(entry.url) || undefined,
}));
},
);
}
async function runPerplexitySearch(params: {
query: string;
apiKey: string;
baseUrl: string;
model: string;
timeoutSeconds: number;
freshness?: string;
}): Promise<{ content: string; citations: string[] }> {
const endpoint = `${params.baseUrl.trim().replace(/\/$/, "")}/chat/completions`;
const body: Record<string, unknown> = {
model: resolvePerplexityRequestModel(params.baseUrl, params.model),
messages: [{ role: "user", content: params.query }],
};
if (params.freshness) {
body.search_recency_filter = params.freshness;
}
return withTrustedWebSearchEndpoint(
{
url: endpoint,
timeoutSeconds: params.timeoutSeconds,
init: {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${params.apiKey}`,
"HTTP-Referer": "https://openclaw.ai",
"X-Title": "OpenClaw Web Search",
},
body: JSON.stringify(body),
},
},
async (res) => {
if (!res.ok) {
return await throwWebSearchApiError(res, "Perplexity");
}
const data = (await res.json()) as PerplexitySearchResponse;
return {
content: data.choices?.[0]?.message?.content ?? "No response",
citations: extractPerplexityCitations(data),
};
},
);
}
function resolveRuntimeTransport(params: {
searchConfig?: Record<string, unknown>;
resolvedKey?: string;
keySource: WebSearchCredentialResolutionSource;
fallbackEnvVar?: string;
}): PerplexityTransport | undefined {
const perplexity = params.searchConfig?.perplexity;
const scoped =
perplexity && typeof perplexity === "object" && !Array.isArray(perplexity)
? (perplexity as { baseUrl?: string; model?: string })
: undefined;
const configuredBaseUrl = normalizeOptionalString(scoped?.baseUrl) ?? "";
const configuredModel = normalizeOptionalString(scoped?.model) ?? "";
const baseUrl = (() => {
if (configuredBaseUrl) {
return configuredBaseUrl;
}
if (params.keySource === "env") {
if (params.fallbackEnvVar === "PERPLEXITY_API_KEY") {
return PERPLEXITY_DIRECT_BASE_URL;
}
if (params.fallbackEnvVar === "OPENROUTER_API_KEY") {
return DEFAULT_PERPLEXITY_BASE_URL;
}
}
if ((params.keySource === "config" || params.keySource === "secretRef") && params.resolvedKey) {
return inferPerplexityBaseUrlFromApiKey(params.resolvedKey) === "openrouter"
? DEFAULT_PERPLEXITY_BASE_URL
: PERPLEXITY_DIRECT_BASE_URL;
}
return DEFAULT_PERPLEXITY_BASE_URL;
})();
return configuredBaseUrl || configuredModel || !isDirectPerplexityBaseUrl(baseUrl)
? "chat_completions"
: "search_api";
}
function createPerplexitySchema(transport?: PerplexityTransport) {
const querySchema = {
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Number({
description: "Number of results to return (1-10).",
minimum: 1,
maximum: MAX_SEARCH_COUNT,
}),
),
freshness: Type.Optional(
Type.String({ description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'." }),
),
};
if (transport === "chat_completions") {
return Type.Object(querySchema);
}
return Type.Object({
...querySchema,
country: Type.Optional(
Type.String({ description: "Native Perplexity Search API only. 2-letter country code." }),
),
language: Type.Optional(
Type.String({ description: "Native Perplexity Search API only. ISO 639-1 language code." }),
),
date_after: Type.Optional(
Type.String({
description:
"Native Perplexity Search API only. Only results published after this date (YYYY-MM-DD).",
}),
),
date_before: Type.Optional(
Type.String({
description:
"Native Perplexity Search API only. Only results published before this date (YYYY-MM-DD).",
}),
),
domain_filter: Type.Optional(
Type.Array(Type.String(), {
description: "Native Perplexity Search API only. Domain filter (max 20).",
}),
),
max_tokens: Type.Optional(
Type.Number({
description: "Native Perplexity Search API only. Total content budget across all results.",
minimum: 1,
maximum: 1000000,
}),
),
max_tokens_per_page: Type.Optional(
Type.Number({
description: "Native Perplexity Search API only. Max tokens extracted per page.",
minimum: 1,
}),
),
});
}
function createPerplexityToolDefinition(
searchConfig?: SearchConfigRecord,
runtimeTransport?: PerplexityTransport,
searchConfig?: Record<string, unknown>,
runtimeTransport?: string,
): WebSearchProviderToolDefinition {
const perplexityConfig = resolvePerplexityConfig(searchConfig);
const schemaTransport =
runtimeTransport ??
(perplexityConfig.baseUrl || perplexityConfig.model ? "chat_completions" : undefined);
(hasPerplexityLegacyOverride(searchConfig) ? "chat_completions" : undefined);
return {
description:
schemaTransport === "chat_completions"
? "Search the web using Perplexity Sonar via Perplexity/OpenRouter chat completions. Returns AI-synthesized answers with citations from web-grounded search."
: "Search the web using Perplexity. Runtime routing decides between native Search API and Sonar chat-completions compatibility. Structured filters are available on the native Search API path.",
parameters: createPerplexitySchema(schemaTransport),
parameters: createPerplexityParameters(schemaTransport),
execute: async (args) => {
const runtime = resolvePerplexityTransport(perplexityConfig);
if (!runtime.apiKey) {
return {
error: "missing_perplexity_api_key",
message:
"web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const params = args;
const query = readStringParam(params, "query", { required: true });
const count =
readNumberParam(params, "count", { integer: true }) ??
searchConfig?.maxResults ??
undefined;
const rawFreshness = readStringParam(params, "freshness");
const freshness = rawFreshness ? normalizeFreshness(rawFreshness, "perplexity") : undefined;
if (rawFreshness && !freshness) {
return {
error: "invalid_freshness",
message: "freshness must be day, week, month, or year.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const structured = runtime.transport === "search_api";
const country = readStringParam(params, "country");
const language = readStringParam(params, "language");
const rawDateAfter = readStringParam(params, "date_after");
const rawDateBefore = readStringParam(params, "date_before");
const domainFilter = readStringArrayParam(params, "domain_filter");
const maxTokens = readNumberParam(params, "max_tokens", { integer: true });
const maxTokensPerPage = readNumberParam(params, "max_tokens_per_page", { integer: true });
if (!structured) {
if (country) {
return {
error: "unsupported_country",
message:
"country filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (language) {
return {
error: "unsupported_language",
message:
"language filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (rawDateAfter || rawDateBefore) {
return {
error: "unsupported_date_filter",
message:
"date_after/date_before are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (domainFilter?.length) {
return {
error: "unsupported_domain_filter",
message:
"domain_filter is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (maxTokens !== undefined || maxTokensPerPage !== undefined) {
return {
error: "unsupported_content_budget",
message:
"max_tokens and max_tokens_per_page are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
}
if (language && !/^[a-z]{2}$/i.test(language)) {
return {
error: "invalid_language",
message: "language must be a 2-letter ISO 639-1 code like 'en', 'de', or 'fr'.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (rawFreshness && (rawDateAfter || rawDateBefore)) {
return {
error: "conflicting_time_filters",
message:
"freshness and date_after/date_before cannot be used together. Use either freshness (day/week/month/year) or a date range (date_after/date_before), not both.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined;
const dateBefore = rawDateBefore ? normalizeToIsoDate(rawDateBefore) : undefined;
if (rawDateAfter && !dateAfter) {
return {
error: "invalid_date",
message: "date_after must be YYYY-MM-DD format.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (rawDateBefore && !dateBefore) {
return {
error: "invalid_date",
message: "date_before must be YYYY-MM-DD format.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (dateAfter && dateBefore && dateAfter > dateBefore) {
return {
error: "invalid_date_range",
message: "date_after must be before date_before.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (domainFilter?.length) {
const hasDeny = domainFilter.some((entry) => entry.startsWith("-"));
const hasAllow = domainFilter.some((entry) => !entry.startsWith("-"));
if (hasDeny && hasAllow) {
return {
error: "invalid_domain_filter",
message:
"domain_filter cannot mix allowlist and denylist entries. Use either all positive entries (allowlist) or all entries prefixed with '-' (denylist).",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (domainFilter.length > 20) {
return {
error: "invalid_domain_filter",
message: "domain_filter supports a maximum of 20 domains.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
}
const cacheKey = buildSearchCacheKey([
"perplexity",
runtime.transport,
runtime.baseUrl,
runtime.model,
query,
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
country,
language,
freshness,
dateAfter,
dateBefore,
domainFilter?.join(","),
maxTokens,
maxTokensPerPage,
]);
const cached = readCachedSearchPayload(cacheKey);
if (cached) {
return cached;
}
const start = Date.now();
const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
const payload =
runtime.transport === "chat_completions"
? {
query,
provider: "perplexity",
model: runtime.model,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "perplexity",
wrapped: true,
},
...(await (async () => {
const result = await runPerplexitySearch({
query,
apiKey: runtime.apiKey!,
baseUrl: runtime.baseUrl,
model: runtime.model,
timeoutSeconds,
freshness,
});
return {
content: wrapWebContent(result.content, "web_search"),
citations: result.citations,
};
})()),
}
: {
query,
provider: "perplexity",
count: 0,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: "perplexity",
wrapped: true,
},
results: await runPerplexitySearchApi({
query,
apiKey: runtime.apiKey,
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
timeoutSeconds,
country: country ?? undefined,
searchDomainFilter: domainFilter,
searchRecencyFilter: freshness,
searchLanguageFilter: language ? [language] : undefined,
searchAfterDate: dateAfter ? isoToPerplexityDate(dateAfter) : undefined,
searchBeforeDate: dateBefore ? isoToPerplexityDate(dateBefore) : undefined,
maxTokens: maxTokens ?? undefined,
maxTokensPerPage: maxTokensPerPage ?? undefined,
}),
};
if (Array.isArray((payload as { results?: unknown[] }).results)) {
(payload as { count: number }).count = (payload as { results: unknown[] }).results.length;
(payload as { tookMs: number }).tookMs = Date.now() - start;
} else {
(payload as { tookMs: number }).tookMs = Date.now() - start;
}
writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
return payload;
const { executePerplexitySearch } =
await import("./perplexity-web-search-provider.runtime.js");
return await executePerplexitySearch(args, searchConfig);
},
};
}
@@ -686,18 +114,14 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
signupUrl: "https://www.perplexity.ai/settings/api",
docsUrl: "https://docs.openclaw.ai/perplexity",
autoDetectOrder: 50,
credentialPath: "plugins.entries.perplexity.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.perplexity.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "perplexity"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "perplexity", value),
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "perplexity")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "perplexity", "apiKey", value);
},
credentialPath: PERPLEXITY_CREDENTIAL_PATH,
...createWebSearchProviderContractFields({
credentialPath: PERPLEXITY_CREDENTIAL_PATH,
searchCredential: { type: "scoped", scopeId: "perplexity" },
configuredCredential: { pluginId: "perplexity" },
}),
resolveRuntimeMetadata: (ctx) => ({
perplexityTransport: resolveRuntimeTransport({
perplexityTransport: resolvePerplexityRuntimeTransport({
searchConfig: mergeScopedSearchConfig(
ctx.searchConfig,
"perplexity",
@@ -719,15 +143,3 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
),
};
}
export const __testing = {
inferPerplexityBaseUrlFromApiKey,
resolvePerplexityBaseUrl,
resolvePerplexityModel,
resolvePerplexityTransport,
isDirectPerplexityBaseUrl,
resolvePerplexityRequestModel,
resolvePerplexityApiKey,
normalizeToIsoDate,
isoToPerplexityDate,
} as const;

View File

@@ -1 +1 @@
export { __testing } from "./src/perplexity-web-search-provider.js";
export { __testing } from "./src/perplexity-web-search-provider.runtime.js";

View File

@@ -1,7 +1,10 @@
import {
createWebSearchProviderContractFields,
mergeScopedSearchConfig,
resolveProviderWebSearchPluginConfig,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
import { resolvePerplexityRuntimeTransport } from "./src/perplexity-web-search-provider.shared.js";
export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
const credentialPath = "plugins.entries.perplexity.config.webSearch.apiKey";
@@ -23,6 +26,18 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
searchCredential: { type: "scoped", scopeId: "perplexity" },
configuredCredential: { pluginId: "perplexity" },
}),
resolveRuntimeMetadata: (ctx) => ({
perplexityTransport: resolvePerplexityRuntimeTransport({
searchConfig: mergeScopedSearchConfig(
ctx.searchConfig,
"perplexity",
resolveProviderWebSearchPluginConfig(ctx.config, "perplexity"),
),
resolvedKey: ctx.resolvedCredential?.value,
keySource: ctx.resolvedCredential?.source ?? "missing",
fallbackEnvVar: ctx.resolvedCredential?.fallbackEnvVar,
}),
}),
createTool: () => null,
};
}

View File

@@ -1,4 +1 @@
export {
__testing,
createPerplexityWebSearchProvider,
} from "./src/perplexity-web-search-provider.js";
export { createPerplexityWebSearchProvider } from "./src/perplexity-web-search-provider.js";

View File

@@ -72,6 +72,7 @@ import {
runQaDockerScaffoldCommand,
runQaDockerUpCommand,
runQaCharacterEvalCommand,
runQaCoverageReportCommand,
runQaManualLaneCommand,
runQaParityReportCommand,
runQaSuiteCommand,
@@ -336,6 +337,13 @@ describe("qa cli runtime", () => {
}
});
it("prints a markdown coverage report from scenario metadata", async () => {
await runQaCoverageReportCommand({ repoRoot: process.cwd() });
expect(stdoutWrite).toHaveBeenCalledWith(expect.stringContaining("# QA Coverage Inventory"));
expect(stdoutWrite).toHaveBeenCalledWith(expect.stringContaining("memory.recall"));
});
it("resolves character eval paths and passes model refs through", async () => {
await runQaCharacterEvalCommand({
repoRoot: "/tmp/openclaw-repo",

View File

@@ -9,6 +9,7 @@ import {
import { resolveQaParityPackScenarioIds } from "./agentic-parity.js";
import { runQaCharacterEval, type QaCharacterModelOptions } from "./character-eval.js";
import { resolveRepoRelativeOutputDir } from "./cli-paths.js";
import { buildQaCoverageInventory, renderQaCoverageMarkdownReport } from "./coverage-report.js";
import { buildQaDockerHarnessImage, writeQaDockerHarnessFiles } from "./docker-harness.js";
import { runQaDockerUp } from "./docker-up.runtime.js";
import type { QaCliBackendAuthMode } from "./gateway-child.js";
@@ -36,6 +37,7 @@ import {
type QaProviderMode,
type QaProviderModeInput,
} from "./run-config.js";
import { readQaScenarioPack } from "./scenario-catalog.js";
import { runQaSuiteFromRuntime } from "./suite-launch.runtime.js";
type InterruptibleServer = {
@@ -442,6 +444,29 @@ export async function runQaParityReportCommand(opts: {
process.exitCode = 1;
}
}
export async function runQaCoverageReportCommand(opts: {
repoRoot?: string;
output?: string;
json?: boolean;
}) {
const repoRoot = path.resolve(opts.repoRoot ?? process.cwd());
const inventory = buildQaCoverageInventory(readQaScenarioPack().scenarios);
const outputPath = opts.output ? path.resolve(repoRoot, opts.output) : undefined;
const body = opts.json
? `${JSON.stringify(inventory, null, 2)}\n`
: renderQaCoverageMarkdownReport(inventory);
if (outputPath) {
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, body, "utf8");
process.stdout.write(`QA coverage report: ${outputPath}\n`);
return;
}
process.stdout.write(body);
}
export async function runQaCharacterEvalCommand(opts: {
repoRoot?: string;
outputDir?: string;

View File

@@ -44,12 +44,14 @@ const {
runQaCredentialsAddCommand,
runQaCredentialsListCommand,
runQaCredentialsRemoveCommand,
runQaCoverageReportCommand,
runQaProviderServerCommand,
runQaTelegramCommand,
} = vi.hoisted(() => ({
runQaCredentialsAddCommand: vi.fn(),
runQaCredentialsListCommand: vi.fn(),
runQaCredentialsRemoveCommand: vi.fn(),
runQaCoverageReportCommand: vi.fn(),
runQaProviderServerCommand: vi.fn(),
runQaTelegramCommand: vi.fn(),
}));
@@ -72,6 +74,7 @@ vi.mock("./cli.runtime.js", () => ({
runQaCredentialsAddCommand,
runQaCredentialsListCommand,
runQaCredentialsRemoveCommand,
runQaCoverageReportCommand,
runQaProviderServerCommand,
}));
@@ -85,6 +88,7 @@ describe("qa cli registration", () => {
runQaCredentialsAddCommand.mockReset();
runQaCredentialsListCommand.mockReset();
runQaCredentialsRemoveCommand.mockReset();
runQaCoverageReportCommand.mockReset();
runQaProviderServerCommand.mockReset();
runQaTelegramCommand.mockReset();
listQaRunnerCliContributions
@@ -101,10 +105,30 @@ describe("qa cli registration", () => {
const qa = program.commands.find((command) => command.name() === "qa");
expect(qa).toBeDefined();
expect(qa?.commands.map((command) => command.name())).toEqual(
expect.arrayContaining([TEST_QA_RUNNER.commandName, "telegram", "credentials"]),
expect.arrayContaining([TEST_QA_RUNNER.commandName, "telegram", "credentials", "coverage"]),
);
});
it("routes coverage report flags into the qa runtime command", async () => {
await program.parseAsync([
"node",
"openclaw",
"qa",
"coverage",
"--repo-root",
"/tmp/openclaw-repo",
"--output",
".artifacts/qa-coverage.md",
"--json",
]);
expect(runQaCoverageReportCommand).toHaveBeenCalledWith({
repoRoot: "/tmp/openclaw-repo",
output: ".artifacts/qa-coverage.md",
json: true,
});
});
it("delegates discovered qa runner registration through the generic host seam", () => {
const [{ registration }] = listQaRunnerCliContributions.mock.results[0]?.value;
expect(registration.register).toHaveBeenCalledTimes(1);

View File

@@ -60,6 +60,12 @@ async function runQaParityReport(opts: {
const runtime = await loadQaLabCliRuntime();
await runtime.runQaParityReportCommand(opts);
}
async function runQaCoverageReport(opts: { repoRoot?: string; output?: string; json?: boolean }) {
const runtime = await loadQaLabCliRuntime();
await runtime.runQaCoverageReportCommand(opts);
}
async function runQaCharacterEval(opts: {
repoRoot?: string;
outputDir?: string;
@@ -302,6 +308,15 @@ export function registerQaLabCli(program: Command) {
},
);
qa.command("coverage")
.description("Print the markdown scenario coverage inventory")
.option("--repo-root <path>", "Repository root to target when writing --output")
.option("--output <path>", "Write the coverage inventory to this path")
.option("--json", "Print JSON instead of Markdown", false)
.action(async (opts: { repoRoot?: string; output?: string; json?: boolean }) => {
await runQaCoverageReport(opts);
});
qa.command("character-eval")
.description("Run the character QA scenario across live models and write a judged report")
.option("--repo-root <path>", "Repository root to target when running from a neutral cwd")

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { buildQaCoverageInventory, renderQaCoverageMarkdownReport } from "./coverage-report.js";
import { readQaScenarioPack } from "./scenario-catalog.js";
describe("qa coverage report", () => {
it("groups scenario coverage metadata by theme and surface", () => {
const inventory = buildQaCoverageInventory(readQaScenarioPack().scenarios);
expect(inventory.scenarioCount).toBeGreaterThan(0);
expect(inventory.coverageIdCount).toBeGreaterThan(0);
expect(inventory.primaryCoverageIdCount).toBeGreaterThan(0);
expect(inventory.secondaryCoverageIdCount).toBeGreaterThan(0);
expect(inventory.overlappingCoverage.length).toBeGreaterThan(0);
expect(inventory.missingCoverage).toEqual([]);
expect(inventory.byTheme.memory.some((feature) => feature.id === "memory.recall")).toBe(true);
expect(inventory.bySurface.memory.some((feature) => feature.id === "memory.recall")).toBe(true);
});
it("renders a compact markdown inventory", () => {
const report = renderQaCoverageMarkdownReport(
buildQaCoverageInventory(readQaScenarioPack().scenarios),
);
expect(report).toContain("# QA Coverage Inventory");
expect(report).toContain("- Missing coverage metadata: 0");
expect(report).toContain("- Overlapping coverage IDs:");
expect(report).toContain("memory.recall");
expect(report).toContain("primary: memory-recall (qa/scenarios/memory/memory-recall.md)");
expect(report).toContain("secondary: active-memory-preprompt-recall");
});
});

View File

@@ -0,0 +1,192 @@
import type { QaSeedScenarioWithSource } from "./scenario-catalog.js";
export type QaCoverageScenarioSummary = {
id: string;
title: string;
sourcePath: string;
theme: string;
surfaces: string[];
risk: string;
};
export type QaCoverageIntent = "primary" | "secondary";
export type QaCoverageScenarioReference = QaCoverageScenarioSummary & {
intent: QaCoverageIntent;
};
export type QaCoverageFeatureSummary = {
id: string;
scenarios: QaCoverageScenarioReference[];
};
export type QaCoverageInventory = {
scenarioCount: number;
coverageIdCount: number;
primaryCoverageIdCount: number;
secondaryCoverageIdCount: number;
features: QaCoverageFeatureSummary[];
overlappingCoverage: QaCoverageFeatureSummary[];
missingCoverage: QaCoverageScenarioSummary[];
byTheme: Record<string, QaCoverageFeatureSummary[]>;
bySurface: Record<string, QaCoverageFeatureSummary[]>;
};
function scenarioTheme(sourcePath: string) {
const parts = sourcePath.split("/");
return parts[2] ?? "unknown";
}
function scenarioSurfaces(scenario: QaSeedScenarioWithSource) {
return scenario.surfaces && scenario.surfaces.length > 0 ? scenario.surfaces : [scenario.surface];
}
function scenarioRisk(scenario: QaSeedScenarioWithSource) {
return scenario.risk ?? scenario.riskLevel ?? "unassigned";
}
function summarizeScenario(scenario: QaSeedScenarioWithSource): QaCoverageScenarioSummary {
return {
id: scenario.id,
title: scenario.title,
sourcePath: scenario.sourcePath,
theme: scenarioTheme(scenario.sourcePath),
surfaces: scenarioSurfaces(scenario),
risk: scenarioRisk(scenario),
};
}
function sortFeatures(features: readonly QaCoverageFeatureSummary[]) {
return features.toSorted((left, right) => left.id.localeCompare(right.id));
}
export function buildQaCoverageInventory(
scenarios: readonly QaSeedScenarioWithSource[],
): QaCoverageInventory {
const byCoverageId = new Map<string, QaCoverageFeatureSummary>();
const primaryCoverageIds = new Set<string>();
const secondaryCoverageIds = new Set<string>();
const missingCoverage: QaCoverageScenarioSummary[] = [];
const addCoverage = (
scenario: QaSeedScenarioWithSource,
coverageIds: readonly string[] | undefined,
intent: QaCoverageIntent,
) => {
const summary = summarizeScenario(scenario);
for (const coverageId of coverageIds ?? []) {
const feature = byCoverageId.get(coverageId) ?? {
id: coverageId,
scenarios: [],
};
feature.scenarios.push({ ...summary, intent });
byCoverageId.set(coverageId, feature);
if (intent === "primary") {
primaryCoverageIds.add(coverageId);
} else {
secondaryCoverageIds.add(coverageId);
}
}
};
for (const scenario of scenarios) {
if (!scenario.coverage) {
missingCoverage.push(summarizeScenario(scenario));
continue;
}
addCoverage(scenario, scenario.coverage.primary, "primary");
addCoverage(scenario, scenario.coverage.secondary, "secondary");
}
const features = sortFeatures([...byCoverageId.values()]);
const overlappingCoverage = features.filter((feature) => feature.scenarios.length > 1);
const byTheme: Record<string, QaCoverageFeatureSummary[]> = {};
const bySurface: Record<string, QaCoverageFeatureSummary[]> = {};
for (const feature of features) {
const themes = new Set(feature.scenarios.map((scenario) => scenario.theme));
for (const theme of themes) {
byTheme[theme] ??= [];
byTheme[theme].push({
...feature,
scenarios: feature.scenarios.filter((scenario) => scenario.theme === theme),
});
}
const surfaces = new Set(feature.scenarios.flatMap((scenario) => scenario.surfaces));
for (const surface of surfaces) {
bySurface[surface] ??= [];
bySurface[surface].push({
...feature,
scenarios: feature.scenarios.filter((scenario) => scenario.surfaces.includes(surface)),
});
}
}
return {
scenarioCount: scenarios.length,
coverageIdCount: features.length,
primaryCoverageIdCount: primaryCoverageIds.size,
secondaryCoverageIdCount: secondaryCoverageIds.size,
features,
overlappingCoverage,
missingCoverage,
byTheme,
bySurface,
};
}
function pushFeatureLines(lines: string[], features: readonly QaCoverageFeatureSummary[]) {
for (const feature of sortFeatures(features)) {
const scenarios = feature.scenarios
.map((scenario) => `${scenario.intent}: ${scenario.id} (${scenario.sourcePath})`)
.join(", ");
lines.push(`- ${feature.id}: ${scenarios}`);
}
}
export function renderQaCoverageMarkdownReport(inventory: QaCoverageInventory): string {
const lines: string[] = [
"# QA Coverage Inventory",
"",
`- Scenarios: ${inventory.scenarioCount}`,
`- Coverage IDs: ${inventory.coverageIdCount}`,
`- Primary coverage IDs: ${inventory.primaryCoverageIdCount}`,
`- Secondary coverage IDs: ${inventory.secondaryCoverageIdCount}`,
`- Overlapping coverage IDs: ${inventory.overlappingCoverage.length}`,
`- Missing coverage metadata: ${inventory.missingCoverage.length}`,
"",
"## By Theme",
"",
];
for (const theme of Object.keys(inventory.byTheme).toSorted()) {
lines.push(`### ${theme}`, "");
pushFeatureLines(lines, inventory.byTheme[theme] ?? []);
lines.push("");
}
lines.push("## By Surface", "");
for (const surface of Object.keys(inventory.bySurface).toSorted()) {
lines.push(`### ${surface}`, "");
pushFeatureLines(lines, inventory.bySurface[surface] ?? []);
lines.push("");
}
if (inventory.overlappingCoverage.length > 0) {
lines.push("## Overlap", "");
pushFeatureLines(lines, inventory.overlappingCoverage);
lines.push("");
}
if (inventory.missingCoverage.length > 0) {
lines.push("## Missing Metadata", "");
for (const scenario of inventory.missingCoverage.toSorted((left, right) =>
left.id.localeCompare(right.id),
)) {
lines.push(`- ${scenario.id}: ${scenario.sourcePath}`);
}
lines.push("");
}
return `${lines.join("\n").trimEnd()}\n`;
}

View File

@@ -268,38 +268,41 @@ describe("buildQaRuntimeEnv", () => {
expect(env.CODEX_HOME).toBe("/custom/codex-home");
});
it("scrubs direct and live provider keys in mock mode", () => {
const env = buildQaRuntimeEnv({
...createParams({
ANTHROPIC_API_KEY: "anthropic-live",
ANTHROPIC_OAUTH_TOKEN: "anthropic-oauth",
GEMINI_API_KEY: "gemini-live",
GEMINI_API_KEYS: "gemini-a gemini-b",
GOOGLE_API_KEY: "google-live",
OPENAI_API_KEY: "openai-live",
OPENAI_API_KEYS: "openai-a,openai-b",
CODEX_HOME: "/host/.codex",
OPENCLAW_LIVE_ANTHROPIC_KEY: "anthropic-live",
OPENCLAW_LIVE_ANTHROPIC_KEYS: "anthropic-a,anthropic-b",
OPENCLAW_LIVE_GEMINI_KEY: "gemini-live",
OPENCLAW_LIVE_OPENAI_KEY: "openai-live",
}),
providerMode: "mock-openai",
});
it.each(["mock-openai", "aimock"] as const)(
"scrubs direct and live provider keys in %s mode",
(providerMode) => {
const env = buildQaRuntimeEnv({
...createParams({
ANTHROPIC_API_KEY: "anthropic-live",
ANTHROPIC_OAUTH_TOKEN: "anthropic-oauth",
GEMINI_API_KEY: "gemini-live",
GEMINI_API_KEYS: "gemini-a gemini-b",
GOOGLE_API_KEY: "google-live",
OPENAI_API_KEY: "openai-live",
OPENAI_API_KEYS: "openai-a,openai-b",
CODEX_HOME: "/host/.codex",
OPENCLAW_LIVE_ANTHROPIC_KEY: "anthropic-live",
OPENCLAW_LIVE_ANTHROPIC_KEYS: "anthropic-a,anthropic-b",
OPENCLAW_LIVE_GEMINI_KEY: "gemini-live",
OPENCLAW_LIVE_OPENAI_KEY: "openai-live",
}),
providerMode,
});
expect(env.OPENAI_API_KEY).toBeUndefined();
expect(env.OPENAI_API_KEYS).toBeUndefined();
expect(env.CODEX_HOME).toBeUndefined();
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
expect(env.ANTHROPIC_OAUTH_TOKEN).toBeUndefined();
expect(env.GEMINI_API_KEY).toBeUndefined();
expect(env.GEMINI_API_KEYS).toBeUndefined();
expect(env.GOOGLE_API_KEY).toBeUndefined();
expect(env.OPENCLAW_LIVE_OPENAI_KEY).toBeUndefined();
expect(env.OPENCLAW_LIVE_ANTHROPIC_KEY).toBeUndefined();
expect(env.OPENCLAW_LIVE_ANTHROPIC_KEYS).toBeUndefined();
expect(env.OPENCLAW_LIVE_GEMINI_KEY).toBeUndefined();
});
expect(env.OPENAI_API_KEY).toBeUndefined();
expect(env.OPENAI_API_KEYS).toBeUndefined();
expect(env.CODEX_HOME).toBeUndefined();
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
expect(env.ANTHROPIC_OAUTH_TOKEN).toBeUndefined();
expect(env.GEMINI_API_KEY).toBeUndefined();
expect(env.GEMINI_API_KEYS).toBeUndefined();
expect(env.GOOGLE_API_KEY).toBeUndefined();
expect(env.OPENCLAW_LIVE_OPENAI_KEY).toBeUndefined();
expect(env.OPENCLAW_LIVE_ANTHROPIC_KEY).toBeUndefined();
expect(env.OPENCLAW_LIVE_ANTHROPIC_KEYS).toBeUndefined();
expect(env.OPENCLAW_LIVE_GEMINI_KEY).toBeUndefined();
},
);
it("treats restart socket closures as retryable gateway call errors", () => {
expect(__testing.isRetryableGatewayCallError("gateway closed (1006 abnormal closure)")).toBe(

View File

@@ -3,9 +3,11 @@ import { createServer } from "node:http";
import os from "node:os";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { startQaLabServer } from "./lab-server.js";
vi.mock("openclaw/plugin-sdk/qa-channel", async () => await import("../../qa-channel/api.js"));
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
@@ -79,6 +81,43 @@ async function waitForFile(filePath: string, timeoutMs = 5_000) {
throw new Error(`file did not appear: ${filePath}`);
}
async function createQaLabRepoRootFixture(params?: {
uiHtml?: string;
models?: Array<{
key: string;
name: string;
input?: string;
available?: boolean;
missing?: boolean;
}>;
}) {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-lab-repo-root-"));
cleanups.push(async () => {
await rm(repoRoot, { recursive: true, force: true });
});
await mkdir(path.join(repoRoot, "dist"), { recursive: true });
await mkdir(path.join(repoRoot, "extensions/qa-lab/web/dist"), { recursive: true });
const models =
params?.models?.map((model) => ({
key: model.key,
name: model.name,
input: model.input ?? model.key,
available: model.available ?? true,
missing: model.missing ?? false,
})) ?? [];
await writeFile(
path.join(repoRoot, "dist/index.js"),
`process.stdout.write(${JSON.stringify(JSON.stringify({ models }))});\n`,
"utf8",
);
await writeFile(
path.join(repoRoot, "extensions/qa-lab/web/dist/index.html"),
params?.uiHtml ?? "<!doctype html><html><body>qa lab fixture</body></html>",
"utf8",
);
return repoRoot;
}
describe("qa-lab server", () => {
it("serves bootstrap state and writes a self-check report", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "qa-lab-test-"));
@@ -86,11 +125,13 @@ describe("qa-lab server", () => {
await rm(tempDir, { recursive: true, force: true });
});
const outputPath = path.join(tempDir, "self-check.md");
const repoRoot = await createQaLabRepoRootFixture();
const lab = await startQaLabServer({
host: "127.0.0.1",
port: 0,
outputPath,
repoRoot,
controlUiUrl: "http://127.0.0.1:18789/",
controlUiToken: "qa-token",
});
@@ -299,32 +340,16 @@ describe("qa-lab server", () => {
});
it("uses the explicit repo root for ui assets and runner model discovery", async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-lab-repo-root-"));
cleanups.push(async () => {
await rm(repoRoot, { recursive: true, force: true });
const repoRoot = await createQaLabRepoRootFixture({
models: [
{
key: "anthropic/qa-temp-model",
name: "QA Temp Model",
},
],
uiHtml:
"<!doctype html><html><head><title>Temp QA Lab UI</title></head><body>repo-root-ui</body></html>",
});
await mkdir(path.join(repoRoot, "dist"), { recursive: true });
await mkdir(path.join(repoRoot, "extensions/qa-lab/web/dist"), { recursive: true });
await writeFile(
path.join(repoRoot, "dist/index.js"),
[
"process.stdout.write(JSON.stringify({",
" models: [{",
' key: "anthropic/qa-temp-model",',
' name: "QA Temp Model",',
' input: "anthropic/qa-temp-model",',
" available: true,",
" missing: false,",
" }],",
"}));",
].join("\n"),
"utf8",
);
await writeFile(
path.join(repoRoot, "extensions/qa-lab/web/dist/index.html"),
"<!doctype html><html><head><title>Temp QA Lab UI</title></head><body>repo-root-ui</body></html>",
"utf8",
);
const lab = await startQaLabServer({
host: "127.0.0.1",

View File

@@ -79,4 +79,32 @@ describe("qa aimock server", () => {
await server.stop();
}
});
it("treats OpenAI Codex model refs as OpenAI-compatible snapshots", async () => {
const server = await startQaAimockServer({
host: "127.0.0.1",
port: 0,
});
try {
const response = await fetch(`${server.baseUrl}/v1/responses`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: "openai-codex/gpt-5.4",
stream: false,
input: [makeResponsesInput("hello codex-compatible aimock")],
}),
});
expect(response.status).toBe(200);
const debug = await fetch(`${server.baseUrl}/debug/last-request`);
expect(debug.status).toBe(200);
expect(await debug.json()).toMatchObject({
model: "openai-codex/gpt-5.4",
providerVariant: "openai",
});
} finally {
await server.stop();
}
});
});

View File

@@ -98,6 +98,18 @@ describe("qa run config", () => {
).toEqual(["dm-chat-baseline", "thread-lifecycle"]);
});
it("keeps idle snapshots on static defaults so startup does not inspect auth profiles", () => {
defaultQaRuntimeModelForMode.mockReturnValue("openai-codex/gpt-5.4");
defaultQaRuntimeModelForMode.mockClear();
expect(createIdleQaRunnerSnapshot(scenarios).selection).toMatchObject({
providerMode: "live-frontier",
primaryModel: "openai/gpt-5.4",
alternateModel: "openai/gpt-5.4",
});
expect(defaultQaRuntimeModelForMode).not.toHaveBeenCalled();
});
it("normalizes aimock selections", () => {
expect(
normalizeQaRunSelection(

View File

@@ -1,4 +1,5 @@
import path from "node:path";
import { defaultQaModelForMode as defaultStaticQaModelForMode } from "./model-selection.js";
import { defaultQaRuntimeModelForMode } from "./model-selection.runtime.js";
import {
DEFAULT_QA_LIVE_PROVIDER_MODE,
@@ -40,12 +41,22 @@ export function defaultQaModelForMode(mode: QaProviderMode, alternate = false) {
return defaultQaRuntimeModelForMode(mode, alternate ? { alternate: true } : undefined);
}
export function createDefaultQaRunSelection(scenarios: QaSeedScenario[]): QaLabRunSelection {
type QaDefaultModelResolver = (mode: QaProviderMode, alternate?: boolean) => string;
function defaultStaticModelForMode(mode: QaProviderMode, alternate = false) {
return defaultStaticQaModelForMode(mode, alternate ? { alternate: true } : undefined);
}
export function createDefaultQaRunSelection(
scenarios: QaSeedScenario[],
options?: { resolveDefaultModel?: QaDefaultModelResolver },
): QaLabRunSelection {
const providerMode: QaProviderMode = DEFAULT_QA_LIVE_PROVIDER_MODE;
const resolveDefaultModel = options?.resolveDefaultModel ?? defaultQaModelForMode;
return {
providerMode,
primaryModel: defaultQaModelForMode(providerMode),
alternateModel: defaultQaModelForMode(providerMode, true),
primaryModel: resolveDefaultModel(providerMode),
alternateModel: resolveDefaultModel(providerMode, true),
fastMode: true,
scenarioIds: scenarios.map((scenario) => scenario.id),
};
@@ -101,7 +112,9 @@ export function normalizeQaRunSelection(
export function createIdleQaRunnerSnapshot(scenarios: QaSeedScenario[]): QaLabRunnerSnapshot {
return {
status: "idle",
selection: createDefaultQaRunSelection(scenarios),
selection: createDefaultQaRunSelection(scenarios, {
resolveDefaultModel: defaultStaticModelForMode,
}),
artifacts: null,
error: null,
};

View File

@@ -27,6 +27,8 @@ describe("qa scenario catalog", () => {
expect(pack.scenarios.some((scenario) => scenario.id === "character-vibes-c3po")).toBe(true);
expect(pack.scenarios.every((scenario) => scenario.execution?.kind === "flow")).toBe(true);
expect(pack.scenarios.some((scenario) => scenario.execution.flow?.steps.length)).toBe(true);
expect(pack.scenarios.every((scenario) => scenario.coverage?.primary.length)).toBe(true);
expect(readQaScenarioById("memory-recall").coverage?.primary).toContain("memory.recall");
});
it("exposes bootstrap data from the markdown pack", () => {

View File

@@ -51,6 +51,44 @@ const qaScenarioExecutionSchema = z.object({
config: qaScenarioConfigSchema.optional(),
});
const qaCoverageIdSchema = z
.string()
.trim()
.regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/, {
message: "coverage ids must use lowercase dotted or dashed tokens",
});
const qaCoverageIdListSchema = z.array(qaCoverageIdSchema).min(1);
const qaScenarioCoverageSchema = z
.object({
primary: qaCoverageIdListSchema,
secondary: qaCoverageIdListSchema.optional(),
})
.superRefine((coverage, ctx) => {
const seen = new Set<string>();
const coverageEntries = [
["primary", coverage.primary],
["secondary", coverage.secondary],
] as const;
for (const [intent, ids] of coverageEntries) {
if (!ids) {
continue;
}
for (const [index, id] of ids.entries()) {
if (!seen.has(id)) {
seen.add(id);
continue;
}
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [intent, index],
message: `duplicate coverage id: ${id}`,
});
}
}
});
const qaScenarioGatewayRuntimeSchema = z.object({
forwardHostHome: z.boolean().optional(),
});
@@ -138,6 +176,9 @@ const qaSeedScenarioSchema = z.object({
title: z.string().trim().min(1),
surface: z.string().trim().min(1),
category: z.string().trim().min(1).optional(),
coverage: qaScenarioCoverageSchema.optional(),
surfaces: z.array(z.string().trim().min(1)).min(1).optional(),
risk: z.enum(["low", "medium", "high"]).optional(),
capabilities: z.array(z.string().trim().min(1)).optional(),
lane: z.record(z.string(), z.union([z.boolean(), z.string()])).optional(),
riskLevel: z.string().trim().min(1).optional(),

View File

@@ -647,6 +647,23 @@ export async function runMatrixQaLive(params: {
`gateway restart done ${scenario.id} ${formatMatrixQaDurationMs(measuredRestart.durationMs)}`,
);
},
restartGatewayWithQueuedMessage: async (queueMessage) => {
if (!gatewayHarness) {
throw new Error("Matrix restart catchup scenario requires a live gateway");
}
writeMatrixQaProgress(`gateway restart+queue start ${scenario.id}`);
const measuredRestart = await measureMatrixQaStep(async () => {
await scenarioGateway.harness.gateway.restart();
await sleep(250);
await queueMessage();
await waitForMatrixChannelReady(scenarioGateway.harness.gateway, sutAccountId);
});
gatewayRestartMs += measuredRestart.durationMs;
scenarioRestartGatewayMs += measuredRestart.durationMs;
writeMatrixQaProgress(
`gateway restart+queue done ${scenario.id} ${formatMatrixQaDurationMs(measuredRestart.durationMs)}`,
);
},
roomId: provisioning.roomId,
sutAccessToken: provisioning.sut.accessToken,
sutDeviceId: provisioning.sut.deviceId,

View File

@@ -40,6 +40,7 @@ export type MatrixQaScenarioId =
| "matrix-reaction-redaction-observed"
| "matrix-restart-resume"
| "matrix-post-restart-room-continue"
| "matrix-initial-catchup-then-incremental"
| "matrix-room-membership-loss"
| "matrix-homeserver-restart-resume"
| "matrix-mention-gating"
@@ -424,6 +425,12 @@ export const MATRIX_QA_SCENARIOS: MatrixQaScenarioDefinition[] = [
title: "Matrix restarted room continues after the first recovered reply",
topology: MATRIX_QA_RESTART_ROOM_TOPOLOGY,
},
{
id: "matrix-initial-catchup-then-incremental",
timeoutMs: 90_000,
title: "Matrix initial catchup is followed by incremental replies",
topology: MATRIX_QA_RESTART_ROOM_TOPOLOGY,
},
{
id: "matrix-room-membership-loss",
timeoutMs: 75_000,

View File

@@ -5,6 +5,13 @@ import {
} from "./scenario-catalog.js";
import {
buildMatrixReplyDetails,
buildMatrixQaToken,
buildMentionPrompt,
buildMatrixReplyArtifact,
isMatrixQaExactMarkerReply,
assertTopLevelReplyArtifact,
advanceMatrixQaActorCursor,
primeMatrixQaDriverScenarioClient,
runAssertedDriverTopLevelScenario,
type MatrixQaScenarioContext,
} from "./scenario-runtime-shared.js";
@@ -107,3 +114,73 @@ export async function runPostRestartRoomContinueScenario(context: MatrixQaScenar
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runInitialCatchupThenIncrementalScenario(context: MatrixQaScenarioContext) {
if (!context.restartGatewayWithQueuedMessage) {
throw new Error(
"Matrix initial catchup scenario requires a queued-message gateway restart callback",
);
}
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_RESTART_ROOM_KEY);
const { client, startSince } = await primeMatrixQaDriverScenarioClient(context);
const catchupToken = buildMatrixQaToken("MATRIX_QA_CATCHUP");
const catchupBody = buildMentionPrompt(context.sutUserId, catchupToken);
let catchupDriverEventId = "";
await context.restartGatewayWithQueuedMessage(async () => {
catchupDriverEventId = await client.sendTextMessage({
body: catchupBody,
mentionUserIds: [context.sutUserId],
roomId,
});
});
const catchupMatched = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
isMatrixQaExactMarkerReply(event, {
roomId,
sutUserId: context.sutUserId,
token: catchupToken,
}) && event.relatesTo === undefined,
roomId,
since: startSince,
timeoutMs: context.timeoutMs,
});
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: context.syncState,
nextSince: catchupMatched.since,
startSince,
});
const catchupReply = buildMatrixReplyArtifact(catchupMatched.event, catchupToken);
assertTopLevelReplyArtifact("catchup reply", catchupReply);
const incremental = await runAssertedDriverTopLevelScenario({
context,
label: "incremental reply after catchup",
roomId,
tokenPrefix: "MATRIX_QA_INCREMENTAL",
});
return {
artifacts: {
catchupDriverEventId,
catchupReply,
catchupToken,
incrementalDriverEventId: incremental.driverEventId,
incrementalReply: incremental.reply,
incrementalToken: incremental.token,
restartSignal: "SIGUSR1",
roomId,
},
details: [
`room id: ${roomId}`,
"restart signal: SIGUSR1",
`catchup driver event: ${catchupDriverEventId}`,
...buildMatrixReplyDetails("catchup reply", catchupReply),
`incremental driver event: ${incremental.driverEventId}`,
...buildMatrixReplyDetails("incremental reply", incremental.reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}

View File

@@ -29,6 +29,7 @@ export type MatrixQaScenarioContext = {
observerUserId: string;
outputDir?: string;
restartGateway?: () => Promise<void>;
restartGatewayWithQueuedMessage?: (queueMessage: () => Promise<void>) => Promise<void>;
roomId: string;
interruptTransport?: () => Promise<void>;
sutAccessToken: string;

View File

@@ -41,6 +41,7 @@ import {
} from "./scenario-runtime-reaction.js";
import {
runHomeserverRestartResumeScenario,
runInitialCatchupThenIncrementalScenario,
runPostRestartRoomContinueScenario,
runRestartResumeScenario,
} from "./scenario-runtime-restart.js";
@@ -229,6 +230,8 @@ export async function runMatrixQaScenario(
return await runRestartResumeScenario(context);
case "matrix-post-restart-room-continue":
return await runPostRestartRoomContinueScenario(context);
case "matrix-initial-catchup-then-incremental":
return await runInitialCatchupThenIncrementalScenario(context);
case "matrix-room-membership-loss":
return await runMembershipLossScenario(context);
case "matrix-homeserver-restart-resume":

View File

@@ -32,6 +32,9 @@ export type MatrixQaScenarioArtifacts = {
attachmentMsgtype?: string;
actorUserId?: string;
blocked?: MatrixQaScenarioArtifacts;
catchupDriverEventId?: string;
catchupReply?: MatrixQaReplyArtifact;
catchupToken?: string;
driverEventId?: string;
editEventId?: string;
editedToken?: string;
@@ -39,6 +42,9 @@ export type MatrixQaScenarioArtifacts = {
firstDriverEventId?: string;
firstReply?: MatrixQaReplyArtifact;
firstToken?: string;
incrementalDriverEventId?: string;
incrementalReply?: MatrixQaReplyArtifact;
incrementalToken?: string;
originalDriverEventId?: string;
originalReply?: MatrixQaReplyArtifact;
originalToken?: string;

View File

@@ -102,6 +102,7 @@ describe("matrix live qa scenarios", () => {
"matrix-reaction-redaction-observed",
"matrix-restart-resume",
"matrix-post-restart-room-continue",
"matrix-initial-catchup-then-incremental",
"matrix-room-membership-loss",
"matrix-homeserver-restart-resume",
"matrix-mention-gating",
@@ -515,6 +516,107 @@ describe("matrix live qa scenarios", () => {
});
});
it("queues a Matrix trigger during restart before proving incremental sync continues", async () => {
const callOrder: string[] = [];
const primeRoom = vi.fn().mockResolvedValue("driver-sync-start");
const sendTextMessage = vi.fn().mockImplementation(async (params) => {
callOrder.push(`send:${String(params.body).includes("CATCHUP") ? "catchup" : "incremental"}`);
return String(params.body).includes("CATCHUP") ? "$catchup-trigger" : "$incremental-trigger";
});
const waitForRoomEvent = vi.fn().mockImplementation(async () => {
const sentBody = String(sendTextMessage.mock.calls.at(-1)?.[0]?.body ?? "");
const token = sentBody.replace("@sut:matrix-qa.test reply with only this exact marker: ", "");
callOrder.push(`wait:${token.includes("CATCHUP") ? "catchup" : "incremental"}`);
return {
event: {
kind: "message",
roomId: "!restart:matrix-qa.test",
eventId: token.includes("CATCHUP") ? "$catchup-reply" : "$incremental-reply",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
body: token,
},
since: token.includes("CATCHUP")
? "driver-sync-after-catchup"
: "driver-sync-after-incremental",
};
});
createMatrixQaClient.mockReturnValue({
primeRoom,
sendTextMessage,
waitForRoomEvent,
});
const scenario = MATRIX_QA_SCENARIOS.find(
(entry) => entry.id === "matrix-initial-catchup-then-incremental",
);
expect(scenario).toBeDefined();
await expect(
runMatrixQaScenario(scenario!, {
baseUrl: "http://127.0.0.1:28008/",
canary: undefined,
driverAccessToken: "driver-token",
driverUserId: "@driver:matrix-qa.test",
observedEvents: [],
observerAccessToken: "observer-token",
observerUserId: "@observer:matrix-qa.test",
restartGatewayWithQueuedMessage: async (queueMessage) => {
callOrder.push("restart");
await queueMessage();
callOrder.push("ready");
},
roomId: "!room:matrix-qa.test",
syncState: {},
sutAccessToken: "sut-token",
sutUserId: "@sut:matrix-qa.test",
timeoutMs: 8_000,
topology: {
defaultRoomId: "!room:matrix-qa.test",
defaultRoomKey: "main",
rooms: [
{
key: "restart",
kind: "group",
memberRoles: ["driver", "observer", "sut"],
memberUserIds: [
"@driver:matrix-qa.test",
"@observer:matrix-qa.test",
"@sut:matrix-qa.test",
],
name: "Restart room",
requireMention: true,
roomId: "!restart:matrix-qa.test",
},
],
},
}),
).resolves.toMatchObject({
artifacts: {
catchupDriverEventId: "$catchup-trigger",
catchupReply: {
eventId: "$catchup-reply",
tokenMatched: true,
},
incrementalDriverEventId: "$incremental-trigger",
incrementalReply: {
eventId: "$incremental-reply",
tokenMatched: true,
},
},
});
expect(callOrder).toEqual([
"restart",
"send:catchup",
"ready",
"wait:catchup",
"send:incremental",
"wait:incremental",
]);
});
it("runs the DM scenario against the provisioned DM room without a mention", async () => {
const primeRoom = vi.fn().mockResolvedValue("driver-sync-start");
const sendTextMessage = vi.fn().mockResolvedValue("$dm-trigger");

View File

@@ -1,40 +1,32 @@
import { Type } from "@sinclair/typebox";
import { readNumberParam, readStringParam } from "openclaw/plugin-sdk/param-readers";
import {
enablePluginInConfig,
getScopedCredentialValue,
readNumberParam,
readStringParam,
resolveProviderWebSearchPluginConfig,
setScopedCredentialValue,
setProviderWebSearchPluginConfigValue,
createWebSearchProviderContractFields,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search";
import { runSearxngSearch } from "./searxng-client.js";
} from "openclaw/plugin-sdk/provider-web-search-contract";
const SearxngSearchSchema = Type.Object(
{
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Number({
description: "Number of results to return (1-10).",
minimum: 1,
maximum: 10,
}),
),
categories: Type.Optional(
Type.String({
description:
"Optional comma-separated search categories such as general, news, or science.",
}),
),
language: Type.Optional(
Type.String({
description: "Optional language code for results such as en, de, or fr.",
}),
),
const SEARXNG_CREDENTIAL_PATH = "plugins.entries.searxng.config.webSearch.baseUrl";
const SearxngSearchSchema = {
type: "object",
properties: {
query: { type: "string", description: "Search query string." },
count: {
type: "number",
description: "Number of results to return (1-10).",
minimum: 1,
maximum: 10,
},
categories: {
type: "string",
description: "Optional comma-separated search categories such as general, news, or science.",
},
language: {
type: "string",
description: "Optional language code for results such as en, de, or fr.",
},
},
{ additionalProperties: false },
);
additionalProperties: false,
} satisfies Record<string, unknown>;
export function createSearxngWebSearchProvider(): WebSearchProviderPlugin {
return {
@@ -48,29 +40,27 @@ export function createSearxngWebSearchProvider(): WebSearchProviderPlugin {
placeholder: "http://localhost:8080",
signupUrl: "https://docs.searxng.org/",
autoDetectOrder: 200,
credentialPath: "plugins.entries.searxng.config.webSearch.baseUrl",
inactiveSecretPaths: ["plugins.entries.searxng.config.webSearch.baseUrl"],
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "searxng"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "searxng", value),
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "searxng")?.baseUrl,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "searxng", "baseUrl", value);
},
applySelectionConfig: (config) => enablePluginInConfig(config, "searxng").config,
credentialPath: SEARXNG_CREDENTIAL_PATH,
...createWebSearchProviderContractFields({
credentialPath: SEARXNG_CREDENTIAL_PATH,
searchCredential: { type: "scoped", scopeId: "searxng" },
configuredCredential: { pluginId: "searxng", field: "baseUrl" },
selectionPluginId: "searxng",
}),
createTool: (ctx) => ({
description:
"Search the web using a self-hosted SearXNG instance. Returns titles, URLs, and snippets.",
parameters: SearxngSearchSchema,
execute: async (args) =>
await runSearxngSearch({
execute: async (args) => {
const { runSearxngSearch } = await import("./searxng-client.js");
return await runSearxngSearch({
config: ctx.config,
query: readStringParam(args, "query", { required: true }),
count: readNumberParam(args, "count", { integer: true }),
categories: readStringParam(args, "categories"),
language: readStringParam(args, "language"),
}),
});
},
}),
};
}

View File

@@ -0,0 +1,4 @@
export {
listSlackDirectoryGroupsFromConfig,
listSlackDirectoryPeersFromConfig,
} from "./src/directory-config.js";

View File

@@ -0,0 +1,2 @@
export { prepareSlackMessage } from "./src/monitor/message-handler/prepare.js";
export { createInboundSlackTestContext } from "./src/monitor/message-handler/prepare.test-helpers.js";

View File

@@ -0,0 +1 @@
export { createSlackOutboundPayloadHarness } from "./src/outbound-payload.test-harness.js";

View File

@@ -2,7 +2,7 @@ import { normalizeAccountId } from "openclaw/plugin-sdk/account-resolution";
import {
createResolvedDirectoryEntriesLister,
type DirectoryConfigParams,
} from "openclaw/plugin-sdk/directory-runtime";
} from "openclaw/plugin-sdk/directory-config-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { mergeSlackAccountConfig, resolveDefaultSlackAccountId } from "./accounts.js";
import { parseSlackTarget } from "./targets.js";

View File

@@ -1,5 +1,27 @@
import { describe, expect, it } from "vitest";
import { setSlackChannelAllowlist } from "./shared.js";
import { createSlackPluginBase, setSlackChannelAllowlist } from "./shared.js";
describe("createSlackPluginBase", () => {
it("owns Slack native command name overrides", () => {
const plugin = createSlackPluginBase({
setup: {} as never,
setupWizard: {} as never,
});
expect(
plugin.commands?.resolveNativeCommandName?.({
commandKey: "status",
defaultName: "status",
}),
).toBe("agentstatus");
expect(
plugin.commands?.resolveNativeCommandName?.({
commandKey: "tts",
defaultName: "tts",
}),
).toBe("tts");
});
});
describe("setSlackChannelAllowlist", () => {
it("writes canonical enabled entries for setup-generated channel allowlists", () => {

View File

@@ -1,27 +1,22 @@
import { Type } from "@sinclair/typebox";
import {
enablePluginInConfig,
getScopedCredentialValue,
resolveProviderWebSearchPluginConfig,
setScopedCredentialValue,
setProviderWebSearchPluginConfigValue,
createWebSearchProviderContractFields,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search";
import { runTavilySearch } from "./tavily-client.js";
} from "openclaw/plugin-sdk/provider-web-search-contract";
const GenericTavilySearchSchema = Type.Object(
{
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Number({
description: "Number of results to return (1-20).",
minimum: 1,
maximum: 20,
}),
),
const TAVILY_CREDENTIAL_PATH = "plugins.entries.tavily.config.webSearch.apiKey";
const GenericTavilySearchSchema = {
type: "object",
properties: {
query: { type: "string", description: "Search query string." },
count: {
type: "number",
description: "Number of results to return (1-20).",
minimum: 1,
maximum: 20,
},
},
{ additionalProperties: false },
);
additionalProperties: false,
} satisfies Record<string, unknown>;
export function createTavilyWebSearchProvider(): WebSearchProviderPlugin {
return {
@@ -35,27 +30,25 @@ export function createTavilyWebSearchProvider(): WebSearchProviderPlugin {
signupUrl: "https://tavily.com/",
docsUrl: "https://docs.openclaw.ai/tools/tavily",
autoDetectOrder: 70,
credentialPath: "plugins.entries.tavily.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.tavily.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "tavily"),
setCredentialValue: (searchConfigTarget, value) =>
setScopedCredentialValue(searchConfigTarget, "tavily", value),
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "tavily")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "tavily", "apiKey", value);
},
applySelectionConfig: (config) => enablePluginInConfig(config, "tavily").config,
credentialPath: TAVILY_CREDENTIAL_PATH,
...createWebSearchProviderContractFields({
credentialPath: TAVILY_CREDENTIAL_PATH,
searchCredential: { type: "scoped", scopeId: "tavily" },
configuredCredential: { pluginId: "tavily" },
selectionPluginId: "tavily",
}),
createTool: (ctx) => ({
description:
"Search the web using Tavily. Returns structured results with snippets. Use tavily_search for Tavily-specific options like search depth, topic filtering, or AI answers.",
parameters: GenericTavilySearchSchema,
execute: async (args) =>
await runTavilySearch({
execute: async (args) => {
const { runTavilySearch } = await import("./tavily-client.js");
return await runTavilySearch({
cfg: ctx.config,
query: typeof args.query === "string" ? args.query : "",
maxResults: typeof args.count === "number" ? args.count : undefined,
}),
});
},
}),
};
}

View File

@@ -0,0 +1,4 @@
export {
listTelegramDirectoryGroupsFromConfig,
listTelegramDirectoryPeersFromConfig,
} from "./src/directory-config.js";

View File

@@ -0,0 +1 @@
export { detectTelegramLegacyStateMigrations } from "./src/state-migrations.js";

Some files were not shown because too many files have changed in this diff Show More