mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-09 23:41:55 +08:00
Compare commits
113 Commits
fix/codex-
...
docs/add-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55300ea850 | ||
|
|
8e5f702adf | ||
|
|
56d2662f9d | ||
|
|
e2ecd0a321 | ||
|
|
7fce53976e | ||
|
|
1cc021251e | ||
|
|
a8ad7e42af | ||
|
|
42320281c6 | ||
|
|
f60168b735 | ||
|
|
0a5701f468 | ||
|
|
07f65838ed | ||
|
|
4d326271f0 | ||
|
|
eb4ff4464e | ||
|
|
cbcf9d0811 | ||
|
|
83a854bfa0 | ||
|
|
3ada30e670 | ||
|
|
c5095153b0 | ||
|
|
68775745d2 | ||
|
|
f399a818ef | ||
|
|
6bd5735519 | ||
|
|
11be305609 | ||
|
|
f6cb77134c | ||
|
|
25d0aa7296 | ||
|
|
dd7470730d | ||
|
|
c70151e873 | ||
|
|
a007bed375 | ||
|
|
fa580e33c1 | ||
|
|
371c53b282 | ||
|
|
cee2f3e8b4 | ||
|
|
2ed644f5d3 | ||
|
|
404b1527e6 | ||
|
|
72ebaf97c3 | ||
|
|
8ab762c005 | ||
|
|
d307a7ca1a | ||
|
|
52bc809143 | ||
|
|
6094035054 | ||
|
|
f493b03202 | ||
|
|
e53d840fed | ||
|
|
f66bd105a4 | ||
|
|
ef2541ceb3 | ||
|
|
8a18e2598f | ||
|
|
749eb4efea | ||
|
|
64d4d9aabb | ||
|
|
e5c06dd64a | ||
|
|
efcca3d2ea | ||
|
|
0b452a5665 | ||
|
|
4c71176c9f | ||
|
|
c5bba6628e | ||
|
|
3b68d3fded | ||
|
|
7856f5730c | ||
|
|
aebfce7a36 | ||
|
|
e19b3679d1 | ||
|
|
d23d36a2f9 | ||
|
|
2ae58542a0 | ||
|
|
55465d86d9 | ||
|
|
615466bdf4 | ||
|
|
6f4de3cc23 | ||
|
|
f19761cefa | ||
|
|
5387faa718 | ||
|
|
bdf9739e59 | ||
|
|
2970d72554 | ||
|
|
74624e619d | ||
|
|
6c9b49a10b | ||
|
|
caf1b84822 | ||
|
|
b6520d7172 | ||
|
|
d4ab731746 | ||
|
|
95dff166cb | ||
|
|
1ec1f0f1f2 | ||
|
|
bce9d93fb5 | ||
|
|
bec3c0b71d | ||
|
|
b41bcb08a2 | ||
|
|
75e1521660 | ||
|
|
79c5c660bb | ||
|
|
fa00b1d0ca | ||
|
|
032778fb2e | ||
|
|
16a5f0b006 | ||
|
|
dc5645d459 | ||
|
|
8d3d742c6a | ||
|
|
87640f9a61 | ||
|
|
b7ad8fd661 | ||
|
|
ca5e352c53 | ||
|
|
c942655451 | ||
|
|
fa83010b17 | ||
|
|
67b2e81360 | ||
|
|
28e46d04e5 | ||
|
|
d9e8e8ac15 | ||
|
|
da3cccb212 | ||
|
|
e8ad80afc7 | ||
|
|
b4c8950417 | ||
|
|
4e2290b841 | ||
|
|
4f482d2a2b | ||
|
|
eba9dcc67a | ||
|
|
27558806b5 | ||
|
|
0af3118d08 | ||
|
|
6ff7e8f42e | ||
|
|
097c588a6b | ||
|
|
2bf53c2cb6 | ||
|
|
e2c07f8a47 | ||
|
|
1a364cd066 | ||
|
|
9ce79bba34 | ||
|
|
047f4acacf | ||
|
|
64760614aa | ||
|
|
76e4b8277f | ||
|
|
6dadfaa18c | ||
|
|
d5b305b250 | ||
|
|
ba2d580c4e | ||
|
|
acac7e3132 | ||
|
|
8a1015f1aa | ||
|
|
38f4ac5e3c | ||
|
|
d91d24e41d | ||
|
|
d2347ed825 | ||
|
|
6e086a5b3b | ||
|
|
c9f2d6b761 |
18
.github/codeql/codeql-javascript-typescript.yml
vendored
Normal file
18
.github/codeql/codeql-javascript-typescript.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
name: openclaw-codeql-javascript-typescript
|
||||
|
||||
paths:
|
||||
- src
|
||||
- extensions
|
||||
- ui/src
|
||||
- skills
|
||||
|
||||
paths-ignore:
|
||||
- apps
|
||||
- dist
|
||||
- docs
|
||||
- "**/node_modules"
|
||||
- "**/coverage"
|
||||
- "**/*.test.ts"
|
||||
- "**/*.test.tsx"
|
||||
- "**/*.e2e.test.ts"
|
||||
- "**/*.e2e.test.tsx"
|
||||
7
.github/pull_request_template.md
vendored
7
.github/pull_request_template.md
vendored
@@ -87,6 +87,13 @@ What you personally verified (not just CI), and how:
|
||||
- Edge cases checked:
|
||||
- What you did **not** verify:
|
||||
|
||||
## Review Conversations
|
||||
|
||||
- [ ] I replied to or resolved every bot review conversation I addressed in this PR.
|
||||
- [ ] I left unresolved only the conversations that still need reviewer or maintainer judgment.
|
||||
|
||||
If a bot review conversation is addressed by this PR, resolve that conversation yourself. Do not leave bot review conversation cleanup for maintainers.
|
||||
|
||||
## Compatibility / Migration
|
||||
|
||||
- Backward compatible? (`Yes/No`)
|
||||
|
||||
6
.github/workflows/auto-response.yml
vendored
6
.github/workflows/auto-response.yml
vendored
@@ -261,6 +261,8 @@ jobs:
|
||||
};
|
||||
|
||||
const triggerLabel = "trigger-response";
|
||||
const activePrLimitLabel = "r: too-many-prs";
|
||||
const activePrLimitOverrideLabel = "r: too-many-prs-override";
|
||||
const target = context.payload.issue ?? context.payload.pull_request;
|
||||
if (!target) {
|
||||
return;
|
||||
@@ -448,6 +450,10 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
if (pullRequest && labelSet.has(activePrLimitOverrideLabel)) {
|
||||
labelSet.delete(activePrLimitLabel);
|
||||
}
|
||||
|
||||
const rule = rules.find((item) => labelSet.has(item.label));
|
||||
if (!rule) {
|
||||
return;
|
||||
|
||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -28,6 +28,7 @@ jobs:
|
||||
needs_swift_tools: false
|
||||
needs_manual_build: false
|
||||
needs_autobuild: false
|
||||
config_file: ./.github/codeql/codeql-javascript-typescript.yml
|
||||
- language: actions
|
||||
runs_on: blacksmith-16vcpu-ubuntu-2404
|
||||
needs_node: false
|
||||
@@ -36,6 +37,7 @@ jobs:
|
||||
needs_swift_tools: false
|
||||
needs_manual_build: false
|
||||
needs_autobuild: false
|
||||
config_file: ""
|
||||
- language: python
|
||||
runs_on: blacksmith-16vcpu-ubuntu-2404
|
||||
needs_node: false
|
||||
@@ -44,6 +46,7 @@ jobs:
|
||||
needs_swift_tools: false
|
||||
needs_manual_build: false
|
||||
needs_autobuild: false
|
||||
config_file: ""
|
||||
- language: java-kotlin
|
||||
runs_on: blacksmith-16vcpu-ubuntu-2404
|
||||
needs_node: false
|
||||
@@ -52,6 +55,7 @@ jobs:
|
||||
needs_swift_tools: false
|
||||
needs_manual_build: true
|
||||
needs_autobuild: false
|
||||
config_file: ""
|
||||
- language: swift
|
||||
runs_on: macos-latest
|
||||
needs_node: false
|
||||
@@ -60,6 +64,7 @@ jobs:
|
||||
needs_swift_tools: true
|
||||
needs_manual_build: true
|
||||
needs_autobuild: false
|
||||
config_file: ""
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -95,6 +100,7 @@ jobs:
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
config-file: ${{ matrix.config_file || '' }}
|
||||
|
||||
- name: Autobuild
|
||||
if: matrix.needs_autobuild
|
||||
|
||||
28
.github/workflows/labeler.yml
vendored
28
.github/workflows/labeler.yml
vendored
@@ -213,6 +213,7 @@ jobs:
|
||||
}
|
||||
|
||||
const activePrLimitLabel = "r: too-many-prs";
|
||||
const activePrLimitOverrideLabel = "r: too-many-prs-override";
|
||||
const activePrLimit = 10;
|
||||
const labelColor = "B60205";
|
||||
const labelDescription = `Author has more than ${activePrLimit} active PRs in this repo`;
|
||||
@@ -221,12 +222,37 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const labelNames = new Set(
|
||||
(pullRequest.labels ?? [])
|
||||
currentLabels
|
||||
.map((label) => (typeof label === "string" ? label : label?.name))
|
||||
.filter((name) => typeof name === "string"),
|
||||
);
|
||||
|
||||
if (labelNames.has(activePrLimitOverrideLabel)) {
|
||||
if (labelNames.has(activePrLimitLabel)) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
name: activePrLimitLabel,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const ensureLabelExists = async () => {
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
|
||||
@@ -66,7 +66,7 @@ repos:
|
||||
- --exclude-lines
|
||||
- 'env: \{ MISTRAL_API_K[E]Y: "sk-\.\.\." \},'
|
||||
- --exclude-lines
|
||||
- '"ap[i]Key": "xxxxx",'
|
||||
- '"ap[i]Key": "xxxxx"(,)?'
|
||||
- --exclude-lines
|
||||
- 'ap[i]Key: "A[I]za\.\.\.",'
|
||||
# Shell script linting
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
"export CUSTOM_API_K[E]Y=\"your-key\"",
|
||||
"grep -q 'N[O]DE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache' ~/.bashrc \\|\\| cat >> ~/.bashrc <<'EOF'",
|
||||
"env: \\{ MISTRAL_API_K[E]Y: \"sk-\\.\\.\\.\" \\},",
|
||||
"\"ap[i]Key\": \"xxxxx\",",
|
||||
"\"ap[i]Key\": \"xxxxx\"(,)?",
|
||||
"ap[i]Key: \"A[I]za\\.\\.\\.\","
|
||||
]
|
||||
},
|
||||
@@ -9619,14 +9619,14 @@
|
||||
"filename": "docs/channels/feishu.md",
|
||||
"hashed_secret": "b60d121b438a380c343d5ec3c2037564b82ffef3",
|
||||
"is_verified": false,
|
||||
"line_number": 189
|
||||
"line_number": 187
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/channels/feishu.md",
|
||||
"hashed_secret": "186154712b2d5f6791d85b9a0987b98fa231779c",
|
||||
"is_verified": false,
|
||||
"line_number": 501
|
||||
"line_number": 499
|
||||
}
|
||||
],
|
||||
"docs/channels/irc.md": [
|
||||
@@ -9809,49 +9809,49 @@
|
||||
"filename": "docs/gateway/configuration-reference.md",
|
||||
"hashed_secret": "7f8aaf142ce0552c260f2e546dda43ddd7c9aef3",
|
||||
"is_verified": false,
|
||||
"line_number": 1813
|
||||
"line_number": 1815
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/gateway/configuration-reference.md",
|
||||
"hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27",
|
||||
"is_verified": false,
|
||||
"line_number": 1986
|
||||
"line_number": 1988
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/gateway/configuration-reference.md",
|
||||
"hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0",
|
||||
"is_verified": false,
|
||||
"line_number": 2042
|
||||
"line_number": 2044
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/gateway/configuration-reference.md",
|
||||
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
|
||||
"is_verified": false,
|
||||
"line_number": 2274
|
||||
"line_number": 2276
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/gateway/configuration-reference.md",
|
||||
"hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6",
|
||||
"is_verified": false,
|
||||
"line_number": 2402
|
||||
"line_number": 2404
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/gateway/configuration-reference.md",
|
||||
"hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281",
|
||||
"is_verified": false,
|
||||
"line_number": 2655
|
||||
"line_number": 2657
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/gateway/configuration-reference.md",
|
||||
"hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25",
|
||||
"is_verified": false,
|
||||
"line_number": 2657
|
||||
"line_number": 2659
|
||||
}
|
||||
],
|
||||
"docs/gateway/configuration.md": [
|
||||
@@ -9972,7 +9972,7 @@
|
||||
"filename": "docs/perplexity.md",
|
||||
"hashed_secret": "6b26c117c66a0c030e239eef595c1e18865132a8",
|
||||
"is_verified": false,
|
||||
"line_number": 29
|
||||
"line_number": 43
|
||||
}
|
||||
],
|
||||
"docs/plugins/voice-call.md": [
|
||||
@@ -10198,21 +10198,21 @@
|
||||
"filename": "docs/tools/web.md",
|
||||
"hashed_secret": "6b26c117c66a0c030e239eef595c1e18865132a8",
|
||||
"is_verified": false,
|
||||
"line_number": 90
|
||||
"line_number": 135
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/tools/web.md",
|
||||
"hashed_secret": "491d458f895b9213facb2ee9375b1b044eaea3ac",
|
||||
"is_verified": false,
|
||||
"line_number": 179
|
||||
"line_number": 228
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/tools/web.md",
|
||||
"hashed_secret": "674397e2c0c2faaa85961c708d2a96a7cc7af217",
|
||||
"is_verified": false,
|
||||
"line_number": 277
|
||||
"line_number": 332
|
||||
}
|
||||
],
|
||||
"docs/tts.md": [
|
||||
@@ -10255,14 +10255,14 @@
|
||||
"filename": "docs/zh-CN/channels/feishu.md",
|
||||
"hashed_secret": "b60d121b438a380c343d5ec3c2037564b82ffef3",
|
||||
"is_verified": false,
|
||||
"line_number": 195
|
||||
"line_number": 191
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/zh-CN/channels/feishu.md",
|
||||
"hashed_secret": "186154712b2d5f6791d85b9a0987b98fa231779c",
|
||||
"is_verified": false,
|
||||
"line_number": 509
|
||||
"line_number": 505
|
||||
}
|
||||
],
|
||||
"docs/zh-CN/channels/line.md": [
|
||||
@@ -11481,7 +11481,7 @@
|
||||
"filename": "src/agents/models-config.e2e-harness.ts",
|
||||
"hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d",
|
||||
"is_verified": false,
|
||||
"line_number": 130
|
||||
"line_number": 131
|
||||
}
|
||||
],
|
||||
"src/agents/models-config.fills-missing-provider-apikey-from-env-var.e2e.test.ts": [
|
||||
@@ -11583,7 +11583,7 @@
|
||||
"filename": "src/agents/pi-embedded-runner/model.ts",
|
||||
"hashed_secret": "e774aaeac31c6272107ba89080295e277050fa7c",
|
||||
"is_verified": false,
|
||||
"line_number": 272
|
||||
"line_number": 267
|
||||
}
|
||||
],
|
||||
"src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts": [
|
||||
@@ -11680,7 +11680,7 @@
|
||||
"filename": "src/agents/tools/web-search.ts",
|
||||
"hashed_secret": "dfba7aade0868074c2861c98e2a9a92f3178a51b",
|
||||
"is_verified": false,
|
||||
"line_number": 254
|
||||
"line_number": 292
|
||||
}
|
||||
],
|
||||
"src/agents/tools/web-tools.enabled-defaults.e2e.test.ts": [
|
||||
@@ -12335,14 +12335,14 @@
|
||||
"filename": "src/config/schema.help.ts",
|
||||
"hashed_secret": "9f4cda226d3868676ac7f86f59e4190eb94bd208",
|
||||
"is_verified": false,
|
||||
"line_number": 649
|
||||
"line_number": 651
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/config/schema.help.ts",
|
||||
"hashed_secret": "01822c8bbf6a8b136944b14182cb885100ec2eae",
|
||||
"is_verified": false,
|
||||
"line_number": 680
|
||||
"line_number": 684
|
||||
}
|
||||
],
|
||||
"src/config/schema.irc.ts": [
|
||||
@@ -12388,7 +12388,7 @@
|
||||
"filename": "src/config/schema.labels.ts",
|
||||
"hashed_secret": "2eda7cd978f39eebec3bf03e4410a40e14167fff",
|
||||
"is_verified": false,
|
||||
"line_number": 324
|
||||
"line_number": 325
|
||||
}
|
||||
],
|
||||
"src/config/slack-http-config.test.ts": [
|
||||
@@ -13034,5 +13034,5 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_at": "2026-03-08T05:05:36Z"
|
||||
"generated_at": "2026-03-08T18:30:57Z"
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
- GitHub comment footgun: never use `gh issue/pr comment -b "..."` when body contains backticks or shell chars. Always use single-quoted heredoc (`-F - <<'EOF'`) so no command substitution/escaping corruption.
|
||||
- GitHub linking footgun: don’t wrap issue/PR refs like `#24643` in backticks when you want auto-linking. Use plain `#24643` (optionally add full URL).
|
||||
- PR landing comments: always make commit SHAs clickable with full commit links (both landed SHA + source SHA when present).
|
||||
- PR review conversations: if a bot leaves review conversations on your PR, address them and resolve those conversations yourself once fixed. Leave a conversation unresolved only when reviewer or maintainer judgment is still needed; do not leave bot-conversation cleanup to maintainers.
|
||||
- GitHub searching footgun: don't limit yourself to the first 500 issues or PRs when wanting to search all. Unless you're supposed to look at the most recent, keep going until you've reached the last page in the search
|
||||
- Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries.
|
||||
|
||||
@@ -28,6 +29,7 @@
|
||||
- Docs are hosted on Mintlify (docs.openclaw.ai).
|
||||
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
|
||||
- When working with documentation, read the mintlify skill.
|
||||
- For docs, UI copy, and picker lists, order services/providers alphabetically unless the section is explicitly describing runtime behavior (for example auto-detection or execution order).
|
||||
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
|
||||
- Doc headings and anchors: avoid em dashes and apostrophes in headings because they break Mintlify anchor links.
|
||||
- When Peter asks for links, reply with full `https://docs.openclaw.ai/...` URLs (not root-relative).
|
||||
@@ -113,6 +115,7 @@
|
||||
|
||||
**Full maintainer PR workflow (optional):** If you want the repo's end-to-end maintainer workflow (triage order, quality bar, rebase rules, commit/changelog conventions, co-contributor policy, and the `review-pr` > `prepare-pr` > `merge-pr` pipeline), see `.agents/skills/PR_WORKFLOW.md`. Maintainers may use other workflows; when a maintainer specifies a workflow, follow that. If no workflow is specified, default to PR_WORKFLOW.
|
||||
|
||||
- `/landpr` lives in the global Codex prompts (`~/.codex/prompts/landpr.md`); when landing or merging any PR, always follow that `/landpr` process.
|
||||
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
|
||||
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
|
||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -7,14 +7,27 @@ Docs: https://docs.openclaw.ai
|
||||
### Changes
|
||||
|
||||
- TUI: infer the active agent from the current workspace when launched inside a configured agent workspace, while preserving explicit `agent:` session targets. (#39591) thanks @arceus77-7.
|
||||
- Tools/Brave web search: add opt-in `tools.web.search.brave.mode: "llm-context"` so `web_search` can call Brave's LLM Context endpoint and return extracted grounding snippets with source metadata, plus config/docs/test coverage. (#33383) Thanks @thirumaleshp.
|
||||
- Talk mode: add top-level `talk.silenceTimeoutMs` config so Talk waits a configurable amount of silence before auto-sending the current transcript, while keeping each platform's existing default pause window when unset. (#39607) Thanks @danodoesdesign. Fixes #17147.
|
||||
- CLI/install: include the short git commit hash in `openclaw --version` output when metadata is available, and keep installer version checks compatible with the decorated format. (#39712) thanks @sourman.
|
||||
- Docs/Web search: restore $5/month free-credit details, replace defunct "Data for Search"/"Data for AI" plan names with current "Search" plan, and note legacy subscription validity in Brave setup docs. Follows up on #26860. (#40111) Thanks @remusao.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Plugins/channel onboarding: prefer bundled channel plugins over duplicate npm-installed copies during onboarding and release-channel sync, preventing bundled plugins from being shadowed by npm installs with the same plugin ID. (#40092)
|
||||
- macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1.
|
||||
- Mattermost replies: keep `root_id` pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda.
|
||||
- Agents/failover: detect Amazon Bedrock `Too many tokens per day` quota errors as rate limits across fallback, cron retry, and memory embeddings while keeping context-window `too many tokens per request` errors out of the rate-limit lane. (#39377) Thanks @gambletan.
|
||||
- Android/Play distribution: remove self-update, background location, `screen.record`, and background mic capture from the Android app, narrow the foreground service to `dataSync` only, and clean up the legacy `location.enabledMode=always` preference migration. (#39660) Thanks @obviyus.
|
||||
- Agents/openai-codex model resolution: fall through from inline `openai-codex` model entries without an `api` so GPT-5.4 keeps the codex transport and still preserves configured `baseUrl` and headers. (#39753) Thanks @justinhuangcode.
|
||||
- Telegram/DM partial streaming: keep DM preview lanes on real message edits instead of native draft materialization so final replies no longer flash a second duplicate copy before collapsing back to one.
|
||||
- macOS overlays: fix VoiceWake, Talk, and Notify overlay exclusivity crashes by removing shared `inout` visibility mutation from `OverlayPanelFactory.present`, and add a repeated Talk overlay smoke test. (#39275, #39321) Thanks @fellanH.
|
||||
- macOS Talk Mode: set the speech recognition request `taskHint` to `.dictation` for mic capture, and add regression coverage for the request defaults. (#38445) Thanks @dmiv.
|
||||
- macOS release packaging: default `scripts/package-mac-app.sh` to universal binaries for `BUILD_CONFIG=release`, and clarify that `scripts/package-mac-dist.sh` already produces the release zip + DMG. (#33891) Thanks @cgdusek.
|
||||
- Tools/web search: restore Perplexity OpenRouter/Sonar compatibility for legacy `OPENROUTER_API_KEY`, `sk-or-...`, and explicit `perplexity.baseUrl` / `model` setups while keeping direct Perplexity keys on the native Search API path. (#39937) Thanks @obviyus.
|
||||
- Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera.
|
||||
- Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii.
|
||||
- ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky.
|
||||
- Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for `/json/*` tab operations so local `ws://` / `wss://` profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150.
|
||||
|
||||
## 2026.3.7
|
||||
|
||||
@@ -47,6 +60,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Mattermost/model picker: add Telegram-style interactive provider/model browsing for `/oc_model` and `/oc_models`, fix picker callback updates, and emit a normal confirmation reply when a model is selected. (#38767) thanks @mukhtharcm.
|
||||
- Docker/multi-stage build: restructure Dockerfile as a multi-stage build to produce a minimal runtime image without build tools, source code, or Bun; add `OPENCLAW_VARIANT=slim` build arg for a bookworm-slim variant. (#38479) Thanks @sallyom.
|
||||
- Google/Gemini 3.1 Flash-Lite: add first-class `google/gemini-3.1-flash-lite-preview` support across model-id normalization, default aliases, media-understanding image lookups, Google Gemini CLI forward-compat fallback, and docs.
|
||||
- Agents/compaction model override: allow `agents.defaults.compaction.model` to route compaction summarization through a different model than the main session, and document the override across config help/reference surfaces. (#38753) thanks @starbuck100.
|
||||
|
||||
### Breaking
|
||||
|
||||
|
||||
@@ -74,8 +74,19 @@ Welcome to the lobster tank! 🦞
|
||||
- Ensure CI checks pass
|
||||
- Keep PRs focused (one thing per PR; do not mix unrelated concerns)
|
||||
- Describe what & why
|
||||
- Reply to or resolve bot review conversations you addressed before asking for review again
|
||||
- **Include screenshots** — one showing the problem/before, one showing the fix/after (for UI or visual changes)
|
||||
|
||||
## Review Conversations Are Author-Owned
|
||||
|
||||
If a review bot leaves review conversations on your PR, you are expected to handle the follow-through:
|
||||
|
||||
- Resolve the conversation yourself once the code or explanation fully addresses the bot's concern
|
||||
- Reply and leave it open only when you need maintainer or reviewer judgment
|
||||
- Do not leave "fixed" bot review conversations for maintainers to clean up for you
|
||||
|
||||
This applies to both human-authored and AI-assisted PRs.
|
||||
|
||||
## Control UI Decorators
|
||||
|
||||
The Control UI uses Lit with **legacy** decorators (current Rollup parsing does not support
|
||||
@@ -101,8 +112,9 @@ Please include in your PR:
|
||||
- [ ] Note the degree of testing (untested / lightly tested / fully tested)
|
||||
- [ ] Include prompts or session logs if possible (super helpful!)
|
||||
- [ ] Confirm you understand what the code does
|
||||
- [ ] Resolve or reply to bot review conversations after you address them
|
||||
|
||||
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for.
|
||||
AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for. If you are using an LLM coding agent, instruct it to resolve bot review conversations it has addressed instead of leaving them for maintainers.
|
||||
|
||||
## Current Focus & Roadmap 🗺
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
internal object TalkDefaults {
|
||||
const val defaultSilenceTimeoutMs = 700L
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import ai.openclaw.app.normalizeMainKey
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
internal data class TalkProviderConfigSelection(
|
||||
val provider: String,
|
||||
val config: JsonObject,
|
||||
val normalizedPayload: Boolean,
|
||||
)
|
||||
|
||||
internal data class TalkModeGatewayConfigState(
|
||||
val activeProvider: String,
|
||||
val normalizedPayload: Boolean,
|
||||
val missingResolvedPayload: Boolean,
|
||||
val mainSessionKey: String,
|
||||
val defaultVoiceId: String?,
|
||||
val voiceAliases: Map<String, String>,
|
||||
val defaultModelId: String,
|
||||
val defaultOutputFormat: String,
|
||||
val apiKey: String?,
|
||||
val interruptOnSpeech: Boolean?,
|
||||
val silenceTimeoutMs: Long,
|
||||
)
|
||||
|
||||
internal object TalkModeGatewayConfigParser {
|
||||
private const val defaultTalkProvider = "elevenlabs"
|
||||
|
||||
fun parse(
|
||||
config: JsonObject?,
|
||||
defaultProvider: String,
|
||||
defaultModelIdFallback: String,
|
||||
defaultOutputFormatFallback: String,
|
||||
envVoice: String?,
|
||||
sagVoice: String?,
|
||||
envKey: String?,
|
||||
): TalkModeGatewayConfigState {
|
||||
val talk = config?.get("talk").asObjectOrNull()
|
||||
val selection = selectTalkProviderConfig(talk)
|
||||
val activeProvider = selection?.provider ?: defaultProvider
|
||||
val activeConfig = selection?.config
|
||||
val sessionCfg = config?.get("session").asObjectOrNull()
|
||||
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
|
||||
val voice = activeConfig?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val aliases =
|
||||
activeConfig?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) ->
|
||||
val id = value.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||
normalizeTalkAliasKey(key).takeIf { it.isNotEmpty() }?.let { it to id }
|
||||
}?.toMap().orEmpty()
|
||||
val model = activeConfig?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val outputFormat =
|
||||
activeConfig?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val key = activeConfig?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
|
||||
val silenceTimeoutMs = resolvedSilenceTimeoutMs(talk)
|
||||
|
||||
return TalkModeGatewayConfigState(
|
||||
activeProvider = activeProvider,
|
||||
normalizedPayload = selection?.normalizedPayload == true,
|
||||
missingResolvedPayload = talk != null && selection == null,
|
||||
mainSessionKey = mainKey,
|
||||
defaultVoiceId =
|
||||
if (activeProvider == defaultProvider) {
|
||||
voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
|
||||
} else {
|
||||
voice
|
||||
},
|
||||
voiceAliases = aliases,
|
||||
defaultModelId = model ?: defaultModelIdFallback,
|
||||
defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback,
|
||||
apiKey = key ?: envKey?.takeIf { it.isNotEmpty() },
|
||||
interruptOnSpeech = interrupt,
|
||||
silenceTimeoutMs = silenceTimeoutMs,
|
||||
)
|
||||
}
|
||||
|
||||
fun fallback(
|
||||
defaultProvider: String,
|
||||
defaultModelIdFallback: String,
|
||||
defaultOutputFormatFallback: String,
|
||||
envVoice: String?,
|
||||
sagVoice: String?,
|
||||
envKey: String?,
|
||||
): TalkModeGatewayConfigState =
|
||||
TalkModeGatewayConfigState(
|
||||
activeProvider = defaultProvider,
|
||||
normalizedPayload = false,
|
||||
missingResolvedPayload = false,
|
||||
mainSessionKey = "main",
|
||||
defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() },
|
||||
voiceAliases = emptyMap(),
|
||||
defaultModelId = defaultModelIdFallback,
|
||||
defaultOutputFormat = defaultOutputFormatFallback,
|
||||
apiKey = envKey?.takeIf { it.isNotEmpty() },
|
||||
interruptOnSpeech = null,
|
||||
silenceTimeoutMs = TalkDefaults.defaultSilenceTimeoutMs,
|
||||
)
|
||||
|
||||
fun selectTalkProviderConfig(talk: JsonObject?): TalkProviderConfigSelection? {
|
||||
if (talk == null) return null
|
||||
selectResolvedTalkProviderConfig(talk)?.let { return it }
|
||||
val rawProvider = talk["provider"].asStringOrNull()
|
||||
val rawProviders = talk["providers"].asObjectOrNull()
|
||||
val hasNormalizedPayload = rawProvider != null || rawProviders != null
|
||||
if (hasNormalizedPayload) {
|
||||
return null
|
||||
}
|
||||
return TalkProviderConfigSelection(
|
||||
provider = defaultTalkProvider,
|
||||
config = talk,
|
||||
normalizedPayload = false,
|
||||
)
|
||||
}
|
||||
|
||||
fun resolvedSilenceTimeoutMs(talk: JsonObject?): Long {
|
||||
val fallback = TalkDefaults.defaultSilenceTimeoutMs
|
||||
val primitive = talk?.get("silenceTimeoutMs") as? JsonPrimitive ?: return fallback
|
||||
if (primitive.isString) return fallback
|
||||
val timeout = primitive.content.toDoubleOrNull() ?: return fallback
|
||||
if (timeout <= 0 || timeout % 1.0 != 0.0 || timeout > Long.MAX_VALUE.toDouble()) {
|
||||
return fallback
|
||||
}
|
||||
return timeout.toLong()
|
||||
}
|
||||
|
||||
private fun selectResolvedTalkProviderConfig(talk: JsonObject): TalkProviderConfigSelection? {
|
||||
val resolved = talk["resolved"].asObjectOrNull() ?: return null
|
||||
val providerId = normalizeTalkProviderId(resolved["provider"].asStringOrNull()) ?: return null
|
||||
return TalkProviderConfigSelection(
|
||||
provider = providerId,
|
||||
config = resolved["config"].asObjectOrNull() ?: buildJsonObject {},
|
||||
normalizedPayload = true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun normalizeTalkProviderId(raw: String?): String? {
|
||||
val trimmed = raw?.trim()?.lowercase().orEmpty()
|
||||
return trimmed.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeTalkAliasKey(value: String): String =
|
||||
value.trim().lowercase()
|
||||
|
||||
private fun JsonElement?.asStringOrNull(): String? =
|
||||
this?.let { element ->
|
||||
element as? JsonPrimitive
|
||||
}?.contentOrNull
|
||||
|
||||
private fun JsonElement?.asBooleanOrNull(): Boolean? {
|
||||
val primitive = this as? JsonPrimitive ?: return null
|
||||
return primitive.booleanOrNull
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? =
|
||||
this as? JsonObject
|
||||
@@ -59,52 +59,11 @@ class TalkModeManager(
|
||||
private const val tag = "TalkMode"
|
||||
private const val defaultModelIdFallback = "eleven_v3"
|
||||
private const val defaultOutputFormatFallback = "pcm_24000"
|
||||
private const val defaultTalkProvider = "elevenlabs"
|
||||
private const val silenceWindowMs = 500L
|
||||
private const val defaultTalkProvider = "elevenlabs"
|
||||
private const val listenWatchdogMs = 12_000L
|
||||
private const val chatFinalWaitWithSubscribeMs = 45_000L
|
||||
private const val chatFinalWaitWithoutSubscribeMs = 6_000L
|
||||
private const val maxCachedRunCompletions = 128
|
||||
|
||||
internal data class TalkProviderConfigSelection(
|
||||
val provider: String,
|
||||
val config: JsonObject,
|
||||
val normalizedPayload: Boolean,
|
||||
)
|
||||
|
||||
private fun normalizeTalkProviderId(raw: String?): String? {
|
||||
val trimmed = raw?.trim()?.lowercase().orEmpty()
|
||||
return trimmed.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
internal fun selectTalkProviderConfig(talk: JsonObject?): TalkProviderConfigSelection? {
|
||||
if (talk == null) return null
|
||||
val rawProvider = talk["provider"].asStringOrNull()
|
||||
val rawProviders = talk["providers"].asObjectOrNull()
|
||||
val hasNormalizedPayload = rawProvider != null || rawProviders != null
|
||||
if (hasNormalizedPayload) {
|
||||
val providers =
|
||||
rawProviders?.entries?.mapNotNull { (key, value) ->
|
||||
val providerId = normalizeTalkProviderId(key) ?: return@mapNotNull null
|
||||
val providerConfig = value.asObjectOrNull() ?: return@mapNotNull null
|
||||
providerId to providerConfig
|
||||
}?.toMap().orEmpty()
|
||||
val providerId =
|
||||
normalizeTalkProviderId(rawProvider)
|
||||
?: providers.keys.sorted().firstOrNull()
|
||||
?: defaultTalkProvider
|
||||
return TalkProviderConfigSelection(
|
||||
provider = providerId,
|
||||
config = providers[providerId] ?: buildJsonObject {},
|
||||
normalizedPayload = true,
|
||||
)
|
||||
}
|
||||
return TalkProviderConfigSelection(
|
||||
provider = defaultTalkProvider,
|
||||
config = talk,
|
||||
normalizedPayload = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
@@ -134,7 +93,7 @@ private const val defaultTalkProvider = "elevenlabs"
|
||||
private var listeningMode = false
|
||||
|
||||
private var silenceJob: Job? = null
|
||||
private val silenceWindowMs = 700L
|
||||
private var silenceWindowMs = TalkDefaults.defaultSilenceTimeoutMs
|
||||
private var lastTranscript: String = ""
|
||||
private var lastHeardAtMs: Long? = null
|
||||
private var lastSpokenText: String? = null
|
||||
@@ -854,7 +813,7 @@ private const val defaultTalkProvider = "elevenlabs"
|
||||
_lastAssistantText.value = cleaned
|
||||
|
||||
val requestedVoice = directive?.voiceId?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val resolvedVoice = resolveVoiceAlias(requestedVoice)
|
||||
val resolvedVoice = TalkModeVoiceResolver.resolveVoiceAlias(requestedVoice, voiceAliases)
|
||||
if (requestedVoice != null && resolvedVoice == null) {
|
||||
Log.w(tag, "unknown voice alias: $requestedVoice")
|
||||
}
|
||||
@@ -877,12 +836,35 @@ private const val defaultTalkProvider = "elevenlabs"
|
||||
apiKey?.trim()?.takeIf { it.isNotEmpty() }
|
||||
?: System.getenv("ELEVENLABS_API_KEY")?.trim()
|
||||
val preferredVoice = resolvedVoice ?: currentVoiceId ?: defaultVoiceId
|
||||
val voiceId =
|
||||
val resolvedPlaybackVoice =
|
||||
if (!apiKey.isNullOrEmpty()) {
|
||||
resolveVoiceId(preferredVoice, apiKey)
|
||||
try {
|
||||
TalkModeVoiceResolver.resolveVoiceId(
|
||||
preferred = preferredVoice,
|
||||
fallbackVoiceId = fallbackVoiceId,
|
||||
defaultVoiceId = defaultVoiceId,
|
||||
currentVoiceId = currentVoiceId,
|
||||
voiceOverrideActive = voiceOverrideActive,
|
||||
listVoices = { TalkModeVoiceResolver.listVoices(apiKey, json) },
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
Log.w(tag, "list voices failed: ${err.message ?: err::class.simpleName}")
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
resolvedPlaybackVoice?.let { resolved ->
|
||||
fallbackVoiceId = resolved.fallbackVoiceId
|
||||
defaultVoiceId = resolved.defaultVoiceId
|
||||
currentVoiceId = resolved.currentVoiceId
|
||||
resolved.selectedVoiceName?.let { name ->
|
||||
resolved.voiceId?.let { voiceId ->
|
||||
Log.d(tag, "default voice selected $name ($voiceId)")
|
||||
}
|
||||
}
|
||||
}
|
||||
val voiceId = resolvedPlaybackVoice?.voiceId
|
||||
|
||||
_statusText.value = "Speaking…"
|
||||
_isSpeaking.value = true
|
||||
@@ -1393,60 +1375,64 @@ private const val defaultTalkProvider = "elevenlabs"
|
||||
try {
|
||||
val res = session.request("talk.config", """{"includeSecrets":true}""")
|
||||
val root = json.parseToJsonElement(res).asObjectOrNull()
|
||||
val config = root?.get("config").asObjectOrNull()
|
||||
val talk = config?.get("talk").asObjectOrNull()
|
||||
val selection = selectTalkProviderConfig(talk)
|
||||
val activeProvider = selection?.provider ?: defaultTalkProvider
|
||||
val activeConfig = selection?.config
|
||||
val sessionCfg = config?.get("session").asObjectOrNull()
|
||||
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
|
||||
val voice = activeConfig?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val aliases =
|
||||
activeConfig?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) ->
|
||||
val id = value.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||
normalizeAliasKey(key).takeIf { it.isNotEmpty() }?.let { it to id }
|
||||
}?.toMap().orEmpty()
|
||||
val model = activeConfig?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val outputFormat =
|
||||
activeConfig?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val key = activeConfig?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
|
||||
val parsed =
|
||||
TalkModeGatewayConfigParser.parse(
|
||||
config = root?.get("config").asObjectOrNull(),
|
||||
defaultProvider = defaultTalkProvider,
|
||||
defaultModelIdFallback = defaultModelIdFallback,
|
||||
defaultOutputFormatFallback = defaultOutputFormatFallback,
|
||||
envVoice = envVoice,
|
||||
sagVoice = sagVoice,
|
||||
envKey = envKey,
|
||||
)
|
||||
if (parsed.missingResolvedPayload) {
|
||||
Log.w(tag, "talk config ignored: normalized payload missing talk.resolved")
|
||||
}
|
||||
|
||||
if (!isCanonicalMainSessionKey(mainSessionKey)) {
|
||||
mainSessionKey = mainKey
|
||||
mainSessionKey = parsed.mainSessionKey
|
||||
}
|
||||
defaultVoiceId =
|
||||
if (activeProvider == defaultTalkProvider) {
|
||||
voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
|
||||
} else {
|
||||
voice
|
||||
}
|
||||
voiceAliases = aliases
|
||||
defaultVoiceId = parsed.defaultVoiceId
|
||||
voiceAliases = parsed.voiceAliases
|
||||
if (!voiceOverrideActive) currentVoiceId = defaultVoiceId
|
||||
defaultModelId = model ?: defaultModelIdFallback
|
||||
defaultModelId = parsed.defaultModelId
|
||||
if (!modelOverrideActive) currentModelId = defaultModelId
|
||||
defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback
|
||||
apiKey = key ?: envKey?.takeIf { it.isNotEmpty() }
|
||||
Log.d(tag, "reloadConfig apiKey=${if (apiKey != null) "set" else "null"} voiceId=$defaultVoiceId")
|
||||
if (interrupt != null) interruptOnSpeech = interrupt
|
||||
activeProviderIsElevenLabs = activeProvider == defaultTalkProvider
|
||||
defaultOutputFormat = parsed.defaultOutputFormat
|
||||
apiKey = parsed.apiKey
|
||||
silenceWindowMs = parsed.silenceTimeoutMs
|
||||
Log.d(
|
||||
tag,
|
||||
"reloadConfig apiKey=${if (apiKey != null) "set" else "null"} voiceId=$defaultVoiceId silenceTimeoutMs=${parsed.silenceTimeoutMs}",
|
||||
)
|
||||
if (parsed.interruptOnSpeech != null) interruptOnSpeech = parsed.interruptOnSpeech
|
||||
activeProviderIsElevenLabs = parsed.activeProvider == defaultTalkProvider
|
||||
if (!activeProviderIsElevenLabs) {
|
||||
// Clear ElevenLabs credentials so playAssistant won't attempt ElevenLabs calls
|
||||
apiKey = null
|
||||
defaultVoiceId = null
|
||||
if (!voiceOverrideActive) currentVoiceId = null
|
||||
Log.w(tag, "talk provider $activeProvider unsupported; using system voice fallback")
|
||||
} else if (selection?.normalizedPayload == true) {
|
||||
Log.w(tag, "talk provider ${parsed.activeProvider} unsupported; using system voice fallback")
|
||||
} else if (parsed.normalizedPayload) {
|
||||
Log.d(tag, "talk config provider=elevenlabs")
|
||||
}
|
||||
configLoaded = true
|
||||
} catch (_: Throwable) {
|
||||
defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
|
||||
defaultModelId = defaultModelIdFallback
|
||||
val fallback =
|
||||
TalkModeGatewayConfigParser.fallback(
|
||||
defaultProvider = defaultTalkProvider,
|
||||
defaultModelIdFallback = defaultModelIdFallback,
|
||||
defaultOutputFormatFallback = defaultOutputFormatFallback,
|
||||
envVoice = envVoice,
|
||||
sagVoice = sagVoice,
|
||||
envKey = envKey,
|
||||
)
|
||||
silenceWindowMs = fallback.silenceTimeoutMs
|
||||
defaultVoiceId = fallback.defaultVoiceId
|
||||
defaultModelId = fallback.defaultModelId
|
||||
if (!modelOverrideActive) currentModelId = defaultModelId
|
||||
apiKey = envKey?.takeIf { it.isNotEmpty() }
|
||||
voiceAliases = emptyMap()
|
||||
defaultOutputFormat = defaultOutputFormatFallback
|
||||
apiKey = fallback.apiKey
|
||||
voiceAliases = fallback.voiceAliases
|
||||
defaultOutputFormat = fallback.defaultOutputFormat
|
||||
// Keep config load retryable after transient fetch failures.
|
||||
configLoaded = false
|
||||
}
|
||||
@@ -1740,82 +1726,6 @@ private const val defaultTalkProvider = "elevenlabs"
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveVoiceAlias(value: String?): String? {
|
||||
val trimmed = value?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return null
|
||||
val normalized = normalizeAliasKey(trimmed)
|
||||
voiceAliases[normalized]?.let { return it }
|
||||
if (voiceAliases.values.any { it.equals(trimmed, ignoreCase = true) }) return trimmed
|
||||
return if (isLikelyVoiceId(trimmed)) trimmed else null
|
||||
}
|
||||
|
||||
private suspend fun resolveVoiceId(preferred: String?, apiKey: String): String? {
|
||||
val trimmed = preferred?.trim().orEmpty()
|
||||
if (trimmed.isNotEmpty()) {
|
||||
val resolved = resolveVoiceAlias(trimmed)
|
||||
// If it resolves as an alias, use the alias target.
|
||||
// Otherwise treat it as a direct voice ID (e.g. "21m00Tcm4TlvDq8ikWAM").
|
||||
return resolved ?: trimmed
|
||||
}
|
||||
fallbackVoiceId?.let { return it }
|
||||
|
||||
return try {
|
||||
val voices = listVoices(apiKey)
|
||||
val first = voices.firstOrNull() ?: return null
|
||||
fallbackVoiceId = first.voiceId
|
||||
if (defaultVoiceId.isNullOrBlank()) {
|
||||
defaultVoiceId = first.voiceId
|
||||
}
|
||||
if (!voiceOverrideActive) {
|
||||
currentVoiceId = first.voiceId
|
||||
}
|
||||
val name = first.name ?: "unknown"
|
||||
Log.d(tag, "default voice selected $name (${first.voiceId})")
|
||||
first.voiceId
|
||||
} catch (err: Throwable) {
|
||||
Log.w(tag, "list voices failed: ${err.message ?: err::class.simpleName}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun listVoices(apiKey: String): List<ElevenLabsVoice> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val url = URL("https://api.elevenlabs.io/v1/voices")
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
conn.requestMethod = "GET"
|
||||
conn.connectTimeout = 15_000
|
||||
conn.readTimeout = 15_000
|
||||
conn.setRequestProperty("xi-api-key", apiKey)
|
||||
|
||||
val code = conn.responseCode
|
||||
val stream = if (code >= 400) conn.errorStream else conn.inputStream
|
||||
val data = stream.readBytes()
|
||||
if (code >= 400) {
|
||||
val message = data.toString(Charsets.UTF_8)
|
||||
throw IllegalStateException("ElevenLabs voices failed: $code $message")
|
||||
}
|
||||
|
||||
val root = json.parseToJsonElement(data.toString(Charsets.UTF_8)).asObjectOrNull()
|
||||
val voices = (root?.get("voices") as? JsonArray) ?: JsonArray(emptyList())
|
||||
voices.mapNotNull { entry ->
|
||||
val obj = entry.asObjectOrNull() ?: return@mapNotNull null
|
||||
val voiceId = obj["voice_id"].asStringOrNull() ?: return@mapNotNull null
|
||||
val name = obj["name"].asStringOrNull()
|
||||
ElevenLabsVoice(voiceId, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isLikelyVoiceId(value: String): Boolean {
|
||||
if (value.length < 10) return false
|
||||
return value.all { it.isLetterOrDigit() || it == '-' || it == '_' }
|
||||
}
|
||||
|
||||
private fun normalizeAliasKey(value: String): String =
|
||||
value.trim().lowercase()
|
||||
|
||||
private data class ElevenLabsVoice(val voiceId: String, val name: String?)
|
||||
|
||||
private val listener =
|
||||
object : RecognitionListener {
|
||||
override fun onReadyForSpeech(params: Bundle?) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
internal data class ElevenLabsVoice(val voiceId: String, val name: String?)
|
||||
|
||||
internal data class TalkModeResolvedVoice(
|
||||
val voiceId: String?,
|
||||
val fallbackVoiceId: String?,
|
||||
val defaultVoiceId: String?,
|
||||
val currentVoiceId: String?,
|
||||
val selectedVoiceName: String? = null,
|
||||
)
|
||||
|
||||
internal object TalkModeVoiceResolver {
|
||||
fun resolveVoiceAlias(value: String?, voiceAliases: Map<String, String>): String? {
|
||||
val trimmed = value?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return null
|
||||
val normalized = normalizeAliasKey(trimmed)
|
||||
voiceAliases[normalized]?.let { return it }
|
||||
if (voiceAliases.values.any { it.equals(trimmed, ignoreCase = true) }) return trimmed
|
||||
return if (isLikelyVoiceId(trimmed)) trimmed else null
|
||||
}
|
||||
|
||||
suspend fun resolveVoiceId(
|
||||
preferred: String?,
|
||||
fallbackVoiceId: String?,
|
||||
defaultVoiceId: String?,
|
||||
currentVoiceId: String?,
|
||||
voiceOverrideActive: Boolean,
|
||||
listVoices: suspend () -> List<ElevenLabsVoice>,
|
||||
): TalkModeResolvedVoice {
|
||||
val trimmed = preferred?.trim().orEmpty()
|
||||
if (trimmed.isNotEmpty()) {
|
||||
return TalkModeResolvedVoice(
|
||||
voiceId = trimmed,
|
||||
fallbackVoiceId = fallbackVoiceId,
|
||||
defaultVoiceId = defaultVoiceId,
|
||||
currentVoiceId = currentVoiceId,
|
||||
)
|
||||
}
|
||||
if (!fallbackVoiceId.isNullOrBlank()) {
|
||||
return TalkModeResolvedVoice(
|
||||
voiceId = fallbackVoiceId,
|
||||
fallbackVoiceId = fallbackVoiceId,
|
||||
defaultVoiceId = defaultVoiceId,
|
||||
currentVoiceId = currentVoiceId,
|
||||
)
|
||||
}
|
||||
|
||||
val first = listVoices().firstOrNull()
|
||||
if (first == null) {
|
||||
return TalkModeResolvedVoice(
|
||||
voiceId = null,
|
||||
fallbackVoiceId = fallbackVoiceId,
|
||||
defaultVoiceId = defaultVoiceId,
|
||||
currentVoiceId = currentVoiceId,
|
||||
)
|
||||
}
|
||||
|
||||
return TalkModeResolvedVoice(
|
||||
voiceId = first.voiceId,
|
||||
fallbackVoiceId = first.voiceId,
|
||||
defaultVoiceId = if (defaultVoiceId.isNullOrBlank()) first.voiceId else defaultVoiceId,
|
||||
currentVoiceId = if (voiceOverrideActive) currentVoiceId else first.voiceId,
|
||||
selectedVoiceName = first.name,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun listVoices(apiKey: String, json: Json): List<ElevenLabsVoice> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val url = URL("https://api.elevenlabs.io/v1/voices")
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
conn.requestMethod = "GET"
|
||||
conn.connectTimeout = 15_000
|
||||
conn.readTimeout = 15_000
|
||||
conn.setRequestProperty("xi-api-key", apiKey)
|
||||
|
||||
val code = conn.responseCode
|
||||
val stream = if (code >= 400) conn.errorStream else conn.inputStream
|
||||
val data = stream.readBytes()
|
||||
if (code >= 400) {
|
||||
val message = data.toString(Charsets.UTF_8)
|
||||
throw IllegalStateException("ElevenLabs voices failed: $code $message")
|
||||
}
|
||||
|
||||
val root = json.parseToJsonElement(data.toString(Charsets.UTF_8)).asObjectOrNull()
|
||||
val voices = (root?.get("voices") as? JsonArray) ?: JsonArray(emptyList())
|
||||
voices.mapNotNull { entry ->
|
||||
val obj = entry.asObjectOrNull() ?: return@mapNotNull null
|
||||
val voiceId = obj["voice_id"].asStringOrNull() ?: return@mapNotNull null
|
||||
val name = obj["name"].asStringOrNull()
|
||||
ElevenLabsVoice(voiceId, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isLikelyVoiceId(value: String): Boolean {
|
||||
if (value.length < 10) return false
|
||||
return value.all { it.isLetterOrDigit() || it == '-' || it == '_' }
|
||||
}
|
||||
|
||||
private fun normalizeAliasKey(value: String): String =
|
||||
value.trim().lowercase()
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun JsonElement?.asStringOrNull(): String? =
|
||||
(this as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
@@ -0,0 +1,100 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import java.io.File
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
@Serializable
|
||||
private data class TalkConfigContractFixture(
|
||||
@SerialName("selectionCases") val selectionCases: List<SelectionCase>,
|
||||
@SerialName("timeoutCases") val timeoutCases: List<TimeoutCase>,
|
||||
) {
|
||||
@Serializable
|
||||
data class SelectionCase(
|
||||
val id: String,
|
||||
val defaultProvider: String,
|
||||
val payloadValid: Boolean,
|
||||
val expectedSelection: ExpectedSelection? = null,
|
||||
val talk: JsonObject,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ExpectedSelection(
|
||||
val provider: String,
|
||||
val normalizedPayload: Boolean,
|
||||
val voiceId: String? = null,
|
||||
val apiKey: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TimeoutCase(
|
||||
val id: String,
|
||||
val fallback: Long,
|
||||
val expectedTimeoutMs: Long,
|
||||
val talk: JsonObject,
|
||||
)
|
||||
}
|
||||
|
||||
class TalkModeConfigContractTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun selectionFixtures() {
|
||||
for (fixture in loadFixtures().selectionCases) {
|
||||
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(fixture.talk)
|
||||
val expected = fixture.expectedSelection
|
||||
if (expected == null) {
|
||||
assertNull(fixture.id, selection)
|
||||
continue
|
||||
}
|
||||
assertNotNull(fixture.id, selection)
|
||||
assertEquals(fixture.id, expected.provider, selection?.provider)
|
||||
assertEquals(fixture.id, expected.normalizedPayload, selection?.normalizedPayload)
|
||||
assertEquals(
|
||||
fixture.id,
|
||||
expected.voiceId,
|
||||
(selection?.config?.get("voiceId") as? JsonPrimitive)?.content,
|
||||
)
|
||||
assertEquals(
|
||||
fixture.id,
|
||||
expected.apiKey,
|
||||
(selection?.config?.get("apiKey") as? JsonPrimitive)?.content,
|
||||
)
|
||||
assertEquals(fixture.id, true, fixture.payloadValid)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun timeoutFixtures() {
|
||||
for (fixture in loadFixtures().timeoutCases) {
|
||||
val timeout = TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(fixture.talk)
|
||||
assertEquals(fixture.id, fixture.expectedTimeoutMs, timeout)
|
||||
assertEquals(fixture.id, TalkDefaults.defaultSilenceTimeoutMs, fixture.fallback)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadFixtures(): TalkConfigContractFixture {
|
||||
val fixturePath = findFixtureFile()
|
||||
return json.decodeFromString(File(fixturePath).readText())
|
||||
}
|
||||
|
||||
private fun findFixtureFile(): String {
|
||||
val startDir = System.getProperty("user.dir") ?: error("user.dir unavailable")
|
||||
var current = File(startDir).absoluteFile
|
||||
while (true) {
|
||||
val candidate = File(current, "test-fixtures/talk-config-contract.json")
|
||||
if (candidate.exists()) {
|
||||
return candidate.absolutePath
|
||||
}
|
||||
current = current.parentFile ?: break
|
||||
}
|
||||
error("talk-config-contract.json not found from $startDir")
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,36 @@ import org.junit.Test
|
||||
class TalkModeConfigParsingTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun prefersCanonicalResolvedTalkProviderPayload() {
|
||||
val talk =
|
||||
json.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"resolved": {
|
||||
"provider": "elevenlabs",
|
||||
"config": {
|
||||
"voiceId": "voice-resolved"
|
||||
}
|
||||
},
|
||||
"provider": "elevenlabs",
|
||||
"providers": {
|
||||
"elevenlabs": {
|
||||
"voiceId": "voice-normalized"
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
.jsonObject
|
||||
|
||||
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
|
||||
assertNotNull(selection)
|
||||
assertEquals("elevenlabs", selection?.provider)
|
||||
assertTrue(selection?.normalizedPayload == true)
|
||||
assertEquals("voice-resolved", selection?.config?.get("voiceId")?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prefersNormalizedTalkProviderPayload() {
|
||||
val talk =
|
||||
@@ -31,11 +61,52 @@ class TalkModeConfigParsingTest {
|
||||
)
|
||||
.jsonObject
|
||||
|
||||
val selection = TalkModeManager.selectTalkProviderConfig(talk)
|
||||
assertNotNull(selection)
|
||||
assertEquals("elevenlabs", selection?.provider)
|
||||
assertTrue(selection?.normalizedPayload == true)
|
||||
assertEquals("voice-normalized", selection?.config?.get("voiceId")?.jsonPrimitive?.content)
|
||||
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
|
||||
assertEquals(null, selection)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsNormalizedTalkProviderPayloadWhenProviderMissingFromProviders() {
|
||||
val talk =
|
||||
json.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"provider": "acme",
|
||||
"providers": {
|
||||
"elevenlabs": {
|
||||
"voiceId": "voice-normalized"
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
.jsonObject
|
||||
|
||||
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
|
||||
assertEquals(null, selection)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsNormalizedTalkProviderPayloadWhenProviderIsAmbiguous() {
|
||||
val talk =
|
||||
json.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"providers": {
|
||||
"acme": {
|
||||
"voiceId": "voice-acme"
|
||||
},
|
||||
"elevenlabs": {
|
||||
"voiceId": "voice-normalized"
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
.jsonObject
|
||||
|
||||
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
|
||||
assertEquals(null, selection)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -47,11 +118,46 @@ class TalkModeConfigParsingTest {
|
||||
put("apiKey", legacyApiKey) // pragma: allowlist secret
|
||||
}
|
||||
|
||||
val selection = TalkModeManager.selectTalkProviderConfig(talk)
|
||||
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
|
||||
assertNotNull(selection)
|
||||
assertEquals("elevenlabs", selection?.provider)
|
||||
assertTrue(selection?.normalizedPayload == false)
|
||||
assertEquals("voice-legacy", selection?.config?.get("voiceId")?.jsonPrimitive?.content)
|
||||
assertEquals("legacy-key", selection?.config?.get("apiKey")?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsConfiguredSilenceTimeoutMs() {
|
||||
val talk = buildJsonObject { put("silenceTimeoutMs", 1500) }
|
||||
|
||||
assertEquals(1500L, TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultsSilenceTimeoutMsWhenMissing() {
|
||||
assertEquals(
|
||||
TalkDefaults.defaultSilenceTimeoutMs,
|
||||
TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(null),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultsSilenceTimeoutMsWhenInvalid() {
|
||||
val talk = buildJsonObject { put("silenceTimeoutMs", 0) }
|
||||
|
||||
assertEquals(
|
||||
TalkDefaults.defaultSilenceTimeoutMs,
|
||||
TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun defaultsSilenceTimeoutMsWhenString() {
|
||||
val talk = buildJsonObject { put("silenceTimeoutMs", "1500") }
|
||||
|
||||
assertEquals(
|
||||
TalkDefaults.defaultSilenceTimeoutMs,
|
||||
TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class TalkModeVoiceResolverTest {
|
||||
@Test
|
||||
fun resolvesVoiceAliasCaseInsensitively() {
|
||||
val resolved =
|
||||
TalkModeVoiceResolver.resolveVoiceAlias(
|
||||
" Clawd ",
|
||||
mapOf("clawd" to "voice-123"),
|
||||
)
|
||||
|
||||
assertEquals("voice-123", resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsDirectVoiceIds() {
|
||||
val resolved = TalkModeVoiceResolver.resolveVoiceAlias("21m00Tcm4TlvDq8ikWAM", emptyMap())
|
||||
|
||||
assertEquals("21m00Tcm4TlvDq8ikWAM", resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsUnknownAliases() {
|
||||
val resolved = TalkModeVoiceResolver.resolveVoiceAlias("nickname", emptyMap())
|
||||
|
||||
assertNull(resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reusesCachedFallbackVoiceBeforeFetchingCatalog() =
|
||||
runBlocking {
|
||||
var fetchCount = 0
|
||||
|
||||
val resolved =
|
||||
TalkModeVoiceResolver.resolveVoiceId(
|
||||
preferred = null,
|
||||
fallbackVoiceId = "cached-voice",
|
||||
defaultVoiceId = null,
|
||||
currentVoiceId = null,
|
||||
voiceOverrideActive = false,
|
||||
listVoices = {
|
||||
fetchCount += 1
|
||||
emptyList()
|
||||
},
|
||||
)
|
||||
|
||||
assertEquals("cached-voice", resolved.voiceId)
|
||||
assertEquals(0, fetchCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seedsDefaultVoiceFromCatalogWhenNeeded() =
|
||||
runBlocking {
|
||||
val resolved =
|
||||
TalkModeVoiceResolver.resolveVoiceId(
|
||||
preferred = null,
|
||||
fallbackVoiceId = null,
|
||||
defaultVoiceId = null,
|
||||
currentVoiceId = null,
|
||||
voiceOverrideActive = false,
|
||||
listVoices = { listOf(ElevenLabsVoice("voice-1", "First")) },
|
||||
)
|
||||
|
||||
assertEquals("voice-1", resolved.voiceId)
|
||||
assertEquals("voice-1", resolved.fallbackVoiceId)
|
||||
assertEquals("voice-1", resolved.defaultVoiceId)
|
||||
assertEquals("voice-1", resolved.currentVoiceId)
|
||||
assertEquals("First", resolved.selectedVoiceName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preservesCurrentVoiceWhenOverrideIsActive() =
|
||||
runBlocking {
|
||||
val resolved =
|
||||
TalkModeVoiceResolver.resolveVoiceId(
|
||||
preferred = null,
|
||||
fallbackVoiceId = null,
|
||||
defaultVoiceId = null,
|
||||
currentVoiceId = null,
|
||||
voiceOverrideActive = true,
|
||||
listVoices = { listOf(ElevenLabsVoice("voice-1", "First")) },
|
||||
)
|
||||
|
||||
assertEquals("voice-1", resolved.voiceId)
|
||||
assertNull(resolved.currentVoiceId)
|
||||
}
|
||||
}
|
||||
@@ -129,8 +129,7 @@ final class NodeAppModel {
|
||||
private var backgroundReconnectSuppressed = false
|
||||
private var backgroundReconnectLeaseUntil: Date?
|
||||
private var lastSignificantLocationWakeAt: Date?
|
||||
private var queuedWatchReplies: [WatchQuickReplyEvent] = []
|
||||
private var seenWatchReplyIds = Set<String>()
|
||||
@ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator()
|
||||
|
||||
private var gatewayConnected = false
|
||||
private var operatorConnected = false
|
||||
@@ -2199,37 +2198,22 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async {
|
||||
let replyId = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let actionId = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if replyId.isEmpty || actionId.isEmpty {
|
||||
switch self.watchReplyCoordinator.ingest(event, isGatewayConnected: await self.isGatewayConnected()) {
|
||||
case .dropMissingFields:
|
||||
self.watchReplyLogger.info("watch reply dropped: missing replyId/actionId")
|
||||
return
|
||||
}
|
||||
|
||||
if self.seenWatchReplyIds.contains(replyId) {
|
||||
case .deduped(let replyId):
|
||||
self.watchReplyLogger.debug(
|
||||
"watch reply deduped replyId=\(replyId, privacy: .public)")
|
||||
return
|
||||
}
|
||||
self.seenWatchReplyIds.insert(replyId)
|
||||
|
||||
if await !self.isGatewayConnected() {
|
||||
self.queuedWatchReplies.append(event)
|
||||
case .queue(let replyId, let actionId):
|
||||
self.watchReplyLogger.info(
|
||||
"watch reply queued replyId=\(replyId, privacy: .public) action=\(actionId, privacy: .public)")
|
||||
return
|
||||
case .forward:
|
||||
await self.forwardWatchReplyToAgent(event)
|
||||
}
|
||||
|
||||
await self.forwardWatchReplyToAgent(event)
|
||||
}
|
||||
|
||||
private func flushQueuedWatchRepliesIfConnected() async {
|
||||
guard await self.isGatewayConnected() else { return }
|
||||
guard !self.queuedWatchReplies.isEmpty else { return }
|
||||
|
||||
let pending = self.queuedWatchReplies
|
||||
self.queuedWatchReplies.removeAll()
|
||||
for event in pending {
|
||||
for event in self.watchReplyCoordinator.drainIfConnected(await self.isGatewayConnected()) {
|
||||
await self.forwardWatchReplyToAgent(event)
|
||||
}
|
||||
}
|
||||
@@ -2259,7 +2243,7 @@ extension NodeAppModel {
|
||||
"watch reply forwarding failed replyId=\(event.replyId) "
|
||||
+ "error=\(error.localizedDescription)"
|
||||
self.watchReplyLogger.error("\(failedMessage, privacy: .public)")
|
||||
self.queuedWatchReplies.insert(event, at: 0)
|
||||
self.watchReplyCoordinator.requeueFront(event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2852,7 +2836,7 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
func _test_queuedWatchReplyCount() -> Int {
|
||||
self.queuedWatchReplies.count
|
||||
self.watchReplyCoordinator.queuedCount
|
||||
}
|
||||
|
||||
func _test_setGatewayConnected(_ connected: Bool) {
|
||||
|
||||
46
apps/ios/Sources/Model/WatchReplyCoordinator.swift
Normal file
46
apps/ios/Sources/Model/WatchReplyCoordinator.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class WatchReplyCoordinator {
|
||||
enum Decision {
|
||||
case dropMissingFields
|
||||
case deduped(replyId: String)
|
||||
case queue(replyId: String, actionId: String)
|
||||
case forward
|
||||
}
|
||||
|
||||
private var queuedReplies: [WatchQuickReplyEvent] = []
|
||||
private var seenReplyIds = Set<String>()
|
||||
|
||||
func ingest(_ event: WatchQuickReplyEvent, isGatewayConnected: Bool) -> Decision {
|
||||
let replyId = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let actionId = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if replyId.isEmpty || actionId.isEmpty {
|
||||
return .dropMissingFields
|
||||
}
|
||||
if self.seenReplyIds.contains(replyId) {
|
||||
return .deduped(replyId: replyId)
|
||||
}
|
||||
self.seenReplyIds.insert(replyId)
|
||||
if !isGatewayConnected {
|
||||
self.queuedReplies.append(event)
|
||||
return .queue(replyId: replyId, actionId: actionId)
|
||||
}
|
||||
return .forward
|
||||
}
|
||||
|
||||
func drainIfConnected(_ isGatewayConnected: Bool) -> [WatchQuickReplyEvent] {
|
||||
guard isGatewayConnected, !self.queuedReplies.isEmpty else { return [] }
|
||||
let pending = self.queuedReplies
|
||||
self.queuedReplies.removeAll()
|
||||
return pending
|
||||
}
|
||||
|
||||
func requeueFront(_ event: WatchQuickReplyEvent) {
|
||||
self.queuedReplies.insert(event, at: 0)
|
||||
}
|
||||
|
||||
var queuedCount: Int {
|
||||
self.queuedReplies.count
|
||||
}
|
||||
}
|
||||
3
apps/ios/Sources/Voice/TalkDefaults.swift
Normal file
3
apps/ios/Sources/Voice/TalkDefaults.swift
Normal file
@@ -0,0 +1,3 @@
|
||||
enum TalkDefaults {
|
||||
static let silenceTimeoutMs = 900
|
||||
}
|
||||
69
apps/ios/Sources/Voice/TalkModeGatewayConfig.swift
Normal file
69
apps/ios/Sources/Voice/TalkModeGatewayConfig.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
struct TalkModeGatewayConfigState {
|
||||
let activeProvider: String
|
||||
let normalizedPayload: Bool
|
||||
let missingResolvedPayload: Bool
|
||||
let defaultVoiceId: String?
|
||||
let voiceAliases: [String: String]
|
||||
let defaultModelId: String
|
||||
let defaultOutputFormat: String?
|
||||
let rawConfigApiKey: String?
|
||||
let interruptOnSpeech: Bool?
|
||||
let silenceTimeoutMs: Int
|
||||
}
|
||||
|
||||
enum TalkModeGatewayConfigParser {
|
||||
static func parse(
|
||||
config: [String: Any],
|
||||
defaultProvider: String,
|
||||
defaultModelIdFallback: String,
|
||||
defaultSilenceTimeoutMs: Int
|
||||
) -> TalkModeGatewayConfigState {
|
||||
let talk = TalkConfigParsing.bridgeFoundationDictionary(config["talk"] as? [String: Any])
|
||||
let selection = TalkConfigParsing.selectProviderConfig(
|
||||
talk,
|
||||
defaultProvider: defaultProvider,
|
||||
allowLegacyFallback: false)
|
||||
let activeProvider = selection?.provider ?? defaultProvider
|
||||
let activeConfig = selection?.config
|
||||
let defaultVoiceId = activeConfig?["voiceId"]?.stringValue?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let voiceAliases: [String: String]
|
||||
if let aliases = activeConfig?["voiceAliases"]?.dictionaryValue {
|
||||
var resolved: [String: String] = [:]
|
||||
for (key, value) in aliases {
|
||||
guard let id = value.stringValue else { continue }
|
||||
let normalizedKey = key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let trimmedId = id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKey.isEmpty, !trimmedId.isEmpty else { continue }
|
||||
resolved[normalizedKey] = trimmedId
|
||||
}
|
||||
voiceAliases = resolved
|
||||
} else {
|
||||
voiceAliases = [:]
|
||||
}
|
||||
let model = activeConfig?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let defaultModelId = (model?.isEmpty == false) ? model! : defaultModelIdFallback
|
||||
let defaultOutputFormat = activeConfig?["outputFormat"]?.stringValue?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let rawConfigApiKey = activeConfig?["apiKey"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let interruptOnSpeech = talk?["interruptOnSpeech"]?.boolValue
|
||||
let silenceTimeoutMs = TalkConfigParsing.resolvedSilenceTimeoutMs(
|
||||
talk,
|
||||
fallback: defaultSilenceTimeoutMs)
|
||||
|
||||
return TalkModeGatewayConfigState(
|
||||
activeProvider: activeProvider,
|
||||
normalizedPayload: selection?.normalizedPayload == true,
|
||||
missingResolvedPayload: talk != nil && selection == nil,
|
||||
defaultVoiceId: defaultVoiceId,
|
||||
voiceAliases: voiceAliases,
|
||||
defaultModelId: defaultModelId,
|
||||
defaultOutputFormat: defaultOutputFormat,
|
||||
rawConfigApiKey: rawConfigApiKey,
|
||||
interruptOnSpeech: interruptOnSpeech,
|
||||
silenceTimeoutMs: silenceTimeoutMs)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ final class TalkModeManager: NSObject {
|
||||
private typealias SpeechRequest = SFSpeechAudioBufferRecognitionRequest
|
||||
private static let defaultModelIdFallback = "eleven_v3"
|
||||
private static let defaultTalkProvider = "elevenlabs"
|
||||
private static let defaultSilenceTimeoutMs = TalkDefaults.silenceTimeoutMs
|
||||
private static let redactedConfigSentinel = "__OPENCLAW_REDACTED__"
|
||||
var isEnabled: Bool = false
|
||||
var isListening: Bool = false
|
||||
@@ -97,7 +98,7 @@ final class TalkModeManager: NSObject {
|
||||
|
||||
private var gateway: GatewayNodeSession?
|
||||
private var gatewayConnected = false
|
||||
private let silenceWindow: TimeInterval = 0.9
|
||||
private var silenceWindow: TimeInterval = TimeInterval(TalkModeManager.defaultSilenceTimeoutMs) / 1000
|
||||
private var lastAudioActivity: Date?
|
||||
private var noiseFloorSamples: [Double] = []
|
||||
private var noiseFloor: Double?
|
||||
@@ -1969,38 +1970,6 @@ extension TalkModeManager {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
struct TalkProviderConfigSelection {
|
||||
let provider: String
|
||||
let config: [String: Any]
|
||||
}
|
||||
|
||||
private static func normalizedTalkProviderID(_ raw: String?) -> String? {
|
||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
static func selectTalkProviderConfig(_ talk: [String: Any]?) -> TalkProviderConfigSelection? {
|
||||
guard let talk else { return nil }
|
||||
let rawProvider = talk["provider"] as? String
|
||||
let rawProviders = talk["providers"] as? [String: Any]
|
||||
guard rawProvider != nil || rawProviders != nil else { return nil }
|
||||
let providers = rawProviders ?? [:]
|
||||
let normalizedProviders = providers.reduce(into: [String: [String: Any]]()) { acc, entry in
|
||||
guard
|
||||
let providerID = Self.normalizedTalkProviderID(entry.key),
|
||||
let config = entry.value as? [String: Any]
|
||||
else { return }
|
||||
acc[providerID] = config
|
||||
}
|
||||
let providerID =
|
||||
Self.normalizedTalkProviderID(rawProvider) ??
|
||||
normalizedProviders.keys.min() ??
|
||||
Self.defaultTalkProvider
|
||||
return TalkProviderConfigSelection(
|
||||
provider: providerID,
|
||||
config: normalizedProviders[providerID] ?? [:])
|
||||
}
|
||||
|
||||
func reloadConfig() async {
|
||||
guard let gateway else { return }
|
||||
self.pcmFormatUnavailable = false
|
||||
@@ -2012,40 +1981,27 @@ extension TalkModeManager {
|
||||
)
|
||||
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
|
||||
guard let config = json["config"] as? [String: Any] else { return }
|
||||
let talk = config["talk"] as? [String: Any]
|
||||
let selection = Self.selectTalkProviderConfig(talk)
|
||||
if talk != nil, selection == nil {
|
||||
let parsed = TalkModeGatewayConfigParser.parse(
|
||||
config: config,
|
||||
defaultProvider: Self.defaultTalkProvider,
|
||||
defaultModelIdFallback: Self.defaultModelIdFallback,
|
||||
defaultSilenceTimeoutMs: Self.defaultSilenceTimeoutMs)
|
||||
if parsed.missingResolvedPayload {
|
||||
GatewayDiagnostics.log(
|
||||
"talk config ignored: legacy payload unsupported on iOS beta; expected talk.provider/providers")
|
||||
}
|
||||
let activeProvider = selection?.provider ?? Self.defaultTalkProvider
|
||||
let activeConfig = selection?.config
|
||||
self.defaultVoiceId = (activeConfig?["voiceId"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let aliases = activeConfig?["voiceAliases"] as? [String: Any] {
|
||||
var resolved: [String: String] = [:]
|
||||
for (key, value) in aliases {
|
||||
guard let id = value as? String else { continue }
|
||||
let normalizedKey = key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let trimmedId = id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedKey.isEmpty, !trimmedId.isEmpty else { continue }
|
||||
resolved[normalizedKey] = trimmedId
|
||||
}
|
||||
self.voiceAliases = resolved
|
||||
} else {
|
||||
self.voiceAliases = [:]
|
||||
"talk config ignored: normalized payload missing talk.resolved")
|
||||
}
|
||||
let activeProvider = parsed.activeProvider
|
||||
self.defaultVoiceId = parsed.defaultVoiceId
|
||||
self.voiceAliases = parsed.voiceAliases
|
||||
if !self.voiceOverrideActive {
|
||||
self.currentVoiceId = self.defaultVoiceId
|
||||
}
|
||||
let model = (activeConfig?["modelId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.defaultModelId = (model?.isEmpty == false) ? model : Self.defaultModelIdFallback
|
||||
self.defaultModelId = parsed.defaultModelId
|
||||
if !self.modelOverrideActive {
|
||||
self.currentModelId = self.defaultModelId
|
||||
}
|
||||
self.defaultOutputFormat = (activeConfig?["outputFormat"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let rawConfigApiKey = (activeConfig?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.defaultOutputFormat = parsed.defaultOutputFormat
|
||||
let rawConfigApiKey = parsed.rawConfigApiKey
|
||||
let configApiKey = Self.normalizedTalkApiKey(rawConfigApiKey)
|
||||
let localApiKey = Self.normalizedTalkApiKey(
|
||||
GatewaySettingsStore.loadTalkProviderApiKey(provider: activeProvider))
|
||||
@@ -2064,11 +2020,13 @@ extension TalkModeManager {
|
||||
self.gatewayTalkDefaultModelId = self.defaultModelId
|
||||
self.gatewayTalkApiKeyConfigured = (self.apiKey?.isEmpty == false)
|
||||
self.gatewayTalkConfigLoaded = true
|
||||
if let interrupt = talk?["interruptOnSpeech"] as? Bool {
|
||||
if let interrupt = parsed.interruptOnSpeech {
|
||||
self.interruptOnSpeech = interrupt
|
||||
}
|
||||
if selection != nil {
|
||||
GatewayDiagnostics.log("talk config provider=\(activeProvider)")
|
||||
self.silenceWindow = TimeInterval(parsed.silenceTimeoutMs) / 1000
|
||||
if parsed.normalizedPayload || parsed.defaultVoiceId != nil || parsed.rawConfigApiKey != nil {
|
||||
GatewayDiagnostics.log(
|
||||
"talk config provider=\(activeProvider) silenceTimeoutMs=\(parsed.silenceTimeoutMs)")
|
||||
}
|
||||
} catch {
|
||||
self.defaultModelId = Self.defaultModelIdFallback
|
||||
@@ -2079,6 +2037,7 @@ extension TalkModeManager {
|
||||
self.gatewayTalkDefaultModelId = nil
|
||||
self.gatewayTalkApiKeyConfigured = false
|
||||
self.gatewayTalkConfigLoaded = false
|
||||
self.silenceWindow = TimeInterval(Self.defaultSilenceTimeoutMs) / 1000
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ Sources/OpenClawApp.swift
|
||||
Sources/Location/LocationService.swift
|
||||
Sources/Model/NodeAppModel.swift
|
||||
Sources/Model/NodeAppModel+Canvas.swift
|
||||
Sources/Model/WatchReplyCoordinator.swift
|
||||
Sources/RootCanvas.swift
|
||||
Sources/RootTabs.swift
|
||||
Sources/Screen/ScreenController.swift
|
||||
|
||||
75
apps/ios/Tests/Logic/TalkConfigParsingTests.swift
Normal file
75
apps/ios/Tests/Logic/TalkConfigParsingTests.swift
Normal file
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
|
||||
private let iOSSilenceTimeoutMs = 900
|
||||
|
||||
@Suite struct TalkConfigParsingTests {
|
||||
@Test func rejectsNormalizedTalkProviderPayloadWithoutResolved() {
|
||||
let talk: [String: Any] = [
|
||||
"provider": "elevenlabs",
|
||||
"providers": [
|
||||
"elevenlabs": [
|
||||
"voiceId": "voice-normalized",
|
||||
],
|
||||
],
|
||||
"voiceId": "voice-legacy",
|
||||
]
|
||||
|
||||
let selection = TalkConfigParsing.selectProviderConfig(
|
||||
TalkConfigParsing.bridgeFoundationDictionary(talk),
|
||||
defaultProvider: "elevenlabs",
|
||||
allowLegacyFallback: false)
|
||||
#expect(selection == nil)
|
||||
}
|
||||
|
||||
@Test func ignoresLegacyTalkFieldsWhenNormalizedPayloadMissing() {
|
||||
let talk: [String: Any] = [
|
||||
"voiceId": "voice-legacy",
|
||||
"apiKey": "legacy-key", // pragma: allowlist secret
|
||||
]
|
||||
|
||||
let selection = TalkConfigParsing.selectProviderConfig(
|
||||
TalkConfigParsing.bridgeFoundationDictionary(talk),
|
||||
defaultProvider: "elevenlabs",
|
||||
allowLegacyFallback: false)
|
||||
#expect(selection == nil)
|
||||
}
|
||||
|
||||
@Test func readsConfiguredSilenceTimeoutMs() {
|
||||
let talk: [String: Any] = [
|
||||
"silenceTimeoutMs": 1500,
|
||||
]
|
||||
|
||||
#expect(
|
||||
TalkConfigParsing.resolvedSilenceTimeoutMs(
|
||||
TalkConfigParsing.bridgeFoundationDictionary(talk),
|
||||
fallback: iOSSilenceTimeoutMs) == 1500)
|
||||
}
|
||||
|
||||
@Test func defaultsSilenceTimeoutMsWhenMissing() {
|
||||
#expect(TalkConfigParsing.resolvedSilenceTimeoutMs(nil, fallback: iOSSilenceTimeoutMs) == iOSSilenceTimeoutMs)
|
||||
}
|
||||
|
||||
@Test func defaultsSilenceTimeoutMsWhenInvalid() {
|
||||
let talk: [String: Any] = [
|
||||
"silenceTimeoutMs": 0,
|
||||
]
|
||||
|
||||
#expect(
|
||||
TalkConfigParsing.resolvedSilenceTimeoutMs(
|
||||
TalkConfigParsing.bridgeFoundationDictionary(talk),
|
||||
fallback: iOSSilenceTimeoutMs) == iOSSilenceTimeoutMs)
|
||||
}
|
||||
|
||||
@Test func defaultsSilenceTimeoutMsWhenBool() {
|
||||
let talk: [String: Any] = [
|
||||
"silenceTimeoutMs": true,
|
||||
]
|
||||
|
||||
#expect(
|
||||
TalkConfigParsing.resolvedSilenceTimeoutMs(
|
||||
TalkConfigParsing.bridgeFoundationDictionary(talk),
|
||||
fallback: iOSSilenceTimeoutMs) == iOSSilenceTimeoutMs)
|
||||
}
|
||||
}
|
||||
@@ -3,33 +3,7 @@ import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@MainActor
|
||||
@Suite struct TalkModeConfigParsingTests {
|
||||
@Test func prefersNormalizedTalkProviderPayload() {
|
||||
let talk: [String: Any] = [
|
||||
"provider": "elevenlabs",
|
||||
"providers": [
|
||||
"elevenlabs": [
|
||||
"voiceId": "voice-normalized",
|
||||
],
|
||||
],
|
||||
"voiceId": "voice-legacy",
|
||||
]
|
||||
|
||||
let selection = TalkModeManager.selectTalkProviderConfig(talk)
|
||||
#expect(selection?.provider == "elevenlabs")
|
||||
#expect(selection?.config["voiceId"] as? String == "voice-normalized")
|
||||
}
|
||||
|
||||
@Test func ignoresLegacyTalkFieldsWhenNormalizedPayloadMissing() {
|
||||
let talk: [String: Any] = [
|
||||
"voiceId": "voice-legacy",
|
||||
"apiKey": "legacy-key", // pragma: allowlist secret
|
||||
]
|
||||
|
||||
let selection = TalkModeManager.selectTalkProviderConfig(talk)
|
||||
#expect(selection == nil)
|
||||
}
|
||||
|
||||
@Suite struct TalkModeManagerTests {
|
||||
@Test func detectsPCMFormatRejectionFromElevenLabsError() {
|
||||
let error = NSError(
|
||||
domain: "ElevenLabsTTS",
|
||||
|
||||
@@ -25,6 +25,15 @@ schemes:
|
||||
test:
|
||||
targets:
|
||||
- OpenClawTests
|
||||
- OpenClawLogicTests
|
||||
OpenClawLogicTests:
|
||||
shared: true
|
||||
build:
|
||||
targets:
|
||||
OpenClawLogicTests: all
|
||||
test:
|
||||
targets:
|
||||
- OpenClawLogicTests
|
||||
|
||||
targets:
|
||||
OpenClaw:
|
||||
@@ -117,8 +126,11 @@ targets:
|
||||
NSLocationWhenInUseUsageDescription: OpenClaw uses your location when you allow location sharing.
|
||||
NSLocationAlwaysAndWhenInUseUsageDescription: OpenClaw can share your location in the background when you enable Always.
|
||||
NSMicrophoneUsageDescription: OpenClaw needs microphone access for voice wake.
|
||||
NSMotionUsageDescription: OpenClaw may use motion data to support device-aware interactions and automations.
|
||||
NSPhotoLibraryUsageDescription: OpenClaw needs photo library access when you choose existing photos to share with your assistant.
|
||||
NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for voice wake.
|
||||
NSSupportsLiveActivities: true
|
||||
ITSAppUsesNonExemptEncryption: false
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
- UIInterfaceOrientationPortraitUpsideDown
|
||||
@@ -259,6 +271,8 @@ targets:
|
||||
Release: Signing.xcconfig
|
||||
sources:
|
||||
- path: Tests
|
||||
excludes:
|
||||
- Logic
|
||||
dependencies:
|
||||
- target: OpenClaw
|
||||
- package: Swabble
|
||||
@@ -281,3 +295,29 @@ targets:
|
||||
CFBundleDisplayName: OpenClawTests
|
||||
CFBundleShortVersionString: "2026.3.8"
|
||||
CFBundleVersion: "20260308"
|
||||
|
||||
OpenClawLogicTests:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
configFiles:
|
||||
Debug: Signing.xcconfig
|
||||
Release: Signing.xcconfig
|
||||
sources:
|
||||
- path: Tests/Logic
|
||||
dependencies:
|
||||
- package: OpenClawKit
|
||||
settings:
|
||||
base:
|
||||
CODE_SIGN_IDENTITY: "Apple Development"
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: ai.openclaw.ios.logic-tests
|
||||
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
info:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClawLogicTests
|
||||
CFBundleShortVersionString: "2026.3.8"
|
||||
CFBundleVersion: "20260308"
|
||||
|
||||
@@ -4,40 +4,3 @@ import OpenClawKit
|
||||
// Prefer the OpenClawKit wrapper to keep gateway request payloads consistent.
|
||||
typealias AnyCodable = OpenClawKit.AnyCodable
|
||||
typealias InstanceIdentity = OpenClawKit.InstanceIdentity
|
||||
|
||||
extension AnyCodable {
|
||||
var stringValue: String? {
|
||||
self.value as? String
|
||||
}
|
||||
|
||||
var boolValue: Bool? {
|
||||
self.value as? Bool
|
||||
}
|
||||
|
||||
var intValue: Int? {
|
||||
self.value as? Int
|
||||
}
|
||||
|
||||
var doubleValue: Double? {
|
||||
self.value as? Double
|
||||
}
|
||||
|
||||
var dictionaryValue: [String: AnyCodable]? {
|
||||
self.value as? [String: AnyCodable]
|
||||
}
|
||||
|
||||
var arrayValue: [AnyCodable]? {
|
||||
self.value as? [AnyCodable]
|
||||
}
|
||||
|
||||
var foundationValue: Any {
|
||||
switch self.value {
|
||||
case let dict as [String: AnyCodable]:
|
||||
dict.mapValues { $0.foundationValue }
|
||||
case let array as [AnyCodable]:
|
||||
array.map(\.foundationValue)
|
||||
default:
|
||||
self.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3
apps/macos/Sources/OpenClaw/TalkDefaults.swift
Normal file
3
apps/macos/Sources/OpenClaw/TalkDefaults.swift
Normal file
@@ -0,0 +1,3 @@
|
||||
enum TalkDefaults {
|
||||
static let silenceTimeoutMs = 700
|
||||
}
|
||||
104
apps/macos/Sources/OpenClaw/TalkModeGatewayConfig.swift
Normal file
104
apps/macos/Sources/OpenClaw/TalkModeGatewayConfig.swift
Normal file
@@ -0,0 +1,104 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
struct TalkModeGatewayConfigState {
|
||||
let activeProvider: String
|
||||
let normalizedPayload: Bool
|
||||
let missingResolvedPayload: Bool
|
||||
let voiceId: String?
|
||||
let voiceAliases: [String: String]
|
||||
let modelId: String?
|
||||
let outputFormat: String?
|
||||
let interruptOnSpeech: Bool
|
||||
let silenceTimeoutMs: Int
|
||||
let apiKey: String?
|
||||
let seamColorHex: String?
|
||||
}
|
||||
|
||||
enum TalkModeGatewayConfigParser {
|
||||
static func parse(
|
||||
snapshot: ConfigSnapshot,
|
||||
defaultProvider: String,
|
||||
defaultModelIdFallback: String,
|
||||
defaultSilenceTimeoutMs: Int,
|
||||
envVoice: String?,
|
||||
sagVoice: String?,
|
||||
envApiKey: String?
|
||||
) -> TalkModeGatewayConfigState {
|
||||
let talk = snapshot.config?["talk"]?.dictionaryValue
|
||||
let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: defaultProvider)
|
||||
let activeProvider = selection?.provider ?? defaultProvider
|
||||
let activeConfig = selection?.config
|
||||
let silenceTimeoutMs = TalkConfigParsing.resolvedSilenceTimeoutMs(
|
||||
talk,
|
||||
fallback: defaultSilenceTimeoutMs)
|
||||
let ui = snapshot.config?["ui"]?.dictionaryValue
|
||||
let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let voice = activeConfig?["voiceId"]?.stringValue
|
||||
let rawAliases = activeConfig?["voiceAliases"]?.dictionaryValue
|
||||
let resolvedAliases: [String: String] =
|
||||
rawAliases?.reduce(into: [:]) { acc, entry in
|
||||
let key = entry.key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let value = entry.value.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !key.isEmpty, !value.isEmpty else { return }
|
||||
acc[key] = value
|
||||
} ?? [:]
|
||||
let model = activeConfig?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let resolvedModel = (model?.isEmpty == false) ? model! : defaultModelIdFallback
|
||||
let outputFormat = activeConfig?["outputFormat"]?.stringValue
|
||||
let interrupt = talk?["interruptOnSpeech"]?.boolValue
|
||||
let apiKey = activeConfig?["apiKey"]?.stringValue
|
||||
let resolvedVoice: String? = if activeProvider == defaultProvider {
|
||||
(voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) ??
|
||||
(envVoice?.isEmpty == false ? envVoice : nil) ??
|
||||
(sagVoice?.isEmpty == false ? sagVoice : nil)
|
||||
} else {
|
||||
(voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil)
|
||||
}
|
||||
let resolvedApiKey: String? = if activeProvider == defaultProvider {
|
||||
(envApiKey?.isEmpty == false ? envApiKey : nil) ??
|
||||
(apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
||||
return TalkModeGatewayConfigState(
|
||||
activeProvider: activeProvider,
|
||||
normalizedPayload: selection?.normalizedPayload == true,
|
||||
missingResolvedPayload: talk != nil && selection == nil,
|
||||
voiceId: resolvedVoice,
|
||||
voiceAliases: resolvedAliases,
|
||||
modelId: resolvedModel,
|
||||
outputFormat: outputFormat,
|
||||
interruptOnSpeech: interrupt ?? true,
|
||||
silenceTimeoutMs: silenceTimeoutMs,
|
||||
apiKey: resolvedApiKey,
|
||||
seamColorHex: rawSeam.isEmpty ? nil : rawSeam)
|
||||
}
|
||||
|
||||
static func fallback(
|
||||
defaultModelIdFallback: String,
|
||||
defaultSilenceTimeoutMs: Int,
|
||||
envVoice: String?,
|
||||
sagVoice: String?,
|
||||
envApiKey: String?
|
||||
) -> TalkModeGatewayConfigState {
|
||||
let resolvedVoice =
|
||||
(envVoice?.isEmpty == false ? envVoice : nil) ??
|
||||
(sagVoice?.isEmpty == false ? sagVoice : nil)
|
||||
let resolvedApiKey = envApiKey?.isEmpty == false ? envApiKey : nil
|
||||
|
||||
return TalkModeGatewayConfigState(
|
||||
activeProvider: "elevenlabs",
|
||||
normalizedPayload: false,
|
||||
missingResolvedPayload: false,
|
||||
voiceId: resolvedVoice,
|
||||
voiceAliases: [:],
|
||||
modelId: defaultModelIdFallback,
|
||||
outputFormat: nil,
|
||||
interruptOnSpeech: true,
|
||||
silenceTimeoutMs: defaultSilenceTimeoutMs,
|
||||
apiKey: resolvedApiKey,
|
||||
seamColorHex: nil)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ actor TalkModeRuntime {
|
||||
private let ttsLogger = Logger(subsystem: "ai.openclaw", category: "talk.tts")
|
||||
private static let defaultModelIdFallback = "eleven_v3"
|
||||
private static let defaultTalkProvider = "elevenlabs"
|
||||
private static let defaultSilenceTimeoutMs = TalkDefaults.silenceTimeoutMs
|
||||
|
||||
private final class RMSMeter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
@@ -66,10 +67,15 @@ actor TalkModeRuntime {
|
||||
private var fallbackVoiceId: String?
|
||||
private var lastPlaybackWasPCM: Bool = false
|
||||
|
||||
private let silenceWindow: TimeInterval = 0.7
|
||||
private var silenceWindow: TimeInterval = .init(TalkModeRuntime.defaultSilenceTimeoutMs) / 1000
|
||||
private let minSpeechRMS: Double = 1e-3
|
||||
private let speechBoostFactor: Double = 6.0
|
||||
|
||||
static func configureRecognitionRequest(_ request: SFSpeechAudioBufferRecognitionRequest) {
|
||||
request.shouldReportPartialResults = true
|
||||
request.taskHint = .dictation
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func setEnabled(_ enabled: Bool) async {
|
||||
@@ -176,9 +182,9 @@ actor TalkModeRuntime {
|
||||
return
|
||||
}
|
||||
|
||||
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
|
||||
self.recognitionRequest?.shouldReportPartialResults = true
|
||||
guard let request = self.recognitionRequest else { return }
|
||||
let request = SFSpeechAudioBufferRecognitionRequest()
|
||||
Self.configureRecognitionRequest(request)
|
||||
self.recognitionRequest = request
|
||||
|
||||
if self.audioEngine == nil {
|
||||
self.audioEngine = AVAudioEngine()
|
||||
@@ -778,6 +784,7 @@ extension TalkModeRuntime {
|
||||
}
|
||||
self.defaultOutputFormat = cfg.outputFormat
|
||||
self.interruptOnSpeech = cfg.interruptOnSpeech
|
||||
self.silenceWindow = TimeInterval(cfg.silenceTimeoutMs) / 1000
|
||||
self.apiKey = cfg.apiKey
|
||||
let hasApiKey = (cfg.apiKey?.isEmpty == false)
|
||||
let voiceLabel = (cfg.voiceId?.isEmpty == false) ? cfg.voiceId! : "none"
|
||||
@@ -787,95 +794,21 @@ extension TalkModeRuntime {
|
||||
"talk config voiceId=\(voiceLabel, privacy: .public) " +
|
||||
"modelId=\(modelLabel, privacy: .public) " +
|
||||
"apiKey=\(hasApiKey, privacy: .public) " +
|
||||
"interrupt=\(cfg.interruptOnSpeech, privacy: .public)")
|
||||
}
|
||||
|
||||
private struct TalkRuntimeConfig {
|
||||
let voiceId: String?
|
||||
let voiceAliases: [String: String]
|
||||
let modelId: String?
|
||||
let outputFormat: String?
|
||||
let interruptOnSpeech: Bool
|
||||
let apiKey: String?
|
||||
}
|
||||
|
||||
struct TalkProviderConfigSelection {
|
||||
let provider: String
|
||||
let config: [String: AnyCodable]
|
||||
let normalizedPayload: Bool
|
||||
}
|
||||
|
||||
private static func normalizedTalkProviderID(_ raw: String?) -> String? {
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func normalizedTalkProviderConfig(_ value: AnyCodable) -> [String: AnyCodable]? {
|
||||
if let typed = value.value as? [String: AnyCodable] {
|
||||
return typed
|
||||
}
|
||||
if let foundation = value.value as? [String: Any] {
|
||||
return foundation.mapValues(AnyCodable.init)
|
||||
}
|
||||
if let nsDict = value.value as? NSDictionary {
|
||||
var converted: [String: AnyCodable] = [:]
|
||||
for case let (key as String, raw) in nsDict {
|
||||
converted[key] = AnyCodable(raw)
|
||||
}
|
||||
return converted
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func normalizedTalkProviders(_ raw: AnyCodable?) -> [String: [String: AnyCodable]] {
|
||||
guard let raw else { return [:] }
|
||||
var providerMap: [String: AnyCodable] = [:]
|
||||
if let typed = raw.value as? [String: AnyCodable] {
|
||||
providerMap = typed
|
||||
} else if let foundation = raw.value as? [String: Any] {
|
||||
providerMap = foundation.mapValues(AnyCodable.init)
|
||||
} else if let nsDict = raw.value as? NSDictionary {
|
||||
for case let (key as String, value) in nsDict {
|
||||
providerMap[key] = AnyCodable(value)
|
||||
}
|
||||
} else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
return providerMap.reduce(into: [String: [String: AnyCodable]]()) { acc, entry in
|
||||
guard
|
||||
let providerID = Self.normalizedTalkProviderID(entry.key),
|
||||
let providerConfig = Self.normalizedTalkProviderConfig(entry.value)
|
||||
else { return }
|
||||
acc[providerID] = providerConfig
|
||||
}
|
||||
"interrupt=\(cfg.interruptOnSpeech, privacy: .public) " +
|
||||
"silenceTimeoutMs=\(cfg.silenceTimeoutMs, privacy: .public)")
|
||||
}
|
||||
|
||||
static func selectTalkProviderConfig(
|
||||
_ talk: [String: AnyCodable]?) -> TalkProviderConfigSelection?
|
||||
{
|
||||
guard let talk else { return nil }
|
||||
let rawProvider = talk["provider"]?.stringValue
|
||||
let rawProviders = talk["providers"]
|
||||
let hasNormalizedPayload = rawProvider != nil || rawProviders != nil
|
||||
if hasNormalizedPayload {
|
||||
let normalizedProviders = Self.normalizedTalkProviders(rawProviders)
|
||||
let providerID =
|
||||
Self.normalizedTalkProviderID(rawProvider) ??
|
||||
normalizedProviders.keys.min() ??
|
||||
Self.defaultTalkProvider
|
||||
return TalkProviderConfigSelection(
|
||||
provider: providerID,
|
||||
config: normalizedProviders[providerID] ?? [:],
|
||||
normalizedPayload: true)
|
||||
}
|
||||
return TalkProviderConfigSelection(
|
||||
provider: Self.defaultTalkProvider,
|
||||
config: talk,
|
||||
normalizedPayload: false)
|
||||
TalkConfigParsing.selectProviderConfig(talk, defaultProvider: self.defaultTalkProvider)
|
||||
}
|
||||
|
||||
private func fetchTalkConfig() async -> TalkRuntimeConfig {
|
||||
static func resolvedSilenceTimeoutMs(_ talk: [String: AnyCodable]?) -> Int {
|
||||
TalkConfigParsing.resolvedSilenceTimeoutMs(talk, fallback: self.defaultSilenceTimeoutMs)
|
||||
}
|
||||
|
||||
private func fetchTalkConfig() async -> TalkModeGatewayConfigState {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
let envVoice = env["ELEVENLABS_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let sagVoice = env["SAG_VOICE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -886,67 +819,34 @@ extension TalkModeRuntime {
|
||||
method: .talkConfig,
|
||||
params: ["includeSecrets": AnyCodable(true)],
|
||||
timeoutMs: 8000)
|
||||
let talk = snap.config?["talk"]?.dictionaryValue
|
||||
let selection = Self.selectTalkProviderConfig(talk)
|
||||
let activeProvider = selection?.provider ?? Self.defaultTalkProvider
|
||||
let activeConfig = selection?.config
|
||||
let ui = snap.config?["ui"]?.dictionaryValue
|
||||
let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let parsed = TalkModeGatewayConfigParser.parse(
|
||||
snapshot: snap,
|
||||
defaultProvider: Self.defaultTalkProvider,
|
||||
defaultModelIdFallback: Self.defaultModelIdFallback,
|
||||
defaultSilenceTimeoutMs: Self.defaultSilenceTimeoutMs,
|
||||
envVoice: envVoice,
|
||||
sagVoice: sagVoice,
|
||||
envApiKey: envApiKey)
|
||||
if parsed.missingResolvedPayload {
|
||||
self.ttsLogger.info("talk config ignored: normalized payload missing talk.resolved")
|
||||
}
|
||||
await MainActor.run {
|
||||
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
|
||||
AppStateStore.shared.seamColorHex = parsed.seamColorHex
|
||||
}
|
||||
let voice = activeConfig?["voiceId"]?.stringValue
|
||||
let rawAliases = activeConfig?["voiceAliases"]?.dictionaryValue
|
||||
let resolvedAliases: [String: String] =
|
||||
rawAliases?.reduce(into: [:]) { acc, entry in
|
||||
let key = entry.key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let value = entry.value.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !key.isEmpty, !value.isEmpty else { return }
|
||||
acc[key] = value
|
||||
} ?? [:]
|
||||
let model = activeConfig?["modelId"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let resolvedModel = (model?.isEmpty == false) ? model! : Self.defaultModelIdFallback
|
||||
let outputFormat = activeConfig?["outputFormat"]?.stringValue
|
||||
let interrupt = talk?["interruptOnSpeech"]?.boolValue
|
||||
let apiKey = activeConfig?["apiKey"]?.stringValue
|
||||
let resolvedVoice: String? = if activeProvider == Self.defaultTalkProvider {
|
||||
(voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) ??
|
||||
(envVoice?.isEmpty == false ? envVoice : nil) ??
|
||||
(sagVoice?.isEmpty == false ? sagVoice : nil)
|
||||
} else {
|
||||
(voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil)
|
||||
}
|
||||
let resolvedApiKey: String? = if activeProvider == Self.defaultTalkProvider {
|
||||
(envApiKey?.isEmpty == false ? envApiKey : nil) ??
|
||||
(apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? apiKey : nil)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
if activeProvider != Self.defaultTalkProvider {
|
||||
if parsed.activeProvider != Self.defaultTalkProvider {
|
||||
self.ttsLogger
|
||||
.info("talk provider \(activeProvider, privacy: .public) unsupported; using system voice")
|
||||
} else if selection?.normalizedPayload == true {
|
||||
self.ttsLogger.info("talk config provider elevenlabs")
|
||||
.info("talk provider \(parsed.activeProvider, privacy: .public) unsupported; using system voice")
|
||||
} else if parsed.normalizedPayload {
|
||||
self.ttsLogger.info("talk config provider from talk.resolved")
|
||||
}
|
||||
return TalkRuntimeConfig(
|
||||
voiceId: resolvedVoice,
|
||||
voiceAliases: resolvedAliases,
|
||||
modelId: resolvedModel,
|
||||
outputFormat: outputFormat,
|
||||
interruptOnSpeech: interrupt ?? true,
|
||||
apiKey: resolvedApiKey)
|
||||
return parsed
|
||||
} catch {
|
||||
let resolvedVoice =
|
||||
(envVoice?.isEmpty == false ? envVoice : nil) ??
|
||||
(sagVoice?.isEmpty == false ? sagVoice : nil)
|
||||
let resolvedApiKey = envApiKey?.isEmpty == false ? envApiKey : nil
|
||||
return TalkRuntimeConfig(
|
||||
voiceId: resolvedVoice,
|
||||
voiceAliases: [:],
|
||||
modelId: Self.defaultModelIdFallback,
|
||||
outputFormat: nil,
|
||||
interruptOnSpeech: true,
|
||||
apiKey: resolvedApiKey)
|
||||
return TalkModeGatewayConfigParser.fallback(
|
||||
defaultModelIdFallback: Self.defaultModelIdFallback,
|
||||
defaultSilenceTimeoutMs: Self.defaultSilenceTimeoutMs,
|
||||
envVoice: envVoice,
|
||||
sagVoice: sagVoice,
|
||||
envApiKey: envApiKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,15 @@ struct LowCoverageViewSmokeTests {
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
}
|
||||
|
||||
@Test func `talk overlay presents twice and dismisses`() async {
|
||||
let controller = TalkOverlayController()
|
||||
controller.present()
|
||||
controller.updateLevel(0.4)
|
||||
controller.present()
|
||||
controller.dismiss()
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
}
|
||||
|
||||
@Test func `visual effect view hosts in NS hosting view`() {
|
||||
let hosting = NSHostingView(rootView: VisualEffectView(material: .sidebar))
|
||||
_ = hosting.fittingSize
|
||||
|
||||
@@ -3,7 +3,7 @@ import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct TalkModeConfigParsingTests {
|
||||
@Test func `prefers normalized talk provider payload`() {
|
||||
@Test func `rejects normalized talk provider payload without resolved`() {
|
||||
let talk: [String: AnyCodable] = [
|
||||
"provider": AnyCodable("elevenlabs"),
|
||||
"providers": AnyCodable([
|
||||
@@ -15,9 +15,7 @@ struct TalkModeConfigParsingTests {
|
||||
]
|
||||
|
||||
let selection = TalkModeRuntime.selectTalkProviderConfig(talk)
|
||||
#expect(selection?.provider == "elevenlabs")
|
||||
#expect(selection?.normalizedPayload == true)
|
||||
#expect(selection?.config["voiceId"]?.stringValue == "voice-normalized")
|
||||
#expect(selection == nil)
|
||||
}
|
||||
|
||||
@Test func `falls back to legacy talk fields when normalized payload missing`() {
|
||||
@@ -32,4 +30,24 @@ struct TalkModeConfigParsingTests {
|
||||
#expect(selection?.config["voiceId"]?.stringValue == "voice-legacy")
|
||||
#expect(selection?.config["apiKey"]?.stringValue == "legacy-key")
|
||||
}
|
||||
|
||||
@Test func `reads configured silence timeout ms`() {
|
||||
let talk: [String: AnyCodable] = [
|
||||
"silenceTimeoutMs": AnyCodable(1500),
|
||||
]
|
||||
|
||||
#expect(TalkModeRuntime.resolvedSilenceTimeoutMs(talk) == 1500)
|
||||
}
|
||||
|
||||
@Test func `defaults silence timeout ms when missing`() {
|
||||
#expect(TalkModeRuntime.resolvedSilenceTimeoutMs(nil) == TalkDefaults.silenceTimeoutMs)
|
||||
}
|
||||
|
||||
@Test func `defaults silence timeout ms when invalid`() {
|
||||
let talk: [String: AnyCodable] = [
|
||||
"silenceTimeoutMs": AnyCodable(0),
|
||||
]
|
||||
|
||||
#expect(TalkModeRuntime.resolvedSilenceTimeoutMs(talk) == TalkDefaults.silenceTimeoutMs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import Speech
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct TalkModeRuntimeSpeechTests {
|
||||
@Test func `speech request uses dictation defaults`() {
|
||||
let request = SFSpeechAudioBufferRecognitionRequest()
|
||||
|
||||
TalkModeRuntime.configureRecognitionRequest(request)
|
||||
|
||||
#expect(request.shouldReportPartialResults)
|
||||
#expect(request.taskHint == .dictation)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
|
||||
public extension AnyCodable {
|
||||
var stringValue: String? {
|
||||
self.value as? String
|
||||
}
|
||||
|
||||
var boolValue: Bool? {
|
||||
if let value = self.value as? Bool {
|
||||
return value
|
||||
}
|
||||
if let number = self.value as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() {
|
||||
return number.boolValue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var intValue: Int? {
|
||||
if let value = self.value as? Int {
|
||||
return value
|
||||
}
|
||||
if let number = self.value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() {
|
||||
let value = number.doubleValue
|
||||
if value > 0, value.rounded(.towardZero) == value, value <= Double(Int.max) {
|
||||
return Int(value)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var doubleValue: Double? {
|
||||
if let value = self.value as? Double {
|
||||
return value
|
||||
}
|
||||
if let value = self.value as? Int {
|
||||
return Double(value)
|
||||
}
|
||||
if let number = self.value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() {
|
||||
return number.doubleValue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var dictionaryValue: [String: AnyCodable]? {
|
||||
if let value = self.value as? [String: AnyCodable] {
|
||||
return value
|
||||
}
|
||||
if let value = self.value as? [String: Any] {
|
||||
return value.mapValues(AnyCodable.init)
|
||||
}
|
||||
if let value = self.value as? NSDictionary {
|
||||
var converted: [String: AnyCodable] = [:]
|
||||
for case let (key as String, raw) in value {
|
||||
converted[key] = AnyCodable(raw)
|
||||
}
|
||||
return converted
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var arrayValue: [AnyCodable]? {
|
||||
if let value = self.value as? [AnyCodable] {
|
||||
return value
|
||||
}
|
||||
if let value = self.value as? [Any] {
|
||||
return value.map(AnyCodable.init)
|
||||
}
|
||||
if let value = self.value as? NSArray {
|
||||
return value.map(AnyCodable.init)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var foundationValue: Any {
|
||||
switch self.value {
|
||||
case let dict as [String: AnyCodable]:
|
||||
dict.mapValues(\.foundationValue)
|
||||
case let array as [AnyCodable]:
|
||||
array.map(\.foundationValue)
|
||||
case let dict as [String: Any]:
|
||||
dict.mapValues { AnyCodable($0).foundationValue }
|
||||
case let array as [Any]:
|
||||
array.map { AnyCodable($0).foundationValue }
|
||||
default:
|
||||
self.value
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
|
||||
public struct TalkProviderConfigSelection: Sendable {
|
||||
public let provider: String
|
||||
public let config: [String: AnyCodable]
|
||||
public let normalizedPayload: Bool
|
||||
|
||||
public init(provider: String, config: [String: AnyCodable], normalizedPayload: Bool) {
|
||||
self.provider = provider
|
||||
self.config = config
|
||||
self.normalizedPayload = normalizedPayload
|
||||
}
|
||||
}
|
||||
|
||||
public enum TalkConfigParsing {
|
||||
public static func bridgeFoundationDictionary(_ raw: [String: Any]?) -> [String: AnyCodable]? {
|
||||
raw?.mapValues(AnyCodable.init)
|
||||
}
|
||||
|
||||
public static func selectProviderConfig(
|
||||
_ talk: [String: AnyCodable]?,
|
||||
defaultProvider: String,
|
||||
allowLegacyFallback: Bool = true,
|
||||
) -> TalkProviderConfigSelection? {
|
||||
guard let talk else { return nil }
|
||||
if let resolvedSelection = self.resolvedProviderConfig(talk) {
|
||||
return resolvedSelection
|
||||
}
|
||||
let hasNormalizedPayload = talk["provider"] != nil || talk["providers"] != nil
|
||||
if hasNormalizedPayload {
|
||||
return nil
|
||||
}
|
||||
guard allowLegacyFallback else { return nil }
|
||||
return TalkProviderConfigSelection(
|
||||
provider: defaultProvider,
|
||||
config: talk,
|
||||
normalizedPayload: false)
|
||||
}
|
||||
|
||||
public static func resolvedPositiveInt(_ value: AnyCodable?, fallback: Int) -> Int {
|
||||
if let timeout = value?.intValue, timeout > 0 {
|
||||
return timeout
|
||||
}
|
||||
if
|
||||
let timeout = value?.doubleValue,
|
||||
timeout > 0,
|
||||
timeout.rounded(.towardZero) == timeout,
|
||||
timeout <= Double(Int.max)
|
||||
{
|
||||
return Int(timeout)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
public static func resolvedSilenceTimeoutMs(_ talk: [String: AnyCodable]?, fallback: Int) -> Int {
|
||||
self.resolvedPositiveInt(talk?["silenceTimeoutMs"], fallback: fallback)
|
||||
}
|
||||
|
||||
private static func normalizedTalkProviderID(_ raw: String?) -> String? {
|
||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func resolvedProviderConfig(
|
||||
_ talk: [String: AnyCodable]
|
||||
) -> TalkProviderConfigSelection? {
|
||||
guard
|
||||
let resolved = talk["resolved"]?.dictionaryValue,
|
||||
let providerID = self.normalizedTalkProviderID(resolved["provider"]?.stringValue)
|
||||
else { return nil }
|
||||
return TalkProviderConfigSelection(
|
||||
provider: providerID,
|
||||
config: resolved["config"]?.dictionaryValue ?? [:],
|
||||
normalizedPayload: true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
|
||||
private struct TalkConfigContractFixture: Decodable {
|
||||
let selectionCases: [SelectionCase]
|
||||
let timeoutCases: [TimeoutCase]
|
||||
|
||||
struct SelectionCase: Decodable {
|
||||
let id: String
|
||||
let defaultProvider: String
|
||||
let payloadValid: Bool
|
||||
let expectedSelection: ExpectedSelection?
|
||||
let talk: [String: AnyCodable]
|
||||
}
|
||||
|
||||
struct ExpectedSelection: Decodable {
|
||||
let provider: String
|
||||
let normalizedPayload: Bool
|
||||
let voiceId: String?
|
||||
let apiKey: String?
|
||||
}
|
||||
|
||||
struct TimeoutCase: Decodable {
|
||||
let id: String
|
||||
let fallback: Int
|
||||
let expectedTimeoutMs: Int
|
||||
let talk: [String: AnyCodable]
|
||||
}
|
||||
}
|
||||
|
||||
private enum TalkConfigContractFixtureLoader {
|
||||
static func load() throws -> TalkConfigContractFixture {
|
||||
let fixtureURL = try self.findFixtureURL(startingAt: URL(fileURLWithPath: #filePath))
|
||||
let data = try Data(contentsOf: fixtureURL)
|
||||
return try JSONDecoder().decode(TalkConfigContractFixture.self, from: data)
|
||||
}
|
||||
|
||||
private static func findFixtureURL(startingAt fileURL: URL) throws -> URL {
|
||||
var directory = fileURL.deletingLastPathComponent()
|
||||
while directory.path != "/" {
|
||||
let candidate = directory.appendingPathComponent("test-fixtures/talk-config-contract.json")
|
||||
if FileManager.default.fileExists(atPath: candidate.path) {
|
||||
return candidate
|
||||
}
|
||||
directory.deleteLastPathComponent()
|
||||
}
|
||||
throw NSError(domain: "TalkConfigContractFixtureLoader", code: 1)
|
||||
}
|
||||
}
|
||||
|
||||
struct TalkConfigContractTests {
|
||||
@Test func selectionFixtures() throws {
|
||||
for fixture in try TalkConfigContractFixtureLoader.load().selectionCases {
|
||||
let selection = TalkConfigParsing.selectProviderConfig(
|
||||
fixture.talk,
|
||||
defaultProvider: fixture.defaultProvider)
|
||||
if let expected = fixture.expectedSelection {
|
||||
#expect(selection != nil)
|
||||
#expect(selection?.provider == expected.provider)
|
||||
#expect(selection?.normalizedPayload == expected.normalizedPayload)
|
||||
#expect(selection?.config["voiceId"]?.stringValue == expected.voiceId)
|
||||
#expect(selection?.config["apiKey"]?.stringValue == expected.apiKey)
|
||||
} else {
|
||||
#expect(selection == nil)
|
||||
}
|
||||
#expect(fixture.payloadValid == (selection != nil))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func timeoutFixtures() throws {
|
||||
for fixture in try TalkConfigContractFixtureLoader.load().timeoutCases {
|
||||
#expect(
|
||||
TalkConfigParsing.resolvedSilenceTimeoutMs(
|
||||
fixture.talk,
|
||||
fallback: fixture.fallback) == fixture.expectedTimeoutMs,
|
||||
"\(fixture.id)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
|
||||
struct TalkConfigParsingTests {
|
||||
@Test func prefersCanonicalResolvedTalkProviderPayload() {
|
||||
let talk: [String: AnyCodable] = [
|
||||
"resolved": AnyCodable([
|
||||
"provider": "elevenlabs",
|
||||
"config": [
|
||||
"voiceId": "voice-resolved",
|
||||
],
|
||||
]),
|
||||
"provider": AnyCodable("elevenlabs"),
|
||||
"providers": AnyCodable([
|
||||
"elevenlabs": [
|
||||
"voiceId": "voice-normalized",
|
||||
],
|
||||
]),
|
||||
]
|
||||
|
||||
let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: "elevenlabs")
|
||||
#expect(selection?.provider == "elevenlabs")
|
||||
#expect(selection?.normalizedPayload == true)
|
||||
#expect(selection?.config["voiceId"]?.stringValue == "voice-resolved")
|
||||
}
|
||||
|
||||
@Test func rejectsNormalizedTalkProviderPayloadWithoutResolved() {
|
||||
let talk: [String: AnyCodable] = [
|
||||
"provider": AnyCodable("elevenlabs"),
|
||||
"providers": AnyCodable([
|
||||
"elevenlabs": [
|
||||
"voiceId": "voice-normalized",
|
||||
],
|
||||
]),
|
||||
"voiceId": AnyCodable("voice-legacy"),
|
||||
]
|
||||
|
||||
let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: "elevenlabs")
|
||||
#expect(selection == nil)
|
||||
}
|
||||
|
||||
@Test func fallsBackToLegacyTalkFieldsWhenNormalizedPayloadMissing() {
|
||||
let talk: [String: AnyCodable] = [
|
||||
"voiceId": AnyCodable("voice-legacy"),
|
||||
"apiKey": AnyCodable("legacy-key"),
|
||||
]
|
||||
|
||||
let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: "elevenlabs")
|
||||
#expect(selection?.provider == "elevenlabs")
|
||||
#expect(selection?.normalizedPayload == false)
|
||||
#expect(selection?.config["voiceId"]?.stringValue == "voice-legacy")
|
||||
#expect(selection?.config["apiKey"]?.stringValue == "legacy-key")
|
||||
}
|
||||
|
||||
@Test func canDisableLegacyFallback() {
|
||||
let talk: [String: AnyCodable] = [
|
||||
"voiceId": AnyCodable("voice-legacy"),
|
||||
]
|
||||
|
||||
let selection = TalkConfigParsing.selectProviderConfig(
|
||||
talk,
|
||||
defaultProvider: "elevenlabs",
|
||||
allowLegacyFallback: false)
|
||||
#expect(selection == nil)
|
||||
}
|
||||
|
||||
@Test func rejectsNormalizedPayloadWhenProviderMissingFromProviders() {
|
||||
let talk: [String: AnyCodable] = [
|
||||
"provider": AnyCodable("acme"),
|
||||
"providers": AnyCodable([
|
||||
"elevenlabs": [
|
||||
"voiceId": "voice-normalized",
|
||||
],
|
||||
]),
|
||||
]
|
||||
|
||||
let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: "elevenlabs")
|
||||
#expect(selection == nil)
|
||||
}
|
||||
|
||||
@Test func rejectsNormalizedPayloadWhenMultipleProvidersAndNoProvider() {
|
||||
let talk: [String: AnyCodable] = [
|
||||
"providers": AnyCodable([
|
||||
"acme": [
|
||||
"voiceId": "voice-acme",
|
||||
],
|
||||
"elevenlabs": [
|
||||
"voiceId": "voice-eleven",
|
||||
],
|
||||
]),
|
||||
]
|
||||
|
||||
let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: "elevenlabs")
|
||||
#expect(selection == nil)
|
||||
}
|
||||
|
||||
@Test func bridgesFoundationDictionary() {
|
||||
let raw: [String: Any] = [
|
||||
"provider": "elevenlabs",
|
||||
"providers": [
|
||||
"elevenlabs": [
|
||||
"voiceId": "voice-normalized",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
let bridged = TalkConfigParsing.bridgeFoundationDictionary(raw)
|
||||
#expect(bridged?["provider"]?.stringValue == "elevenlabs")
|
||||
let nested = bridged?["providers"]?.dictionaryValue?["elevenlabs"]?.dictionaryValue
|
||||
#expect(nested?["voiceId"]?.stringValue == "voice-normalized")
|
||||
}
|
||||
|
||||
@Test func resolvesPositiveIntegerTimeout() {
|
||||
#expect(TalkConfigParsing.resolvedPositiveInt(AnyCodable(1500), fallback: 700) == 1500)
|
||||
#expect(TalkConfigParsing.resolvedPositiveInt(AnyCodable(0), fallback: 700) == 700)
|
||||
#expect(TalkConfigParsing.resolvedPositiveInt(AnyCodable(true), fallback: 700) == 700)
|
||||
#expect(TalkConfigParsing.resolvedPositiveInt(AnyCodable("1500"), fallback: 700) == 700)
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,13 @@ title: "Brave Search"
|
||||
|
||||
# Brave Search API
|
||||
|
||||
OpenClaw supports Brave Search as a web search provider for `web_search`.
|
||||
OpenClaw supports Brave Search API as a `web_search` provider.
|
||||
|
||||
## Get an API key
|
||||
|
||||
1. Create a Brave Search API account at [https://brave.com/search/api/](https://brave.com/search/api/)
|
||||
2. In the dashboard, choose the **Data for Search** plan and generate an API key.
|
||||
3. Store the key in config (recommended) or set `BRAVE_API_KEY` in the Gateway environment.
|
||||
2. In the dashboard, choose the **Search** plan and generate an API key.
|
||||
3. Store the key in config or set `BRAVE_API_KEY` in the Gateway environment.
|
||||
|
||||
## Config example
|
||||
|
||||
@@ -72,9 +72,9 @@ await web_search({
|
||||
|
||||
## Notes
|
||||
|
||||
- The Data for AI plan is **not** compatible with `web_search`.
|
||||
- Brave provides paid plans; check the Brave API portal for current limits.
|
||||
- Brave Terms include restrictions on some AI-related uses of Search Results. Review the Brave Terms of Service and confirm your intended use is compliant. For legal questions, consult your counsel.
|
||||
- OpenClaw uses the Brave **Search** plan. If you have a legacy subscription (e.g. the original Free plan with 2,000 queries/month), it remains valid but does not include newer features like LLM Context or higher rate limits.
|
||||
- Each Brave plan includes **$5/month in free credit** (renewing). The Search plan costs $5 per 1,000 requests, so the credit covers 1,000 queries/month. Set your usage limit in the Brave dashboard to avoid unexpected charges. See the [Brave API portal](https://brave.com/search/api/) for current plans.
|
||||
- The Search plan includes the LLM Context endpoint and AI inference rights. Storing results to train or tune models requires a plan with explicit storage rights. See the Brave [Terms of Service](https://api-dashboard.search.brave.com/terms-of-service).
|
||||
- Results are cached for 15 minutes by default (configurable via `cacheTtlMinutes`).
|
||||
|
||||
See [Web tools](/tools/web) for the full web_search configuration.
|
||||
|
||||
@@ -12,20 +12,18 @@ Feishu (Lark) is a team chat platform used by companies for messaging and collab
|
||||
|
||||
---
|
||||
|
||||
## Plugin required
|
||||
## Bundled plugin
|
||||
|
||||
Install the Feishu plugin:
|
||||
Feishu ships bundled with current OpenClaw releases, so no separate plugin install
|
||||
is required.
|
||||
|
||||
If you are using an older build or a custom install that does not include bundled
|
||||
Feishu, install it manually:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/feishu
|
||||
```
|
||||
|
||||
Local checkout (when running from a git repo):
|
||||
|
||||
```bash
|
||||
openclaw plugins install ./extensions/feishu
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -232,10 +232,10 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
## Feature reference
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Live stream preview (native drafts + message edits)">
|
||||
<Accordion title="Live stream preview (message edits)">
|
||||
OpenClaw can stream partial replies in real time:
|
||||
|
||||
- direct chats: Telegram native draft streaming via `sendMessageDraft`
|
||||
- direct chats: preview message + `editMessageText`
|
||||
- groups/topics: preview message + `editMessageText`
|
||||
|
||||
Requirement:
|
||||
@@ -244,11 +244,9 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- `progress` maps to `partial` on Telegram (compat with cross-channel naming)
|
||||
- legacy `channels.telegram.streamMode` and boolean `streaming` values are auto-mapped
|
||||
|
||||
Telegram enabled `sendMessageDraft` for all bots in Bot API 9.5 (March 1, 2026).
|
||||
|
||||
For text-only replies:
|
||||
|
||||
- DM: OpenClaw updates the draft in place (no extra preview message)
|
||||
- DM: OpenClaw keeps the same preview message and performs a final edit in place (no second message)
|
||||
- group/topic: OpenClaw keeps the same preview message and performs a final edit in place (no second message)
|
||||
|
||||
For complex replies (for example media payloads), OpenClaw falls back to normal final delivery and then cleans up the preview message.
|
||||
@@ -872,7 +870,7 @@ Primary reference:
|
||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
||||
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
|
||||
- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `partial`; `progress` maps to `partial`; `block` is legacy preview mode compatibility). In DMs, `partial` uses native `sendMessageDraft` when available.
|
||||
- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `partial`; `progress` maps to `partial`; `block` is legacy preview mode compatibility). Telegram preview streaming uses a single preview message that is edited in place.
|
||||
- `channels.telegram.mediaMaxMb`: inbound/outbound Telegram media cap (MB, default: 100).
|
||||
- `channels.telegram.retry`: retry policy for Telegram send helpers (CLI/tools/actions) on recoverable outbound API errors (attempts, minDelayMs, maxDelayMs, jitter).
|
||||
- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to enabled on Node 22+, with WSL2 defaulting to disabled.
|
||||
|
||||
@@ -24,6 +24,36 @@ Compaction **persists** in the session’s JSONL history.
|
||||
Use the `agents.defaults.compaction` setting in your `openclaw.json` to configure compaction behavior (mode, target tokens, etc.).
|
||||
Compaction summarization preserves opaque identifiers by default (`identifierPolicy: "strict"`). You can override this with `identifierPolicy: "off"` or provide custom text with `identifierPolicy: "custom"` and `identifierInstructions`.
|
||||
|
||||
You can optionally specify a different model for compaction summarization via `agents.defaults.compaction.model`. This is useful when your primary model is a local or small model and you want compaction summaries produced by a more capable model. The override accepts any `provider/model-id` string:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"compaction": {
|
||||
"model": "openrouter/anthropic/claude-sonnet-4-5"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This also works with local models, for example a second Ollama model dedicated to summarization or a fine-tuned compaction specialist:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"compaction": {
|
||||
"model": "ollama/llama3.1:8b"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When unset, compaction uses the agent's primary model.
|
||||
|
||||
## Auto-compaction (default on)
|
||||
|
||||
When a session nears or exceeds the model’s context window, OpenClaw triggers auto-compaction and may retry the original request using the compacted context.
|
||||
|
||||
@@ -138,7 +138,7 @@ Legacy key migration:
|
||||
|
||||
Telegram:
|
||||
|
||||
- Uses Bot API `sendMessageDraft` in DMs when available, and `sendMessage` + `editMessageText` for group/topic preview updates.
|
||||
- Uses `sendMessage` + `editMessageText` preview updates across DMs and group/topics.
|
||||
- Preview streaming is skipped when Telegram block streaming is explicitly enabled (to avoid double-streaming).
|
||||
- `/reasoning stream` can write reasoning to preview.
|
||||
|
||||
|
||||
@@ -1005,6 +1005,7 @@ Periodic heartbeat runs.
|
||||
identifierPolicy: "strict", // strict | off | custom
|
||||
identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom
|
||||
postCompactionSections: ["Session Startup", "Red Lines"], // [] disables reinjection
|
||||
model: "openrouter/anthropic/claude-sonnet-4-5", // optional compaction-only model override
|
||||
memoryFlush: {
|
||||
enabled: true,
|
||||
softThresholdTokens: 6000,
|
||||
@@ -1021,6 +1022,7 @@ Periodic heartbeat runs.
|
||||
- `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization.
|
||||
- `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`.
|
||||
- `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Defaults to `["Session Startup", "Red Lines"]`; set `[]` to disable reinjection. When unset or explicitly set to that default pair, older `Every Session`/`Safety` headings are also accepted as a legacy fallback.
|
||||
- `model`: optional `provider/model-id` override for compaction summarization only. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model.
|
||||
- `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Skipped when workspace is read-only.
|
||||
|
||||
### `agents.defaults.contextPruning`
|
||||
@@ -1659,6 +1661,7 @@ Defaults for Talk mode (macOS/iOS/Android).
|
||||
modelId: "eleven_v3",
|
||||
outputFormat: "mp3_44100_128",
|
||||
apiKey: "elevenlabs_api_key",
|
||||
silenceTimeoutMs: 1500,
|
||||
interruptOnSpeech: true,
|
||||
},
|
||||
}
|
||||
@@ -1668,6 +1671,7 @@ Defaults for Talk mode (macOS/iOS/Android).
|
||||
- `apiKey` and `providers.*.apiKey` accept plaintext strings or SecretRef objects.
|
||||
- `ELEVENLABS_API_KEY` fallback applies only when no Talk API key is configured.
|
||||
- `voiceAliases` lets Talk directives use friendly names.
|
||||
- `silenceTimeoutMs` controls how long Talk mode waits after user silence before it sends the transcript. Unset keeps the platform default pause window (`700 ms on macOS and Android, 900 ms on iOS`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ Supported keys:
|
||||
modelId: "eleven_v3",
|
||||
outputFormat: "mp3_44100_128",
|
||||
apiKey: "elevenlabs_api_key",
|
||||
silenceTimeoutMs: 1500,
|
||||
interruptOnSpeech: true,
|
||||
},
|
||||
}
|
||||
@@ -64,6 +65,7 @@ Supported keys:
|
||||
Defaults:
|
||||
|
||||
- `interruptOnSpeech`: true
|
||||
- `silenceTimeoutMs`: when unset, Talk keeps the platform default pause window before sending the transcript (`700 ms on macOS and Android, 900 ms on iOS`)
|
||||
- `voiceId`: falls back to `ELEVENLABS_VOICE_ID` / `SAG_VOICE_ID` (or first ElevenLabs voice when API key is available)
|
||||
- `modelId`: defaults to `eleven_v3` when unset
|
||||
- `apiKey`: falls back to `ELEVENLABS_API_KEY` (or gateway shell profile if available)
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
---
|
||||
summary: "Perplexity Search API setup for web_search"
|
||||
summary: "Perplexity Search API and Sonar/OpenRouter compatibility for web_search"
|
||||
read_when:
|
||||
- You want to use Perplexity Search for web search
|
||||
- You need PERPLEXITY_API_KEY setup
|
||||
- You need PERPLEXITY_API_KEY or OPENROUTER_API_KEY setup
|
||||
title: "Perplexity Search"
|
||||
---
|
||||
|
||||
# Perplexity Search API
|
||||
|
||||
OpenClaw uses Perplexity Search API for the `web_search` tool when `provider: "perplexity"` is set.
|
||||
Perplexity Search returns structured results (title, URL, snippet) for fast research.
|
||||
OpenClaw supports Perplexity Search API as a `web_search` provider.
|
||||
It returns structured results with `title`, `url`, and `snippet` fields.
|
||||
|
||||
For compatibility, OpenClaw also supports legacy Perplexity Sonar/OpenRouter setups.
|
||||
If you use `OPENROUTER_API_KEY`, an `sk-or-...` key in `tools.web.search.perplexity.apiKey`, or set `tools.web.search.perplexity.baseUrl` / `model`, the provider switches to the chat-completions path and returns AI-synthesized answers with citations instead of structured Search API results.
|
||||
|
||||
## Getting a Perplexity API key
|
||||
|
||||
1. Create a Perplexity account at <https://www.perplexity.ai/settings/api>
|
||||
2. Generate an API key in the dashboard
|
||||
3. Store the key in config (recommended) or set `PERPLEXITY_API_KEY` in the Gateway environment.
|
||||
3. Store the key in config or set `PERPLEXITY_API_KEY` in the Gateway environment.
|
||||
|
||||
## Config example
|
||||
## OpenRouter compatibility
|
||||
|
||||
If you were already using OpenRouter for Perplexity Sonar, keep `provider: "perplexity"` and set `OPENROUTER_API_KEY` in the Gateway environment, or store an `sk-or-...` key in `tools.web.search.perplexity.apiKey`.
|
||||
|
||||
Optional legacy controls:
|
||||
|
||||
- `tools.web.search.perplexity.baseUrl`
|
||||
- `tools.web.search.perplexity.model`
|
||||
|
||||
## Config examples
|
||||
|
||||
### Native Perplexity Search API
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -34,7 +48,7 @@ Perplexity Search returns structured results (title, URL, snippet) for fast rese
|
||||
}
|
||||
```
|
||||
|
||||
## Switching from Brave
|
||||
### OpenRouter / Sonar compatibility
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -43,7 +57,9 @@ Perplexity Search returns structured results (title, URL, snippet) for fast rese
|
||||
search: {
|
||||
provider: "perplexity",
|
||||
perplexity: {
|
||||
apiKey: "pplx-...",
|
||||
apiKey: "<openrouter-api-key>",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
model: "perplexity/sonar-pro",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -51,17 +67,19 @@ Perplexity Search returns structured results (title, URL, snippet) for fast rese
|
||||
}
|
||||
```
|
||||
|
||||
## Where to set the key (recommended)
|
||||
## Where to set the key
|
||||
|
||||
**Recommended:** run `openclaw configure --section web`. It stores the key in
|
||||
**Via config:** run `openclaw configure --section web`. It stores the key in
|
||||
`~/.openclaw/openclaw.json` under `tools.web.search.perplexity.apiKey`.
|
||||
|
||||
**Environment alternative:** set `PERPLEXITY_API_KEY` in the Gateway process
|
||||
environment. For a gateway install, put it in `~/.openclaw/.env` (or your
|
||||
service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
|
||||
**Via environment:** set `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
|
||||
in the Gateway process environment. For a gateway install, put it in
|
||||
`~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
|
||||
|
||||
## Tool parameters
|
||||
|
||||
These parameters apply to the native Perplexity Search API path.
|
||||
|
||||
| Parameter | Description |
|
||||
| --------------------- | ---------------------------------------------------- |
|
||||
| `query` | Search query (required) |
|
||||
@@ -75,6 +93,9 @@ service environment). See [Env vars](/help/faq#how-does-openclaw-load-environmen
|
||||
| `max_tokens` | Total content budget (default: 25000, max: 1000000) |
|
||||
| `max_tokens_per_page` | Per-page token limit (default: 2048) |
|
||||
|
||||
For the legacy Sonar/OpenRouter compatibility path, only `query` and `freshness` are supported.
|
||||
Search API-only filters such as `country`, `language`, `date_after`, `date_before`, `domain_filter`, `max_tokens`, and `max_tokens_per_page` return explicit errors.
|
||||
|
||||
**Examples:**
|
||||
|
||||
```javascript
|
||||
@@ -126,7 +147,8 @@ await web_search({
|
||||
|
||||
## Notes
|
||||
|
||||
- Perplexity Search API returns structured web search results (title, URL, snippet)
|
||||
- Perplexity Search API returns structured web search results (`title`, `url`, `snippet`)
|
||||
- OpenRouter or explicit `baseUrl` / `model` switches Perplexity back to Sonar chat completions for compatibility
|
||||
- Results are cached for 15 minutes by default (configurable via `cacheTtlMinutes`)
|
||||
|
||||
See [Web tools](/tools/web) for the full web_search configuration.
|
||||
|
||||
@@ -29,23 +29,27 @@ Notes:
|
||||
- `APP_BUILD` maps to `CFBundleVersion`/`sparkle:version`; keep it numeric + monotonic (no `-beta`), or Sparkle compares it as equal.
|
||||
- If `APP_BUILD` is omitted, `scripts/package-mac-app.sh` derives a Sparkle-safe default from `APP_VERSION` (`YYYYMMDDNN`: stable defaults to `90`, prereleases use a suffix-derived lane) and uses the higher of that value and git commit count.
|
||||
- You can still override `APP_BUILD` explicitly when release engineering needs a specific monotonic value.
|
||||
- Defaults to the current architecture (`$(uname -m)`). For release/universal builds, set `BUILD_ARCHS="arm64 x86_64"` (or `BUILD_ARCHS=all`).
|
||||
- For `BUILD_CONFIG=release`, `scripts/package-mac-app.sh` now defaults to universal (`arm64 x86_64`) automatically. You can still override with `BUILD_ARCHS=arm64` or `BUILD_ARCHS=x86_64`. For local/dev builds (`BUILD_CONFIG=debug`), it defaults to the current architecture (`$(uname -m)`).
|
||||
- Use `scripts/package-mac-dist.sh` for release artifacts (zip + DMG + notarization). Use `scripts/package-mac-app.sh` for local/dev packaging.
|
||||
|
||||
```bash
|
||||
# From repo root; set release IDs so Sparkle feed is enabled.
|
||||
# This command builds release artifacts without notarization.
|
||||
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
||||
# Default is auto-derived from APP_VERSION when omitted.
|
||||
SKIP_NOTARIZE=1 \
|
||||
BUNDLE_ID=ai.openclaw.mac \
|
||||
APP_VERSION=2026.3.8 \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-app.sh
|
||||
scripts/package-mac-dist.sh
|
||||
|
||||
# Zip for distribution (includes resource forks for Sparkle delta support)
|
||||
# `package-mac-dist.sh` already creates the zip + DMG.
|
||||
# If you used `package-mac-app.sh` directly instead, create them manually:
|
||||
# If you want notarization/stapling in this step, use the NOTARIZE command below.
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.8.zip
|
||||
|
||||
# Optional: also build a styled DMG for humans (drag to /Applications)
|
||||
# Optional: build a styled DMG for humans (drag to /Applications)
|
||||
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.8.dmg
|
||||
|
||||
# Recommended: build + notarize/staple zip + DMG
|
||||
|
||||
@@ -79,11 +79,16 @@ See [Memory](/concepts/memory).
|
||||
|
||||
`web_search` uses API keys and may incur usage charges depending on your provider:
|
||||
|
||||
- **Perplexity Search API**: `PERPLEXITY_API_KEY`
|
||||
- **Brave Search API**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
|
||||
- **Gemini (Google Search)**: `GEMINI_API_KEY`
|
||||
- **Grok (xAI)**: `XAI_API_KEY`
|
||||
- **Kimi (Moonshot)**: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
|
||||
- **Perplexity Search API**: `PERPLEXITY_API_KEY`
|
||||
|
||||
**Brave Search free credit:** Each Brave plan includes $5/month in renewing
|
||||
free credit. The Search plan costs $5 per 1,000 requests, so the credit covers
|
||||
1,000 requests/month at no charge. Set your usage limit in the Brave dashboard
|
||||
to avoid unexpected charges.
|
||||
|
||||
See [Web tools](/tools/web).
|
||||
|
||||
|
||||
@@ -196,6 +196,53 @@ Notes:
|
||||
- Replace `<BROWSERLESS_API_KEY>` with your real Browserless token.
|
||||
- Choose the region endpoint that matches your Browserless account (see their docs).
|
||||
|
||||
## Direct WebSocket CDP providers
|
||||
|
||||
Some hosted browser services expose a **direct WebSocket** endpoint rather than
|
||||
the standard HTTP-based CDP discovery (`/json/version`). OpenClaw supports both:
|
||||
|
||||
- **HTTP(S) endpoints** (e.g. Browserless) — OpenClaw calls `/json/version` to
|
||||
discover the WebSocket debugger URL, then connects.
|
||||
- **WebSocket endpoints** (`ws://` / `wss://`) — OpenClaw connects directly,
|
||||
skipping `/json/version`. Use this for services like
|
||||
[Browserbase](https://www.browserbase.com) or any provider that hands you a
|
||||
WebSocket URL.
|
||||
|
||||
### Browserbase
|
||||
|
||||
[Browserbase](https://www.browserbase.com) is a cloud platform for running
|
||||
headless browsers with built-in CAPTCHA solving, stealth mode, and residential
|
||||
proxies.
|
||||
|
||||
```json5
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
defaultProfile: "browserbase",
|
||||
remoteCdpTimeoutMs: 3000,
|
||||
remoteCdpHandshakeTimeoutMs: 5000,
|
||||
profiles: {
|
||||
browserbase: {
|
||||
cdpUrl: "wss://connect.browserbase.com?apiKey=<BROWSERBASE_API_KEY>",
|
||||
color: "#F97316",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- [Sign up](https://www.browserbase.com/sign-up) and copy your **API Key**
|
||||
from the [Overview dashboard](https://www.browserbase.com/overview).
|
||||
- Replace `<BROWSERBASE_API_KEY>` with your real Browserbase API key.
|
||||
- Browserbase auto-creates a browser session on WebSocket connect, so no
|
||||
manual session creation step is needed.
|
||||
- The free tier allows one concurrent session and one browser hour per month.
|
||||
See [pricing](https://www.browserbase.com/pricing) for paid plan limits.
|
||||
- See the [Browserbase docs](https://docs.browserbase.com) for full API
|
||||
reference, SDK guides, and integration examples.
|
||||
|
||||
## Security
|
||||
|
||||
Key ideas:
|
||||
@@ -207,7 +254,7 @@ Key ideas:
|
||||
|
||||
Remote CDP tips:
|
||||
|
||||
- Prefer HTTPS endpoints and short-lived tokens where possible.
|
||||
- Prefer encrypted endpoints (HTTPS or WSS) and short-lived tokens where possible.
|
||||
- Avoid embedding long-lived tokens directly in config files.
|
||||
|
||||
## Profiles (multi-browser)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
summary: "Web search + fetch tools (Perplexity Search API, Brave, Gemini, Grok, and Kimi providers)"
|
||||
summary: "Web search + fetch tools (Brave, Gemini, Grok, Kimi, and Perplexity providers)"
|
||||
read_when:
|
||||
- You want to enable web_search or web_fetch
|
||||
- You need Perplexity or Brave Search API key setup
|
||||
- You need Brave or Perplexity Search API key setup
|
||||
- You want to use Gemini with Google Search grounding
|
||||
title: "Web Tools"
|
||||
---
|
||||
@@ -11,7 +11,7 @@ title: "Web Tools"
|
||||
|
||||
OpenClaw ships two lightweight web tools:
|
||||
|
||||
- `web_search` — Search the web using Perplexity Search API, Brave Search API, Gemini with Google Search grounding, Grok, or Kimi.
|
||||
- `web_search` — Search the web using Brave Search API, Gemini with Google Search grounding, Grok, Kimi, or Perplexity Search API.
|
||||
- `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text).
|
||||
|
||||
These are **not** browser automation. For JS-heavy sites or logins, use the
|
||||
@@ -25,26 +25,26 @@ These are **not** browser automation. For JS-heavy sites or logins, use the
|
||||
(HTML → markdown/text). It does **not** execute JavaScript.
|
||||
- `web_fetch` is enabled by default (unless explicitly disabled).
|
||||
|
||||
See [Perplexity Search setup](/perplexity) and [Brave Search setup](/brave-search) for provider-specific details.
|
||||
See [Brave Search setup](/brave-search) and [Perplexity Search setup](/perplexity) for provider-specific details.
|
||||
|
||||
## Choosing a search provider
|
||||
|
||||
| Provider | Pros | Cons | API Key |
|
||||
| ------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------- | ----------------------------------- |
|
||||
| **Perplexity Search API** | Fast, structured results; domain, language, region, and freshness filters; content extraction | — | `PERPLEXITY_API_KEY` |
|
||||
| **Brave Search API** | Fast, structured results | Fewer filtering options; AI-use terms apply | `BRAVE_API_KEY` |
|
||||
| **Gemini** | Google Search grounding, AI-synthesized | Requires Gemini API key | `GEMINI_API_KEY` |
|
||||
| **Grok** | xAI web-grounded responses | Requires xAI API key | `XAI_API_KEY` |
|
||||
| **Kimi** | Moonshot web search capability | Requires Moonshot API key | `KIMI_API_KEY` / `MOONSHOT_API_KEY` |
|
||||
| Provider | Result shape | Provider-specific filters | Notes | API key |
|
||||
| ------------------------- | ---------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------- |
|
||||
| **Brave Search API** | Structured results with snippets | `country`, `language`, `ui_lang`, time | Supports Brave `llm-context` mode | `BRAVE_API_KEY` |
|
||||
| **Gemini** | AI-synthesized answers + citations | — | Uses Google Search grounding | `GEMINI_API_KEY` |
|
||||
| **Grok** | AI-synthesized answers + citations | — | Uses xAI web-grounded responses | `XAI_API_KEY` |
|
||||
| **Kimi** | AI-synthesized answers + citations | — | Uses Moonshot web search | `KIMI_API_KEY` / `MOONSHOT_API_KEY` |
|
||||
| **Perplexity Search API** | Structured results with snippets | `country`, `language`, time, `domain_filter` | Supports content extraction controls; OpenRouter uses Sonar compatibility path | `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` |
|
||||
|
||||
### Auto-detection
|
||||
|
||||
If no `provider` is explicitly set, OpenClaw auto-detects which provider to use based on available API keys, checking in this order:
|
||||
The table above is alphabetical. If no `provider` is explicitly set, runtime auto-detection checks providers in this order:
|
||||
|
||||
1. **Brave** — `BRAVE_API_KEY` env var or `tools.web.search.apiKey` config
|
||||
2. **Gemini** — `GEMINI_API_KEY` env var or `tools.web.search.gemini.apiKey` config
|
||||
3. **Kimi** — `KIMI_API_KEY` / `MOONSHOT_API_KEY` env var or `tools.web.search.kimi.apiKey` config
|
||||
4. **Perplexity** — `PERPLEXITY_API_KEY` env var or `tools.web.search.perplexity.apiKey` config
|
||||
4. **Perplexity** — `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey` config
|
||||
5. **Grok** — `XAI_API_KEY` env var or `tools.web.search.grok.apiKey` config
|
||||
|
||||
If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one).
|
||||
@@ -53,30 +53,75 @@ If no keys are found, it falls back to Brave (you'll get a missing-key error pro
|
||||
|
||||
Use `openclaw configure --section web` to set up your API key and choose a provider.
|
||||
|
||||
### Brave Search
|
||||
|
||||
1. Create a Brave Search API account at [brave.com/search/api](https://brave.com/search/api/)
|
||||
2. In the dashboard, choose the **Search** plan and generate an API key.
|
||||
3. Run `openclaw configure --section web` to store the key in config, or set `BRAVE_API_KEY` in your environment.
|
||||
|
||||
Each Brave plan includes **$5/month in free credit** (renewing). The Search
|
||||
plan costs $5 per 1,000 requests, so the credit covers 1,000 queries/month. Set
|
||||
your usage limit in the Brave dashboard to avoid unexpected charges. See the
|
||||
[Brave API portal](https://brave.com/search/api/) for current plans and
|
||||
pricing.
|
||||
|
||||
### Perplexity Search
|
||||
|
||||
1. Create a Perplexity account at [perplexity.ai/settings/api](https://www.perplexity.ai/settings/api)
|
||||
2. Generate an API key in the dashboard
|
||||
3. Run `openclaw configure --section web` to store the key in config, or set `PERPLEXITY_API_KEY` in your environment.
|
||||
|
||||
For legacy Sonar/OpenRouter compatibility, set `OPENROUTER_API_KEY` instead, or configure `tools.web.search.perplexity.apiKey` with an `sk-or-...` key. Setting `tools.web.search.perplexity.baseUrl` or `model` also opts Perplexity back into the chat-completions compatibility path.
|
||||
|
||||
See [Perplexity Search API Docs](https://docs.perplexity.ai/guides/search-quickstart) for more details.
|
||||
|
||||
### Brave Search
|
||||
|
||||
1. Create a Brave Search API account at [brave.com/search/api](https://brave.com/search/api/)
|
||||
2. In the dashboard, choose the **Data for Search** plan (not "Data for AI") and generate an API key.
|
||||
3. Run `openclaw configure --section web` to store the key in config (recommended), or set `BRAVE_API_KEY` in your environment.
|
||||
|
||||
Brave provides paid plans; check the Brave API portal for the current limits and pricing.
|
||||
|
||||
### Where to store the key
|
||||
|
||||
**Via config (recommended):** run `openclaw configure --section web`. It stores the key under `tools.web.search.perplexity.apiKey` or `tools.web.search.apiKey`.
|
||||
**Via config:** run `openclaw configure --section web`. It stores the key under `tools.web.search.apiKey` or `tools.web.search.perplexity.apiKey`, depending on provider.
|
||||
|
||||
**Via environment:** set `PERPLEXITY_API_KEY` or `BRAVE_API_KEY` in the Gateway process environment. For a gateway install, put it in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
|
||||
**Via environment:** set `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `BRAVE_API_KEY` in the Gateway process environment. For a gateway install, put it in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
|
||||
|
||||
### Config examples
|
||||
|
||||
**Brave Search:**
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
provider: "brave",
|
||||
apiKey: "YOUR_BRAVE_API_KEY", // optional if BRAVE_API_KEY is set // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Brave LLM Context mode:**
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
provider: "brave",
|
||||
apiKey: "YOUR_BRAVE_API_KEY", // optional if BRAVE_API_KEY is set // pragma: allowlist secret
|
||||
brave: {
|
||||
mode: "llm-context",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`llm-context` returns extracted page chunks for grounding instead of standard Brave snippets.
|
||||
In this mode, `country` and `language` / `search_lang` still work, but `ui_lang`,
|
||||
`freshness`, `date_after`, and `date_before` are rejected.
|
||||
|
||||
**Perplexity Search:**
|
||||
|
||||
```json5
|
||||
@@ -95,7 +140,7 @@ Brave provides paid plans; check the Brave API portal for the current limits and
|
||||
}
|
||||
```
|
||||
|
||||
**Brave Search:**
|
||||
**Perplexity via OpenRouter / Sonar compatibility:**
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -103,8 +148,12 @@ Brave provides paid plans; check the Brave API portal for the current limits and
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
provider: "brave",
|
||||
apiKey: "YOUR_BRAVE_API_KEY", // optional if BRAVE_API_KEY is set // pragma: allowlist secret
|
||||
provider: "perplexity",
|
||||
perplexity: {
|
||||
apiKey: "<openrouter-api-key>", // optional if OPENROUTER_API_KEY is set
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
model: "perplexity/sonar-pro",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -163,7 +212,7 @@ Search the web using your configured provider.
|
||||
- `tools.web.search.enabled` must not be `false` (default: enabled)
|
||||
- API key for your chosen provider:
|
||||
- **Brave**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
|
||||
- **Perplexity**: `PERPLEXITY_API_KEY` or `tools.web.search.perplexity.apiKey`
|
||||
- **Perplexity**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey`
|
||||
- **Gemini**: `GEMINI_API_KEY` or `tools.web.search.gemini.apiKey`
|
||||
- **Grok**: `XAI_API_KEY` or `tools.web.search.grok.apiKey`
|
||||
- **Kimi**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey`
|
||||
@@ -188,7 +237,10 @@ Search the web using your configured provider.
|
||||
|
||||
### Tool parameters
|
||||
|
||||
All parameters work for both Brave and Perplexity unless noted.
|
||||
All parameters work for Brave and for native Perplexity Search API unless noted.
|
||||
|
||||
Perplexity's OpenRouter / Sonar compatibility path supports only `query` and `freshness`.
|
||||
If you set `tools.web.search.perplexity.baseUrl` / `model`, use `OPENROUTER_API_KEY`, or configure an `sk-or-...` key, Search API-only filters return explicit errors.
|
||||
|
||||
| Parameter | Description |
|
||||
| --------------------- | ----------------------------------------------------- |
|
||||
@@ -247,6 +299,9 @@ await web_search({
|
||||
});
|
||||
```
|
||||
|
||||
When Brave `llm-context` mode is enabled, `ui_lang`, `freshness`, `date_after`, and
|
||||
`date_before` are not supported. Use Brave `web` mode for those filters.
|
||||
|
||||
## web_fetch
|
||||
|
||||
Fetch a URL and extract readable content.
|
||||
|
||||
@@ -12,20 +12,16 @@ title: 飞书
|
||||
|
||||
---
|
||||
|
||||
## 需要插件
|
||||
## 内置插件
|
||||
|
||||
安装 Feishu 插件:
|
||||
当前版本的 OpenClaw 已内置 Feishu 插件,因此通常不需要单独安装。
|
||||
|
||||
如果你使用的是较旧版本,或是没有内置 Feishu 的自定义安装,可手动安装:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/feishu
|
||||
```
|
||||
|
||||
本地 checkout(在 git 仓库内运行):
|
||||
|
||||
```bash
|
||||
openclaw plugins install ./extensions/feishu
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
"npmSpec": "@openclaw/googlechat",
|
||||
"localPath": "extensions/googlechat",
|
||||
"defaultChoice": "npm"
|
||||
},
|
||||
"releaseChecks": {
|
||||
"rootDependencyMirrorAllowlist": [
|
||||
"google-auth-library"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,13 @@
|
||||
"npmSpec": "@openclaw/matrix",
|
||||
"localPath": "extensions/matrix",
|
||||
"defaultChoice": "npm"
|
||||
},
|
||||
"releaseChecks": {
|
||||
"rootDependencyMirrorAllowlist": [
|
||||
"@matrix-org/matrix-sdk-crypto-nodejs",
|
||||
"@vector-im/matrix-bot-sdk",
|
||||
"music-metadata"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
"npmSpec": "@openclaw/msteams",
|
||||
"localPath": "extensions/msteams",
|
||||
"defaultChoice": "npm"
|
||||
},
|
||||
"releaseChecks": {
|
||||
"rootDependencyMirrorAllowlist": [
|
||||
"@microsoft/agents-hosting"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
"npmSpec": "@openclaw/nostr",
|
||||
"localPath": "extensions/nostr",
|
||||
"defaultChoice": "npm"
|
||||
},
|
||||
"releaseChecks": {
|
||||
"rootDependencyMirrorAllowlist": [
|
||||
"nostr-tools"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,13 @@
|
||||
"npmSpec": "@openclaw/tlon",
|
||||
"localPath": "extensions/tlon",
|
||||
"defaultChoice": "npm"
|
||||
},
|
||||
"releaseChecks": {
|
||||
"rootDependencyMirrorAllowlist": [
|
||||
"@tloncorp/api",
|
||||
"@tloncorp/tlon-skill",
|
||||
"@urbit/aura"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
63
extensions/zalo/src/api.test.ts
Normal file
63
extensions/zalo/src/api.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { deleteWebhook, getWebhookInfo, sendChatAction, type ZaloFetch } from "./api.js";
|
||||
|
||||
describe("Zalo API request methods", () => {
|
||||
it("uses POST for getWebhookInfo", async () => {
|
||||
const fetcher = vi.fn<ZaloFetch>(
|
||||
async () => new Response(JSON.stringify({ ok: true, result: {} })),
|
||||
);
|
||||
|
||||
await getWebhookInfo("test-token", fetcher);
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
const [, init] = fetcher.mock.calls[0] ?? [];
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(init?.headers).toEqual({ "Content-Type": "application/json" });
|
||||
});
|
||||
|
||||
it("keeps POST for deleteWebhook", async () => {
|
||||
const fetcher = vi.fn<ZaloFetch>(
|
||||
async () => new Response(JSON.stringify({ ok: true, result: {} })),
|
||||
);
|
||||
|
||||
await deleteWebhook("test-token", fetcher);
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
const [, init] = fetcher.mock.calls[0] ?? [];
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(init?.headers).toEqual({ "Content-Type": "application/json" });
|
||||
});
|
||||
|
||||
it("aborts sendChatAction when the typing timeout elapses", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const fetcher = vi.fn<ZaloFetch>(
|
||||
(_, init) =>
|
||||
new Promise<Response>((_, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const promise = sendChatAction(
|
||||
"test-token",
|
||||
{
|
||||
chat_id: "chat-123",
|
||||
action: "typing",
|
||||
},
|
||||
fetcher,
|
||||
25,
|
||||
);
|
||||
const rejected = expect(promise).rejects.toThrow("aborted");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
await rejected;
|
||||
const [, init] = fetcher.mock.calls[0] ?? [];
|
||||
expect(init?.signal?.aborted).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -58,11 +58,22 @@ export type ZaloSendPhotoParams = {
|
||||
caption?: string;
|
||||
};
|
||||
|
||||
export type ZaloSendChatActionParams = {
|
||||
chat_id: string;
|
||||
action: "typing" | "upload_photo";
|
||||
};
|
||||
|
||||
export type ZaloSetWebhookParams = {
|
||||
url: string;
|
||||
secret_token: string;
|
||||
};
|
||||
|
||||
export type ZaloWebhookInfo = {
|
||||
url?: string;
|
||||
updated_at?: number;
|
||||
has_custom_certificate?: boolean;
|
||||
};
|
||||
|
||||
export type ZaloGetUpdatesParams = {
|
||||
/** Timeout in seconds (passed as string to API) */
|
||||
timeout?: number;
|
||||
@@ -161,6 +172,21 @@ export async function sendPhoto(
|
||||
return callZaloApi<ZaloMessage>("sendPhoto", token, params, { fetch: fetcher });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a temporary chat action such as typing.
|
||||
*/
|
||||
export async function sendChatAction(
|
||||
token: string,
|
||||
params: ZaloSendChatActionParams,
|
||||
fetcher?: ZaloFetch,
|
||||
timeoutMs?: number,
|
||||
): Promise<ZaloApiResponse<boolean>> {
|
||||
return callZaloApi<boolean>("sendChatAction", token, params, {
|
||||
timeoutMs,
|
||||
fetch: fetcher,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get updates using long polling (dev/testing only)
|
||||
* Note: Zalo returns a single update per call, not an array like Telegram
|
||||
@@ -183,8 +209,8 @@ export async function setWebhook(
|
||||
token: string,
|
||||
params: ZaloSetWebhookParams,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloApiResponse<boolean>> {
|
||||
return callZaloApi<boolean>("setWebhook", token, params, { fetch: fetcher });
|
||||
): Promise<ZaloApiResponse<ZaloWebhookInfo>> {
|
||||
return callZaloApi<ZaloWebhookInfo>("setWebhook", token, params, { fetch: fetcher });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,8 +219,12 @@ export async function setWebhook(
|
||||
export async function deleteWebhook(
|
||||
token: string,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloApiResponse<boolean>> {
|
||||
return callZaloApi<boolean>("deleteWebhook", token, undefined, { fetch: fetcher });
|
||||
timeoutMs?: number,
|
||||
): Promise<ZaloApiResponse<ZaloWebhookInfo>> {
|
||||
return callZaloApi<ZaloWebhookInfo>("deleteWebhook", token, undefined, {
|
||||
timeoutMs,
|
||||
fetch: fetcher,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,6 +233,6 @@ export async function deleteWebhook(
|
||||
export async function getWebhookInfo(
|
||||
token: string,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloApiResponse<{ url?: string; has_custom_certificate?: boolean }>> {
|
||||
return callZaloApi("getWebhookInfo", token, undefined, { fetch: fetcher });
|
||||
): Promise<ZaloApiResponse<ZaloWebhookInfo>> {
|
||||
return callZaloApi<ZaloWebhookInfo>("getWebhookInfo", token, undefined, { fetch: fetcher });
|
||||
}
|
||||
|
||||
100
extensions/zalo/src/channel.startup.test.ts
Normal file
100
extensions/zalo/src/channel.startup.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/zalo";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStartAccountContext } from "../../test-utils/start-account-context.js";
|
||||
import type { ResolvedZaloAccount } from "./accounts.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
monitorZaloProvider: vi.fn(),
|
||||
probeZalo: vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
error: "probe failed",
|
||||
elapsedMs: 1,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("./monitor.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./monitor.js")>("./monitor.js");
|
||||
return {
|
||||
...actual,
|
||||
monitorZaloProvider: hoisted.monitorZaloProvider,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./probe.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./probe.js")>("./probe.js");
|
||||
return {
|
||||
...actual,
|
||||
probeZalo: hoisted.probeZalo,
|
||||
};
|
||||
});
|
||||
|
||||
import { zaloPlugin } from "./channel.js";
|
||||
|
||||
function buildAccount(): ResolvedZaloAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
token: "test-token",
|
||||
tokenSource: "config",
|
||||
config: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("zaloPlugin gateway.startAccount", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keeps startAccount pending until abort", async () => {
|
||||
hoisted.monitorZaloProvider.mockImplementationOnce(
|
||||
async ({ abortSignal }: { abortSignal: AbortSignal }) =>
|
||||
await new Promise<void>((resolve) => {
|
||||
if (abortSignal.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
abortSignal.addEventListener("abort", () => resolve(), { once: true });
|
||||
}),
|
||||
);
|
||||
|
||||
const patches: ChannelAccountSnapshot[] = [];
|
||||
const abort = new AbortController();
|
||||
const task = zaloPlugin.gateway!.startAccount!(
|
||||
createStartAccountContext({
|
||||
account: buildAccount(),
|
||||
abortSignal: abort.signal,
|
||||
statusPatchSink: (next) => patches.push({ ...next }),
|
||||
}),
|
||||
);
|
||||
|
||||
let settled = false;
|
||||
void task.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(hoisted.probeZalo).toHaveBeenCalledOnce();
|
||||
expect(hoisted.monitorZaloProvider).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
expect(settled).toBe(false);
|
||||
expect(patches).toContainEqual(
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
}),
|
||||
);
|
||||
|
||||
abort.abort();
|
||||
await task;
|
||||
|
||||
expect(settled).toBe(true);
|
||||
expect(hoisted.monitorZaloProvider).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
token: "test-token",
|
||||
account: expect.objectContaining({ accountId: "default" }),
|
||||
abortSignal: abort.signal,
|
||||
useWebhook: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -334,6 +334,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
const token = account.token.trim();
|
||||
const mode = account.config.webhookUrl ? "webhook" : "polling";
|
||||
let zaloBotLabel = "";
|
||||
const fetcher = resolveZaloProxyFetch(account.config.proxy);
|
||||
try {
|
||||
@@ -342,14 +343,21 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
||||
if (name) {
|
||||
zaloBotLabel = ` (${name})`;
|
||||
}
|
||||
if (!probe.ok) {
|
||||
ctx.log?.warn?.(
|
||||
`[${account.accountId}] Zalo probe failed before provider start (${String(probe.elapsedMs)}ms): ${probe.error}`,
|
||||
);
|
||||
}
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
bot: probe.bot,
|
||||
});
|
||||
} catch {
|
||||
// ignore probe errors
|
||||
} catch (err) {
|
||||
ctx.log?.warn?.(
|
||||
`[${account.accountId}] Zalo probe threw before provider start: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
|
||||
);
|
||||
}
|
||||
ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel}`);
|
||||
ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel} mode=${mode}`);
|
||||
const { monitorZaloProvider } = await import("./monitor.js");
|
||||
return monitorZaloProvider({
|
||||
token,
|
||||
|
||||
213
extensions/zalo/src/monitor.lifecycle.test.ts
Normal file
213
extensions/zalo/src/monitor.lifecycle.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
|
||||
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
|
||||
import type { ResolvedZaloAccount } from "./accounts.js";
|
||||
|
||||
const getWebhookInfoMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
|
||||
const deleteWebhookMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
|
||||
const getUpdatesMock = vi.fn(() => new Promise(() => {}));
|
||||
const setWebhookMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
|
||||
|
||||
vi.mock("./api.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./api.js")>();
|
||||
return {
|
||||
...actual,
|
||||
deleteWebhook: deleteWebhookMock,
|
||||
getWebhookInfo: getWebhookInfoMock,
|
||||
getUpdates: getUpdatesMock,
|
||||
setWebhook: setWebhookMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getZaloRuntime: () => ({
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
async function waitForPollingLoopStart(): Promise<void> {
|
||||
await vi.waitFor(() => expect(getUpdatesMock).toHaveBeenCalledTimes(1));
|
||||
}
|
||||
|
||||
describe("monitorZaloProvider lifecycle", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
});
|
||||
|
||||
it("stays alive in polling mode until abort", async () => {
|
||||
const { monitorZaloProvider } = await import("./monitor.js");
|
||||
const abort = new AbortController();
|
||||
const runtime = {
|
||||
log: vi.fn<(message: string) => void>(),
|
||||
error: vi.fn<(message: string) => void>(),
|
||||
};
|
||||
const account = {
|
||||
accountId: "default",
|
||||
config: {},
|
||||
} as unknown as ResolvedZaloAccount;
|
||||
const config = {} as OpenClawConfig;
|
||||
|
||||
let settled = false;
|
||||
const run = monitorZaloProvider({
|
||||
token: "test-token",
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
abortSignal: abort.signal,
|
||||
}).then(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
await waitForPollingLoopStart();
|
||||
|
||||
expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
|
||||
expect(deleteWebhookMock).not.toHaveBeenCalled();
|
||||
expect(getUpdatesMock).toHaveBeenCalledTimes(1);
|
||||
expect(settled).toBe(false);
|
||||
|
||||
abort.abort();
|
||||
await run;
|
||||
|
||||
expect(settled).toBe(true);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Zalo provider stopped mode=polling"),
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes an existing webhook before polling", async () => {
|
||||
getWebhookInfoMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { url: "https://example.com/hooks/zalo" },
|
||||
});
|
||||
|
||||
const { monitorZaloProvider } = await import("./monitor.js");
|
||||
const abort = new AbortController();
|
||||
const runtime = {
|
||||
log: vi.fn<(message: string) => void>(),
|
||||
error: vi.fn<(message: string) => void>(),
|
||||
};
|
||||
const account = {
|
||||
accountId: "default",
|
||||
config: {},
|
||||
} as unknown as ResolvedZaloAccount;
|
||||
const config = {} as OpenClawConfig;
|
||||
|
||||
const run = monitorZaloProvider({
|
||||
token: "test-token",
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
abortSignal: abort.signal,
|
||||
});
|
||||
|
||||
await waitForPollingLoopStart();
|
||||
|
||||
expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
|
||||
expect(deleteWebhookMock).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Zalo polling mode ready (webhook disabled)"),
|
||||
);
|
||||
|
||||
abort.abort();
|
||||
await run;
|
||||
});
|
||||
|
||||
it("continues polling when webhook inspection returns 404", async () => {
|
||||
const { ZaloApiError } = await import("./api.js");
|
||||
getWebhookInfoMock.mockRejectedValueOnce(new ZaloApiError("Not Found", 404, "Not Found"));
|
||||
|
||||
const { monitorZaloProvider } = await import("./monitor.js");
|
||||
const abort = new AbortController();
|
||||
const runtime = {
|
||||
log: vi.fn<(message: string) => void>(),
|
||||
error: vi.fn<(message: string) => void>(),
|
||||
};
|
||||
const account = {
|
||||
accountId: "default",
|
||||
config: {},
|
||||
} as unknown as ResolvedZaloAccount;
|
||||
const config = {} as OpenClawConfig;
|
||||
|
||||
const run = monitorZaloProvider({
|
||||
token: "test-token",
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
abortSignal: abort.signal,
|
||||
});
|
||||
|
||||
await waitForPollingLoopStart();
|
||||
|
||||
expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
|
||||
expect(deleteWebhookMock).not.toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("webhook inspection unavailable; continuing without webhook cleanup"),
|
||||
);
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
|
||||
abort.abort();
|
||||
await run;
|
||||
});
|
||||
|
||||
it("waits for webhook deletion before finishing webhook shutdown", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
let resolveDeleteWebhook: (() => void) | undefined;
|
||||
deleteWebhookMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveDeleteWebhook = () => resolve({ ok: true, result: { url: "" } });
|
||||
}),
|
||||
);
|
||||
|
||||
const { monitorZaloProvider } = await import("./monitor.js");
|
||||
const abort = new AbortController();
|
||||
const runtime = {
|
||||
log: vi.fn<(message: string) => void>(),
|
||||
error: vi.fn<(message: string) => void>(),
|
||||
};
|
||||
const account = {
|
||||
accountId: "default",
|
||||
config: {},
|
||||
} as unknown as ResolvedZaloAccount;
|
||||
const config = {} as OpenClawConfig;
|
||||
|
||||
let settled = false;
|
||||
const run = monitorZaloProvider({
|
||||
token: "test-token",
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
abortSignal: abort.signal,
|
||||
useWebhook: true,
|
||||
webhookUrl: "https://example.com/hooks/zalo",
|
||||
webhookSecret: "supersecret", // pragma: allowlist secret
|
||||
}).then(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(setWebhookMock).toHaveBeenCalledTimes(1));
|
||||
expect(registry.httpRoutes).toHaveLength(1);
|
||||
|
||||
abort.abort();
|
||||
|
||||
await vi.waitFor(() => expect(deleteWebhookMock).toHaveBeenCalledTimes(1));
|
||||
expect(deleteWebhookMock).toHaveBeenCalledWith("test-token", undefined, 5000);
|
||||
expect(settled).toBe(false);
|
||||
expect(registry.httpRoutes).toHaveLength(1);
|
||||
|
||||
resolveDeleteWebhook?.();
|
||||
await run;
|
||||
|
||||
expect(settled).toBe(true);
|
||||
expect(registry.httpRoutes).toHaveLength(0);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Zalo provider stopped mode=webhook"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,11 @@ import type {
|
||||
OutboundReplyPayload,
|
||||
} from "openclaw/plugin-sdk/zalo";
|
||||
import {
|
||||
createTypingCallbacks,
|
||||
createScopedPairingAccess,
|
||||
createReplyPrefixOptions,
|
||||
issuePairingChallenge,
|
||||
logTypingFailure,
|
||||
resolveDirectDmAuthorizationOutcome,
|
||||
resolveSenderCommandAuthorizationWithRuntime,
|
||||
resolveOutboundMediaUrls,
|
||||
@@ -15,13 +17,16 @@ import {
|
||||
resolveInboundRouteEnvelopeBuilderWithRuntime,
|
||||
sendMediaWithLeadingCaption,
|
||||
resolveWebhookPath,
|
||||
waitForAbortSignal,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/zalo";
|
||||
import type { ResolvedZaloAccount } from "./accounts.js";
|
||||
import {
|
||||
ZaloApiError,
|
||||
deleteWebhook,
|
||||
getWebhookInfo,
|
||||
getUpdates,
|
||||
sendChatAction,
|
||||
sendMessage,
|
||||
sendPhoto,
|
||||
setWebhook,
|
||||
@@ -64,15 +69,34 @@ export type ZaloMonitorOptions = {
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
};
|
||||
|
||||
export type ZaloMonitorResult = {
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
const ZALO_TEXT_LIMIT = 2000;
|
||||
const DEFAULT_MEDIA_MAX_MB = 5;
|
||||
const WEBHOOK_CLEANUP_TIMEOUT_MS = 5_000;
|
||||
const ZALO_TYPING_TIMEOUT_MS = 5_000;
|
||||
|
||||
type ZaloCoreRuntime = ReturnType<typeof getZaloRuntime>;
|
||||
|
||||
function formatZaloError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.stack ?? `${error.name}: ${error.message}`;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function describeWebhookTarget(rawUrl: string): string {
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
return `${parsed.origin}${parsed.pathname}`;
|
||||
} catch {
|
||||
return rawUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWebhookUrl(url: string | undefined): string | undefined {
|
||||
const trimmed = url?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
runtime.log?.(`[zalo] ${message}`);
|
||||
@@ -151,6 +175,8 @@ function startPollingLoop(params: {
|
||||
} = params;
|
||||
const pollTimeout = 30;
|
||||
|
||||
runtime.log?.(`[${account.accountId}] Zalo polling loop started timeout=${String(pollTimeout)}s`);
|
||||
|
||||
const poll = async () => {
|
||||
if (isStopped() || abortSignal.aborted) {
|
||||
return;
|
||||
@@ -176,7 +202,7 @@ function startPollingLoop(params: {
|
||||
if (err instanceof ZaloApiError && err.isPollingTimeout) {
|
||||
// no updates
|
||||
} else if (!isStopped() && !abortSignal.aborted) {
|
||||
runtime.error?.(`[${account.accountId}] Zalo polling error: ${String(err)}`);
|
||||
runtime.error?.(`[${account.accountId}] Zalo polling error: ${formatZaloError(err)}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
}
|
||||
@@ -522,12 +548,35 @@ async function processMessageWithPipeline(params: {
|
||||
channel: "zalo",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const typingCallbacks = createTypingCallbacks({
|
||||
start: async () => {
|
||||
await sendChatAction(
|
||||
token,
|
||||
{
|
||||
chat_id: chatId,
|
||||
action: "typing",
|
||||
},
|
||||
fetcher,
|
||||
ZALO_TYPING_TIMEOUT_MS,
|
||||
);
|
||||
},
|
||||
onStartError: (err) => {
|
||||
logTypingFailure({
|
||||
log: (message) => logVerbose(core, runtime, message),
|
||||
channel: "zalo",
|
||||
action: "start",
|
||||
target: chatId,
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config,
|
||||
dispatcherOptions: {
|
||||
...prefixOptions,
|
||||
typingCallbacks,
|
||||
deliver: async (payload) => {
|
||||
await deliverZaloReply({
|
||||
payload,
|
||||
@@ -567,7 +616,6 @@ async function deliverZaloReply(params: {
|
||||
const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params;
|
||||
const tableMode = params.tableMode ?? "code";
|
||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||
|
||||
const sentMedia = await sendMediaWithLeadingCaption({
|
||||
mediaUrls: resolveOutboundMediaUrls(payload),
|
||||
caption: text,
|
||||
@@ -597,7 +645,7 @@ async function deliverZaloReply(params: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<ZaloMonitorResult> {
|
||||
export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<void> {
|
||||
const {
|
||||
token,
|
||||
account,
|
||||
@@ -615,78 +663,140 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
|
||||
const core = getZaloRuntime();
|
||||
const effectiveMediaMaxMb = account.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
||||
const fetcher = fetcherOverride ?? resolveZaloProxyFetch(account.config.proxy);
|
||||
const mode = useWebhook ? "webhook" : "polling";
|
||||
|
||||
let stopped = false;
|
||||
const stopHandlers: Array<() => void> = [];
|
||||
let cleanupWebhook: (() => Promise<void>) | undefined;
|
||||
|
||||
const stop = () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
stopped = true;
|
||||
for (const handler of stopHandlers) {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
if (useWebhook) {
|
||||
if (!webhookUrl || !webhookSecret) {
|
||||
throw new Error("Zalo webhookUrl and webhookSecret are required for webhook mode");
|
||||
}
|
||||
if (!webhookUrl.startsWith("https://")) {
|
||||
throw new Error("Zalo webhook URL must use HTTPS");
|
||||
}
|
||||
if (webhookSecret.length < 8 || webhookSecret.length > 256) {
|
||||
throw new Error("Zalo webhook secret must be 8-256 characters");
|
||||
runtime.log?.(
|
||||
`[${account.accountId}] Zalo provider init mode=${mode} mediaMaxMb=${String(effectiveMediaMaxMb)}`,
|
||||
);
|
||||
|
||||
try {
|
||||
if (useWebhook) {
|
||||
if (!webhookUrl || !webhookSecret) {
|
||||
throw new Error("Zalo webhookUrl and webhookSecret are required for webhook mode");
|
||||
}
|
||||
if (!webhookUrl.startsWith("https://")) {
|
||||
throw new Error("Zalo webhook URL must use HTTPS");
|
||||
}
|
||||
if (webhookSecret.length < 8 || webhookSecret.length > 256) {
|
||||
throw new Error("Zalo webhook secret must be 8-256 characters");
|
||||
}
|
||||
|
||||
const path = resolveWebhookPath({ webhookPath, webhookUrl, defaultPath: null });
|
||||
if (!path) {
|
||||
throw new Error("Zalo webhookPath could not be derived");
|
||||
}
|
||||
|
||||
runtime.log?.(
|
||||
`[${account.accountId}] Zalo configuring webhook path=${path} target=${describeWebhookTarget(webhookUrl)}`,
|
||||
);
|
||||
await setWebhook(token, { url: webhookUrl, secret_token: webhookSecret }, fetcher);
|
||||
let webhookCleanupPromise: Promise<void> | undefined;
|
||||
cleanupWebhook = async () => {
|
||||
if (!webhookCleanupPromise) {
|
||||
webhookCleanupPromise = (async () => {
|
||||
runtime.log?.(`[${account.accountId}] Zalo stopping; deleting webhook`);
|
||||
try {
|
||||
await deleteWebhook(token, fetcher, WEBHOOK_CLEANUP_TIMEOUT_MS);
|
||||
runtime.log?.(`[${account.accountId}] Zalo webhook deleted`);
|
||||
} catch (err) {
|
||||
const detail =
|
||||
err instanceof Error && err.name === "AbortError"
|
||||
? `timed out after ${String(WEBHOOK_CLEANUP_TIMEOUT_MS)}ms`
|
||||
: formatZaloError(err);
|
||||
runtime.error?.(`[${account.accountId}] Zalo webhook delete failed: ${detail}`);
|
||||
}
|
||||
})();
|
||||
}
|
||||
await webhookCleanupPromise;
|
||||
};
|
||||
runtime.log?.(`[${account.accountId}] Zalo webhook registered path=${path}`);
|
||||
|
||||
const unregister = registerZaloWebhookTarget({
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
core,
|
||||
path,
|
||||
secret: webhookSecret,
|
||||
statusSink: (patch) => statusSink?.(patch),
|
||||
mediaMaxMb: effectiveMediaMaxMb,
|
||||
fetcher,
|
||||
});
|
||||
stopHandlers.push(unregister);
|
||||
await waitForAbortSignal(abortSignal);
|
||||
return;
|
||||
}
|
||||
|
||||
const path = resolveWebhookPath({ webhookPath, webhookUrl, defaultPath: null });
|
||||
if (!path) {
|
||||
throw new Error("Zalo webhookPath could not be derived");
|
||||
runtime.log?.(`[${account.accountId}] Zalo polling mode: clearing webhook before startup`);
|
||||
try {
|
||||
try {
|
||||
const currentWebhookUrl = normalizeWebhookUrl(
|
||||
(await getWebhookInfo(token, fetcher)).result?.url,
|
||||
);
|
||||
if (!currentWebhookUrl) {
|
||||
runtime.log?.(`[${account.accountId}] Zalo polling mode ready (no webhook configured)`);
|
||||
} else {
|
||||
runtime.log?.(
|
||||
`[${account.accountId}] Zalo polling mode disabling existing webhook ${describeWebhookTarget(currentWebhookUrl)}`,
|
||||
);
|
||||
await deleteWebhook(token, fetcher);
|
||||
runtime.log?.(`[${account.accountId}] Zalo polling mode ready (webhook disabled)`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ZaloApiError && err.errorCode === 404) {
|
||||
// Some Zalo environments do not expose webhook inspection for polling bots.
|
||||
runtime.log?.(
|
||||
`[${account.accountId}] Zalo polling mode webhook inspection unavailable; continuing without webhook cleanup`,
|
||||
);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
runtime.error?.(
|
||||
`[${account.accountId}] Zalo polling startup could not clear webhook: ${formatZaloError(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
await setWebhook(token, { url: webhookUrl, secret_token: webhookSecret }, fetcher);
|
||||
|
||||
const unregister = registerZaloWebhookTarget({
|
||||
startPollingLoop({
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
core,
|
||||
path,
|
||||
secret: webhookSecret,
|
||||
statusSink: (patch) => statusSink?.(patch),
|
||||
abortSignal,
|
||||
isStopped: () => stopped,
|
||||
mediaMaxMb: effectiveMediaMaxMb,
|
||||
statusSink,
|
||||
fetcher,
|
||||
});
|
||||
stopHandlers.push(unregister);
|
||||
abortSignal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
void deleteWebhook(token, fetcher).catch(() => {});
|
||||
},
|
||||
{ once: true },
|
||||
|
||||
await waitForAbortSignal(abortSignal);
|
||||
} catch (err) {
|
||||
runtime.error?.(
|
||||
`[${account.accountId}] Zalo provider startup failed mode=${mode}: ${formatZaloError(err)}`,
|
||||
);
|
||||
return { stop };
|
||||
throw err;
|
||||
} finally {
|
||||
await cleanupWebhook?.();
|
||||
stop();
|
||||
runtime.log?.(`[${account.accountId}] Zalo provider stopped mode=${mode}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteWebhook(token, fetcher);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
startPollingLoop({
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
core,
|
||||
abortSignal,
|
||||
isStopped: () => stopped,
|
||||
mediaMaxMb: effectiveMediaMaxMb,
|
||||
statusSink,
|
||||
fetcher,
|
||||
});
|
||||
|
||||
return { stop };
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
|
||||
@@ -29,6 +29,11 @@
|
||||
"npmSpec": "@openclaw/zalouser",
|
||||
"localPath": "extensions/zalouser",
|
||||
"defaultChoice": "npm"
|
||||
},
|
||||
"releaseChecks": {
|
||||
"rootDependencyMirrorAllowlist": [
|
||||
"zca-js"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@
|
||||
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
|
||||
"build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts",
|
||||
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
|
||||
"check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope && pnpm check:host-env-policy:swift",
|
||||
"check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope",
|
||||
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links",
|
||||
"check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
|
||||
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
|
||||
@@ -340,6 +340,7 @@
|
||||
"@grammyjs/runner": "^2.0.3",
|
||||
"@grammyjs/transformer-throttler": "^1.2.1",
|
||||
"@homebridge/ciao": "^1.3.5",
|
||||
"@larksuiteoapi/node-sdk": "^1.59.0",
|
||||
"@line/bot-sdk": "^10.6.0",
|
||||
"@lydell/node-pty": "1.2.0-beta.3",
|
||||
"@mariozechner/pi-agent-core": "0.55.3",
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -48,6 +48,9 @@ importers:
|
||||
'@homebridge/ciao':
|
||||
specifier: ^1.3.5
|
||||
version: 1.3.5
|
||||
'@larksuiteoapi/node-sdk':
|
||||
specifier: ^1.59.0
|
||||
version: 1.59.0
|
||||
'@line/bot-sdk':
|
||||
specifier: ^10.6.0
|
||||
version: 10.6.0
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=./version-parse.sh
|
||||
source "$SCRIPT_DIR/version-parse.sh"
|
||||
|
||||
verify_installed_cli() {
|
||||
local package_name="$1"
|
||||
local expected_version="$2"
|
||||
@@ -32,6 +36,8 @@ verify_installed_cli() {
|
||||
installed_version="$(node "$entry_path" --version 2>/dev/null | head -n 1 | tr -d '\r')"
|
||||
fi
|
||||
|
||||
installed_version="$(extract_openclaw_semver "$installed_version")"
|
||||
|
||||
echo "cli=$cli_name installed=$installed_version expected=$expected_version"
|
||||
if [[ "$installed_version" != "$expected_version" ]]; then
|
||||
echo "ERROR: expected ${cli_name}@${expected_version}, got ${cli_name}@${installed_version}" >&2
|
||||
|
||||
14
scripts/docker/install-sh-common/version-parse.sh
Normal file
14
scripts/docker/install-sh-common/version-parse.sh
Normal file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
extract_openclaw_semver() {
|
||||
local raw="${1:-}"
|
||||
local parsed=""
|
||||
parsed="$(
|
||||
printf '%s\n' "$raw" \
|
||||
| tr -d '\r' \
|
||||
| grep -Eo 'v?[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?(\+[0-9A-Za-z.-]+)?' \
|
||||
| head -n 1 \
|
||||
|| true
|
||||
)"
|
||||
printf '%s' "${parsed#v}"
|
||||
}
|
||||
@@ -8,6 +8,7 @@ RUN apt-get update \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh
|
||||
COPY run.sh /usr/local/bin/openclaw-install-e2e
|
||||
RUN chmod +x /usr/local/bin/openclaw-install-e2e
|
||||
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VERIFY_HELPER_PATH="/usr/local/install-sh-common/version-parse.sh"
|
||||
if [[ ! -f "$VERIFY_HELPER_PATH" ]]; then
|
||||
VERIFY_HELPER_PATH="${SCRIPT_DIR}/../install-sh-common/version-parse.sh"
|
||||
fi
|
||||
# shellcheck source=../install-sh-common/version-parse.sh
|
||||
source "$VERIFY_HELPER_PATH"
|
||||
|
||||
INSTALL_URL="${OPENCLAW_INSTALL_URL:-${CLAWDBOT_INSTALL_URL:-https://openclaw.bot/install.sh}}"
|
||||
MODELS_MODE="${OPENCLAW_E2E_MODELS:-${CLAWDBOT_E2E_MODELS:-both}}" # both|openai|anthropic
|
||||
INSTALL_TAG="${OPENCLAW_INSTALL_TAG:-${CLAWDBOT_INSTALL_TAG:-latest}}"
|
||||
@@ -69,6 +77,7 @@ fi
|
||||
|
||||
echo "==> Verify installed version"
|
||||
INSTALLED_VERSION="$(openclaw --version 2>/dev/null | head -n 1 | tr -d '\r')"
|
||||
INSTALLED_VERSION="$(extract_openclaw_semver "$INSTALLED_VERSION")"
|
||||
echo "installed=$INSTALLED_VERSION expected=$EXPECTED_VERSION"
|
||||
if [[ "$INSTALLED_VERSION" != "$EXPECTED_VERSION" ]]; then
|
||||
echo "ERROR: expected openclaw@$EXPECTED_VERSION, got openclaw@$INSTALLED_VERSION" >&2
|
||||
|
||||
@@ -27,6 +27,7 @@ ENV NPM_CONFIG_FUND=false
|
||||
ENV NPM_CONFIG_AUDIT=false
|
||||
|
||||
COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh
|
||||
COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh
|
||||
COPY install-sh-nonroot/run.sh /usr/local/bin/openclaw-install-nonroot
|
||||
RUN sudo chmod +x /usr/local/bin/openclaw-install-nonroot
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ RUN set -eux; \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh
|
||||
COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh
|
||||
COPY install-sh-smoke/run.sh /usr/local/bin/openclaw-install-smoke
|
||||
RUN chmod +x /usr/local/bin/openclaw-install-smoke
|
||||
|
||||
|
||||
@@ -2085,14 +2085,52 @@ run_bootstrap_onboarding_if_needed() {
|
||||
}
|
||||
}
|
||||
|
||||
load_install_version_helpers() {
|
||||
local source_path="${BASH_SOURCE[0]-}"
|
||||
local script_dir=""
|
||||
local helper_path=""
|
||||
if [[ -z "$source_path" || ! -f "$source_path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
script_dir="$(cd "$(dirname "$source_path")" && pwd 2>/dev/null || true)"
|
||||
helper_path="${script_dir}/docker/install-sh-common/version-parse.sh"
|
||||
if [[ -n "$script_dir" && -r "$helper_path" ]]; then
|
||||
# shellcheck source=docker/install-sh-common/version-parse.sh
|
||||
source "$helper_path"
|
||||
fi
|
||||
}
|
||||
|
||||
load_install_version_helpers
|
||||
|
||||
if ! declare -F extract_openclaw_semver >/dev/null 2>&1; then
|
||||
# Inline fallback when version-parse.sh could not be sourced (for example, stdin install).
|
||||
extract_openclaw_semver() {
|
||||
local raw="${1:-}"
|
||||
local parsed=""
|
||||
parsed="$(
|
||||
printf '%s\n' "$raw" \
|
||||
| tr -d '\r' \
|
||||
| grep -Eo 'v?[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?(\+[0-9A-Za-z.-]+)?' \
|
||||
| head -n 1 \
|
||||
|| true
|
||||
)"
|
||||
printf '%s' "${parsed#v}"
|
||||
}
|
||||
fi
|
||||
|
||||
resolve_openclaw_version() {
|
||||
local version=""
|
||||
local raw_version_output=""
|
||||
local claw="${OPENCLAW_BIN:-}"
|
||||
if [[ -z "$claw" ]] && command -v openclaw &> /dev/null; then
|
||||
claw="$(command -v openclaw)"
|
||||
fi
|
||||
if [[ -n "$claw" ]]; then
|
||||
version=$("$claw" --version 2>/dev/null | head -n 1 | tr -d '\r')
|
||||
raw_version_output=$("$claw" --version 2>/dev/null | head -n 1 | tr -d '\r')
|
||||
version="$(extract_openclaw_semver "$raw_version_output")"
|
||||
if [[ -z "$version" ]]; then
|
||||
version="$raw_version_output"
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$version" ]]; then
|
||||
local npm_root=""
|
||||
|
||||
71
scripts/lib/bundled-extension-manifest.ts
Normal file
71
scripts/lib/bundled-extension-manifest.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
export type ExtensionPackageJson = {
|
||||
name?: string;
|
||||
version?: string;
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
openclaw?: {
|
||||
install?: {
|
||||
npmSpec?: string;
|
||||
};
|
||||
releaseChecks?: {
|
||||
rootDependencyMirrorAllowlist?: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type BundledExtension = { id: string; packageJson: ExtensionPackageJson };
|
||||
export type BundledExtensionMetadata = BundledExtension & {
|
||||
npmSpec?: string;
|
||||
rootDependencyMirrorAllowlist: string[];
|
||||
};
|
||||
|
||||
export function normalizeBundledExtensionMetadata(
|
||||
extensions: BundledExtension[],
|
||||
): BundledExtensionMetadata[] {
|
||||
return extensions.map((extension) => ({
|
||||
...extension,
|
||||
npmSpec:
|
||||
typeof extension.packageJson.openclaw?.install?.npmSpec === "string"
|
||||
? extension.packageJson.openclaw.install.npmSpec.trim()
|
||||
: undefined,
|
||||
rootDependencyMirrorAllowlist:
|
||||
extension.packageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist?.filter(
|
||||
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
|
||||
) ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
export function collectBundledExtensionManifestErrors(extensions: BundledExtension[]): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const extension of extensions) {
|
||||
const install = extension.packageJson.openclaw?.install;
|
||||
if (
|
||||
install &&
|
||||
(!install.npmSpec || typeof install.npmSpec !== "string" || !install.npmSpec.trim())
|
||||
) {
|
||||
errors.push(
|
||||
`bundled extension '${extension.id}' manifest invalid | openclaw.install.npmSpec must be a non-empty string`,
|
||||
);
|
||||
}
|
||||
|
||||
const allowlist = extension.packageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist;
|
||||
if (allowlist === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(allowlist)) {
|
||||
errors.push(
|
||||
`bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must be an array of non-empty strings`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const invalidEntries = allowlist.filter((entry) => typeof entry !== "string" || !entry.trim());
|
||||
if (invalidEntries.length > 0) {
|
||||
errors.push(
|
||||
`bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must contain only non-empty strings`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -16,7 +16,14 @@ GIT_BUILD_NUMBER=$(cd "$ROOT_DIR" && git rev-list --count HEAD 2>/dev/null || ec
|
||||
APP_VERSION="${APP_VERSION:-$PKG_VERSION}"
|
||||
APP_BUILD="${APP_BUILD:-}"
|
||||
BUILD_CONFIG="${BUILD_CONFIG:-debug}"
|
||||
BUILD_ARCHS_VALUE="${BUILD_ARCHS:-$(uname -m)}"
|
||||
if [[ -n "${BUILD_ARCHS:-}" ]]; then
|
||||
BUILD_ARCHS_VALUE="${BUILD_ARCHS}"
|
||||
elif [[ "$BUILD_CONFIG" == "release" ]]; then
|
||||
# Release packaging should be universal unless explicitly overridden.
|
||||
BUILD_ARCHS_VALUE="all"
|
||||
else
|
||||
BUILD_ARCHS_VALUE="$(uname -m)"
|
||||
fi
|
||||
if [[ "${BUILD_ARCHS_VALUE}" == "all" ]]; then
|
||||
BUILD_ARCHS_VALUE="arm64 x86_64"
|
||||
fi
|
||||
|
||||
418
scripts/pr
418
scripts/pr
@@ -220,13 +220,47 @@ checkout_prep_branch() {
|
||||
# shellcheck disable=SC1091
|
||||
source .local/prep-context.env
|
||||
|
||||
local prep_branch
|
||||
prep_branch=$(resolve_prep_branch_name "$pr")
|
||||
git checkout "$prep_branch"
|
||||
}
|
||||
|
||||
resolve_prep_branch_name() {
|
||||
local pr="$1"
|
||||
require_artifact .local/prep-context.env
|
||||
# shellcheck disable=SC1091
|
||||
source .local/prep-context.env
|
||||
|
||||
local prep_branch="${PREP_BRANCH:-pr-$pr-prep}"
|
||||
if ! git show-ref --verify --quiet "refs/heads/$prep_branch"; then
|
||||
echo "Expected prep branch $prep_branch not found. Run prepare-init first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git checkout "$prep_branch"
|
||||
printf '%s\n' "$prep_branch"
|
||||
}
|
||||
|
||||
verify_prep_branch_matches_prepared_head() {
|
||||
local pr="$1"
|
||||
local prepared_head_sha="$2"
|
||||
|
||||
local prep_branch
|
||||
prep_branch=$(resolve_prep_branch_name "$pr")
|
||||
local prep_branch_head_sha
|
||||
prep_branch_head_sha=$(git rev-parse "refs/heads/$prep_branch")
|
||||
if [ "$prep_branch_head_sha" = "$prepared_head_sha" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Local prep branch moved after prepare-push (branch=$prep_branch expected $prepared_head_sha, got $prep_branch_head_sha)."
|
||||
if git merge-base --is-ancestor "$prepared_head_sha" "$prep_branch_head_sha" 2>/dev/null; then
|
||||
echo "Unpushed local commits on prep branch:"
|
||||
git log --oneline "${prepared_head_sha}..${prep_branch_head_sha}" | sed 's/^/ /' || true
|
||||
echo "Run scripts/pr prepare-sync-head $pr to push them before merge."
|
||||
else
|
||||
echo "Prep branch no longer contains the prepared head. Re-run prepare-init."
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
resolve_head_push_url() {
|
||||
@@ -389,6 +423,161 @@ resolve_head_push_url_https() {
|
||||
return 1
|
||||
}
|
||||
|
||||
verify_pr_head_branch_matches_expected() {
|
||||
local pr="$1"
|
||||
local expected_head="$2"
|
||||
|
||||
local current_head
|
||||
current_head=$(gh pr view "$pr" --json headRefName --jq .headRefName)
|
||||
if [ "$current_head" != "$expected_head" ]; then
|
||||
echo "PR head branch changed from $expected_head to $current_head. Re-run prepare-init."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
setup_prhead_remote() {
|
||||
local push_url
|
||||
push_url=$(resolve_head_push_url) || {
|
||||
echo "Unable to resolve PR head repo push URL."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Always set prhead to the correct fork URL for this PR.
|
||||
# The remote is repo-level (shared across worktrees), so a previous
|
||||
# prepare-pr run for a different fork PR can leave a stale URL.
|
||||
git remote remove prhead 2>/dev/null || true
|
||||
git remote add prhead "$push_url"
|
||||
}
|
||||
|
||||
resolve_prhead_remote_sha() {
|
||||
local pr_head="$1"
|
||||
|
||||
local remote_sha
|
||||
remote_sha=$(git ls-remote prhead "refs/heads/$pr_head" 2>/dev/null | awk '{print $1}' || true)
|
||||
if [ -z "$remote_sha" ]; then
|
||||
local https_url
|
||||
https_url=$(resolve_head_push_url_https 2>/dev/null) || true
|
||||
local current_push_url
|
||||
current_push_url=$(git remote get-url prhead 2>/dev/null || true)
|
||||
if [ -n "$https_url" ] && [ "$https_url" != "$current_push_url" ]; then
|
||||
echo "SSH remote failed; falling back to HTTPS..."
|
||||
git remote set-url prhead "$https_url"
|
||||
git remote set-url --push prhead "$https_url"
|
||||
remote_sha=$(git ls-remote prhead "refs/heads/$pr_head" 2>/dev/null | awk '{print $1}' || true)
|
||||
fi
|
||||
if [ -z "$remote_sha" ]; then
|
||||
echo "Remote branch refs/heads/$pr_head not found on prhead"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '%s\n' "$remote_sha"
|
||||
}
|
||||
|
||||
run_prepare_push_retry_gates() {
|
||||
local docs_only="${1:-false}"
|
||||
|
||||
bootstrap_deps_if_needed
|
||||
run_quiet_logged "pnpm build (lease-retry)" ".local/lease-retry-build.log" pnpm build
|
||||
run_quiet_logged "pnpm check (lease-retry)" ".local/lease-retry-check.log" pnpm check
|
||||
if [ "$docs_only" != "true" ]; then
|
||||
run_quiet_logged "pnpm test (lease-retry)" ".local/lease-retry-test.log" pnpm test
|
||||
fi
|
||||
}
|
||||
|
||||
push_prep_head_to_pr_branch() {
|
||||
local pr="$1"
|
||||
local pr_head="$2"
|
||||
local prep_head_sha="$3"
|
||||
local lease_sha="$4"
|
||||
local rerun_gates_on_lease_retry="${5:-false}"
|
||||
local docs_only="${6:-false}"
|
||||
local result_env_path="${7:-.local/push-result.env}"
|
||||
|
||||
setup_prhead_remote
|
||||
|
||||
local remote_sha
|
||||
remote_sha=$(resolve_prhead_remote_sha "$pr_head")
|
||||
|
||||
local pushed_from_sha="$remote_sha"
|
||||
if [ "$remote_sha" = "$prep_head_sha" ]; then
|
||||
echo "Remote branch already at local prep HEAD; skipping push."
|
||||
else
|
||||
if [ "$remote_sha" != "$lease_sha" ]; then
|
||||
echo "Remote SHA $remote_sha differs from PR head SHA $lease_sha. Refreshing lease SHA from remote."
|
||||
lease_sha="$remote_sha"
|
||||
fi
|
||||
pushed_from_sha="$lease_sha"
|
||||
local push_output
|
||||
if ! push_output=$(
|
||||
git push --force-with-lease=refs/heads/$pr_head:$lease_sha prhead HEAD:$pr_head 2>&1
|
||||
); then
|
||||
echo "Push failed: $push_output"
|
||||
|
||||
if printf '%s' "$push_output" | grep -qiE '(permission|denied|403|forbidden)'; then
|
||||
echo "Permission denied on git push; trying GraphQL createCommitOnBranch fallback..."
|
||||
if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then
|
||||
local graphql_oid
|
||||
graphql_oid=$(graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$pr_head" "$lease_sha")
|
||||
prep_head_sha="$graphql_oid"
|
||||
else
|
||||
echo "Git push permission denied and no fork owner/repo info for GraphQL fallback."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Lease push failed, retrying once with fresh PR head..."
|
||||
lease_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
|
||||
pushed_from_sha="$lease_sha"
|
||||
|
||||
if [ "$rerun_gates_on_lease_retry" = "true" ]; then
|
||||
git fetch origin "pull/$pr/head:pr-$pr-latest" --force
|
||||
git rebase "pr-$pr-latest"
|
||||
prep_head_sha=$(git rev-parse HEAD)
|
||||
run_prepare_push_retry_gates "$docs_only"
|
||||
fi
|
||||
|
||||
if ! push_output=$(
|
||||
git push --force-with-lease=refs/heads/$pr_head:$lease_sha prhead HEAD:$pr_head 2>&1
|
||||
); then
|
||||
echo "Retry push failed: $push_output"
|
||||
if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then
|
||||
echo "Retry failed; trying GraphQL createCommitOnBranch fallback..."
|
||||
local graphql_oid
|
||||
graphql_oid=$(graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$pr_head" "$lease_sha")
|
||||
prep_head_sha="$graphql_oid"
|
||||
else
|
||||
echo "Git push failed and no fork owner/repo info for GraphQL fallback."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! wait_for_pr_head_sha "$pr" "$prep_head_sha" 8 3; then
|
||||
local observed_sha
|
||||
observed_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
|
||||
echo "Pushed head SHA propagation timed out. expected=$prep_head_sha observed=$observed_sha"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local pr_head_sha_after
|
||||
pr_head_sha_after=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
|
||||
|
||||
git fetch origin main
|
||||
git fetch origin "pull/$pr/head:pr-$pr-verify" --force
|
||||
git merge-base --is-ancestor origin/main "pr-$pr-verify" || {
|
||||
echo "PR branch is behind main after push."
|
||||
exit 1
|
||||
}
|
||||
git branch -D "pr-$pr-verify" 2>/dev/null || true
|
||||
cat > "$result_env_path" <<EOF_ENV
|
||||
PUSH_PREP_HEAD_SHA=$prep_head_sha
|
||||
PUSHED_FROM_SHA=$pushed_from_sha
|
||||
PR_HEAD_SHA_AFTER_PUSH=$pr_head_sha_after
|
||||
EOF_ENV
|
||||
}
|
||||
|
||||
set_review_mode() {
|
||||
local mode="$1"
|
||||
cat > .local/review-mode.env <<EOF_ENV
|
||||
@@ -1265,121 +1454,17 @@ prepare_push() {
|
||||
local prep_head_sha
|
||||
prep_head_sha=$(git rev-parse HEAD)
|
||||
|
||||
local current_head
|
||||
current_head=$(gh pr view "$pr" --json headRefName --jq .headRefName)
|
||||
local lease_sha
|
||||
lease_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
|
||||
local push_result_env=".local/prepare-push-result.env"
|
||||
|
||||
if [ "$current_head" != "$PR_HEAD" ]; then
|
||||
echo "PR head branch changed from $PR_HEAD to $current_head. Re-run prepare-init."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local push_url
|
||||
push_url=$(resolve_head_push_url) || {
|
||||
echo "Unable to resolve PR head repo push URL."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Always set prhead to the correct fork URL for this PR.
|
||||
# The remote is repo-level (shared across worktrees), so a previous
|
||||
# prepare-pr run for a different fork PR can leave a stale URL.
|
||||
git remote remove prhead 2>/dev/null || true
|
||||
git remote add prhead "$push_url"
|
||||
|
||||
local remote_sha
|
||||
remote_sha=$(git ls-remote prhead "refs/heads/$PR_HEAD" 2>/dev/null | awk '{print $1}' || true)
|
||||
if [ -z "$remote_sha" ]; then
|
||||
local https_url
|
||||
https_url=$(resolve_head_push_url_https 2>/dev/null) || true
|
||||
if [ -n "$https_url" ] && [ "$https_url" != "$push_url" ]; then
|
||||
echo "SSH remote failed; falling back to HTTPS..."
|
||||
git remote set-url prhead "$https_url"
|
||||
git remote set-url --push prhead "$https_url"
|
||||
push_url="$https_url"
|
||||
remote_sha=$(git ls-remote prhead "refs/heads/$PR_HEAD" 2>/dev/null | awk '{print $1}' || true)
|
||||
fi
|
||||
if [ -z "$remote_sha" ]; then
|
||||
echo "Remote branch refs/heads/$PR_HEAD not found on prhead"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
local pushed_from_sha="$remote_sha"
|
||||
if [ "$remote_sha" = "$prep_head_sha" ]; then
|
||||
echo "Remote branch already at local prep HEAD; skipping push."
|
||||
else
|
||||
if [ "$remote_sha" != "$lease_sha" ]; then
|
||||
echo "Remote SHA $remote_sha differs from PR head SHA $lease_sha. Refreshing lease SHA from remote."
|
||||
lease_sha="$remote_sha"
|
||||
fi
|
||||
pushed_from_sha="$lease_sha"
|
||||
local push_output
|
||||
if ! push_output=$(git push --force-with-lease=refs/heads/$PR_HEAD:$lease_sha prhead HEAD:$PR_HEAD 2>&1); then
|
||||
echo "Push failed: $push_output"
|
||||
|
||||
# Check if this is a permission error (fork PR) vs a lease conflict.
|
||||
# Permission errors go straight to GraphQL; lease conflicts retry with rebase.
|
||||
if printf '%s' "$push_output" | grep -qiE '(permission|denied|403|forbidden)'; then
|
||||
echo "Permission denied on git push; trying GraphQL createCommitOnBranch fallback..."
|
||||
if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then
|
||||
local graphql_oid
|
||||
graphql_oid=$(graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$PR_HEAD" "$lease_sha")
|
||||
prep_head_sha="$graphql_oid"
|
||||
else
|
||||
echo "Git push permission denied and no fork owner/repo info for GraphQL fallback."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Lease push failed, retrying once with fresh PR head..."
|
||||
|
||||
lease_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
|
||||
pushed_from_sha="$lease_sha"
|
||||
|
||||
git fetch origin "pull/$pr/head:pr-$pr-latest" --force
|
||||
git rebase "pr-$pr-latest"
|
||||
prep_head_sha=$(git rev-parse HEAD)
|
||||
|
||||
bootstrap_deps_if_needed
|
||||
run_quiet_logged "pnpm build (lease-retry)" ".local/lease-retry-build.log" pnpm build
|
||||
run_quiet_logged "pnpm check (lease-retry)" ".local/lease-retry-check.log" pnpm check
|
||||
if [ "${DOCS_ONLY:-false}" != "true" ]; then
|
||||
run_quiet_logged "pnpm test (lease-retry)" ".local/lease-retry-test.log" pnpm test
|
||||
fi
|
||||
|
||||
if ! git push --force-with-lease=refs/heads/$PR_HEAD:$lease_sha prhead HEAD:$PR_HEAD; then
|
||||
# Retry also failed — try GraphQL as last resort.
|
||||
if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then
|
||||
echo "Git push retry failed; trying GraphQL createCommitOnBranch fallback..."
|
||||
local graphql_oid
|
||||
graphql_oid=$(graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$PR_HEAD" "$lease_sha")
|
||||
prep_head_sha="$graphql_oid"
|
||||
else
|
||||
echo "Git push failed and no fork owner/repo info for GraphQL fallback."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! wait_for_pr_head_sha "$pr" "$prep_head_sha" 8 3; then
|
||||
local observed_sha
|
||||
observed_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
|
||||
echo "Pushed head SHA propagation timed out. expected=$prep_head_sha observed=$observed_sha"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local pr_head_sha_after
|
||||
pr_head_sha_after=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
|
||||
|
||||
git fetch origin main
|
||||
git fetch origin "pull/$pr/head:pr-$pr-verify" --force
|
||||
git merge-base --is-ancestor origin/main "pr-$pr-verify" || {
|
||||
echo "PR branch is behind main after push."
|
||||
exit 1
|
||||
}
|
||||
git branch -D "pr-$pr-verify" 2>/dev/null || true
|
||||
verify_pr_head_branch_matches_expected "$pr" "$PR_HEAD"
|
||||
push_prep_head_to_pr_branch "$pr" "$PR_HEAD" "$prep_head_sha" "$lease_sha" true "${DOCS_ONLY:-false}" "$push_result_env"
|
||||
# shellcheck disable=SC1090
|
||||
source "$push_result_env"
|
||||
prep_head_sha="$PUSH_PREP_HEAD_SHA"
|
||||
local pushed_from_sha="$PUSHED_FROM_SHA"
|
||||
local pr_head_sha_after="$PR_HEAD_SHA_AFTER_PUSH"
|
||||
|
||||
local contrib="${PR_AUTHOR:-}"
|
||||
if [ -z "$contrib" ]; then
|
||||
@@ -1430,107 +1515,17 @@ prepare_sync_head() {
|
||||
local prep_head_sha
|
||||
prep_head_sha=$(git rev-parse HEAD)
|
||||
|
||||
local current_head
|
||||
current_head=$(gh pr view "$pr" --json headRefName --jq .headRefName)
|
||||
local lease_sha
|
||||
lease_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
|
||||
local push_result_env=".local/prepare-sync-result.env"
|
||||
|
||||
if [ "$current_head" != "$PR_HEAD" ]; then
|
||||
echo "PR head branch changed from $PR_HEAD to $current_head. Re-run prepare-init."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local push_url
|
||||
push_url=$(resolve_head_push_url) || {
|
||||
echo "Unable to resolve PR head repo push URL."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Always set prhead to the correct fork URL for this PR.
|
||||
# The remote is repo-level (shared across worktrees), so a previous
|
||||
# run for a different fork PR can leave a stale URL.
|
||||
git remote remove prhead 2>/dev/null || true
|
||||
git remote add prhead "$push_url"
|
||||
|
||||
local remote_sha
|
||||
remote_sha=$(git ls-remote prhead "refs/heads/$PR_HEAD" 2>/dev/null | awk '{print $1}' || true)
|
||||
if [ -z "$remote_sha" ]; then
|
||||
local https_url
|
||||
https_url=$(resolve_head_push_url_https 2>/dev/null) || true
|
||||
if [ -n "$https_url" ] && [ "$https_url" != "$push_url" ]; then
|
||||
echo "SSH remote failed; falling back to HTTPS..."
|
||||
git remote set-url prhead "$https_url"
|
||||
git remote set-url --push prhead "$https_url"
|
||||
push_url="$https_url"
|
||||
remote_sha=$(git ls-remote prhead "refs/heads/$PR_HEAD" 2>/dev/null | awk '{print $1}' || true)
|
||||
fi
|
||||
if [ -z "$remote_sha" ]; then
|
||||
echo "Remote branch refs/heads/$PR_HEAD not found on prhead"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
local pushed_from_sha="$remote_sha"
|
||||
if [ "$remote_sha" = "$prep_head_sha" ]; then
|
||||
echo "Remote branch already at local prep HEAD; skipping push."
|
||||
else
|
||||
if [ "$remote_sha" != "$lease_sha" ]; then
|
||||
echo "Remote SHA $remote_sha differs from PR head SHA $lease_sha. Refreshing lease SHA from remote."
|
||||
lease_sha="$remote_sha"
|
||||
fi
|
||||
pushed_from_sha="$lease_sha"
|
||||
local push_output
|
||||
if ! push_output=$(git push --force-with-lease=refs/heads/$PR_HEAD:$lease_sha prhead HEAD:$PR_HEAD 2>&1); then
|
||||
echo "Push failed: $push_output"
|
||||
|
||||
if printf '%s' "$push_output" | grep -qiE '(permission|denied|403|forbidden)'; then
|
||||
echo "Permission denied on git push; trying GraphQL createCommitOnBranch fallback..."
|
||||
if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then
|
||||
local graphql_oid
|
||||
graphql_oid=$(graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$PR_HEAD" "$lease_sha")
|
||||
prep_head_sha="$graphql_oid"
|
||||
else
|
||||
echo "Git push permission denied and no fork owner/repo info for GraphQL fallback."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Lease push failed, retrying once with fresh PR head lease..."
|
||||
lease_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
|
||||
pushed_from_sha="$lease_sha"
|
||||
|
||||
if ! push_output=$(git push --force-with-lease=refs/heads/$PR_HEAD:$lease_sha prhead HEAD:$PR_HEAD 2>&1); then
|
||||
echo "Retry push failed: $push_output"
|
||||
if [ -n "${PR_HEAD_OWNER:-}" ] && [ -n "${PR_HEAD_REPO_NAME:-}" ]; then
|
||||
echo "Retry failed; trying GraphQL createCommitOnBranch fallback..."
|
||||
local graphql_oid
|
||||
graphql_oid=$(graphql_push_to_fork "${PR_HEAD_OWNER}/${PR_HEAD_REPO_NAME}" "$PR_HEAD" "$lease_sha")
|
||||
prep_head_sha="$graphql_oid"
|
||||
else
|
||||
echo "Git push failed and no fork owner/repo info for GraphQL fallback."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! wait_for_pr_head_sha "$pr" "$prep_head_sha" 8 3; then
|
||||
local observed_sha
|
||||
observed_sha=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
|
||||
echo "Pushed head SHA propagation timed out. expected=$prep_head_sha observed=$observed_sha"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local pr_head_sha_after
|
||||
pr_head_sha_after=$(gh pr view "$pr" --json headRefOid --jq .headRefOid)
|
||||
|
||||
git fetch origin main
|
||||
git fetch origin "pull/$pr/head:pr-$pr-verify" --force
|
||||
git merge-base --is-ancestor origin/main "pr-$pr-verify" || {
|
||||
echo "PR branch is behind main after push."
|
||||
exit 1
|
||||
}
|
||||
git branch -D "pr-$pr-verify" 2>/dev/null || true
|
||||
verify_pr_head_branch_matches_expected "$pr" "$PR_HEAD"
|
||||
push_prep_head_to_pr_branch "$pr" "$PR_HEAD" "$prep_head_sha" "$lease_sha" false false "$push_result_env"
|
||||
# shellcheck disable=SC1090
|
||||
source "$push_result_env"
|
||||
prep_head_sha="$PUSH_PREP_HEAD_SHA"
|
||||
local pushed_from_sha="$PUSHED_FROM_SHA"
|
||||
local pr_head_sha_after="$PR_HEAD_SHA_AFTER_PUSH"
|
||||
|
||||
local contrib="${PR_AUTHOR:-}"
|
||||
if [ -z "$contrib" ]; then
|
||||
@@ -1667,6 +1662,7 @@ merge_verify() {
|
||||
require_artifact .local/prep.env
|
||||
# shellcheck disable=SC1091
|
||||
source .local/prep.env
|
||||
verify_prep_branch_matches_prepared_head "$pr" "$PREP_HEAD_SHA"
|
||||
|
||||
local json
|
||||
json=$(pr_meta_json "$pr")
|
||||
|
||||
@@ -4,8 +4,16 @@ import { execSync } from "node:child_process";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
collectBundledExtensionManifestErrors,
|
||||
normalizeBundledExtensionMetadata,
|
||||
type BundledExtension,
|
||||
type ExtensionPackageJson as PackageJson,
|
||||
} from "./lib/bundled-extension-manifest.ts";
|
||||
import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./sparkle-build.ts";
|
||||
|
||||
export { collectBundledExtensionManifestErrors } from "./lib/bundled-extension-manifest.ts";
|
||||
|
||||
type PackFile = { path: string };
|
||||
type PackResult = { files?: PackFile[] };
|
||||
|
||||
@@ -108,11 +116,6 @@ const appcastPath = resolve("appcast.xml");
|
||||
const laneBuildMin = 1_000_000_000;
|
||||
const laneFloorAdoptionDateKey = 20260227;
|
||||
|
||||
type PackageJson = {
|
||||
name?: string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
function normalizePluginSyncVersion(version: string): string {
|
||||
const normalized = version.trim().replace(/^v/, "");
|
||||
const base = /^([0-9]+\.[0-9]+\.[0-9]+)/.exec(normalized)?.[1];
|
||||
@@ -122,6 +125,90 @@ function normalizePluginSyncVersion(version: string): string {
|
||||
return normalized.replace(/[-+].*$/, "");
|
||||
}
|
||||
|
||||
export function collectBundledExtensionRootDependencyGapErrors(params: {
|
||||
rootPackage: PackageJson;
|
||||
extensions: BundledExtension[];
|
||||
}): string[] {
|
||||
const rootDeps = {
|
||||
...params.rootPackage.dependencies,
|
||||
...params.rootPackage.optionalDependencies,
|
||||
};
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const extension of normalizeBundledExtensionMetadata(params.extensions)) {
|
||||
if (!extension.npmSpec) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const missing = Object.keys(extension.packageJson.dependencies ?? {})
|
||||
.filter((dep) => dep !== "openclaw" && !rootDeps[dep])
|
||||
.toSorted();
|
||||
const allowlisted = extension.rootDependencyMirrorAllowlist.toSorted();
|
||||
if (missing.join("\n") !== allowlisted.join("\n")) {
|
||||
const unexpected = missing.filter((dep) => !allowlisted.includes(dep));
|
||||
const resolved = allowlisted.filter((dep) => !missing.includes(dep));
|
||||
const parts = [
|
||||
`bundled extension '${extension.id}' root dependency mirror drift`,
|
||||
`missing in root package: ${missing.length > 0 ? missing.join(", ") : "(none)"}`,
|
||||
];
|
||||
if (unexpected.length > 0) {
|
||||
parts.push(`new gaps: ${unexpected.join(", ")}`);
|
||||
}
|
||||
if (resolved.length > 0) {
|
||||
parts.push(`remove stale allowlist entries: ${resolved.join(", ")}`);
|
||||
}
|
||||
errors.push(parts.join(" | "));
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function collectBundledExtensions(): BundledExtension[] {
|
||||
const extensionsDir = resolve("extensions");
|
||||
const entries = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) =>
|
||||
entry.isDirectory(),
|
||||
);
|
||||
|
||||
return entries.flatMap((entry) => {
|
||||
const packagePath = join(extensionsDir, entry.name, "package.json");
|
||||
try {
|
||||
return [
|
||||
{
|
||||
id: entry.name,
|
||||
packageJson: JSON.parse(readFileSync(packagePath, "utf8")) as PackageJson,
|
||||
},
|
||||
];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function checkBundledExtensionRootDependencyMirrors() {
|
||||
const rootPackage = JSON.parse(readFileSync(resolve("package.json"), "utf8")) as PackageJson;
|
||||
const extensions = collectBundledExtensions();
|
||||
const manifestErrors = collectBundledExtensionManifestErrors(extensions);
|
||||
if (manifestErrors.length > 0) {
|
||||
console.error("release-check: bundled extension manifest validation failed:");
|
||||
for (const error of manifestErrors) {
|
||||
console.error(` - ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
const errors = collectBundledExtensionRootDependencyGapErrors({
|
||||
rootPackage,
|
||||
extensions,
|
||||
});
|
||||
if (errors.length > 0) {
|
||||
console.error("release-check: bundled extension root dependency mirror validation failed:");
|
||||
for (const error of errors) {
|
||||
console.error(` - ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function runPackDry(): PackResult[] {
|
||||
const raw = execSync("npm pack --dry-run --json --ignore-scripts", {
|
||||
encoding: "utf8",
|
||||
@@ -321,6 +408,7 @@ function main() {
|
||||
checkPluginVersions();
|
||||
checkAppcastSparkleVersions();
|
||||
checkPluginSdkExports();
|
||||
checkBundledExtensionRootDependencyMirrors();
|
||||
|
||||
const results = runPackDry();
|
||||
const files = results.flatMap((entry) => entry.files ?? []);
|
||||
|
||||
@@ -31,6 +31,8 @@ const unitIsolatedFilesRaw = [
|
||||
"src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts",
|
||||
// Setup-heavy CLI update flow suite; move off unit-fast critical path.
|
||||
"src/cli/update-cli.test.ts",
|
||||
// Uses temp repos + module cache resets; keep it off vmForks to avoid ref-resolution flakes.
|
||||
"src/infra/git-commit.test.ts",
|
||||
// Expensive schema build/bootstrap checks; keep coverage but run in isolated lane.
|
||||
"src/config/schema.test.ts",
|
||||
"src/config/schema.tags.test.ts",
|
||||
@@ -86,6 +88,8 @@ const unitIsolatedFilesRaw = [
|
||||
"src/slack/monitor/slash.test.ts",
|
||||
// Uses process-level unhandledRejection listeners; keep it off vmForks to avoid cross-file leakage.
|
||||
"src/imessage/monitor.shutdown.unhandled-rejection.test.ts",
|
||||
// Mutates process.cwd() and mocks core module loaders; isolate from the shared fast lane.
|
||||
"src/infra/git-commit.test.ts",
|
||||
];
|
||||
const unitIsolatedFiles = unitIsolatedFilesRaw.filter((file) => fs.existsSync(file));
|
||||
|
||||
@@ -119,7 +123,9 @@ const testProfile =
|
||||
rawTestProfile === "serial"
|
||||
? rawTestProfile
|
||||
: "normal";
|
||||
const shouldSplitUnitRuns = testProfile !== "low" && testProfile !== "serial";
|
||||
// Even on low-memory hosts, keep the isolated lane split so files like
|
||||
// git-commit.test.ts still get the worker/process isolation they require.
|
||||
const shouldSplitUnitRuns = testProfile !== "serial";
|
||||
const runs = [
|
||||
...(shouldSplitUnitRuns
|
||||
? [
|
||||
|
||||
@@ -49,7 +49,9 @@ import {
|
||||
normalizeAcpErrorCode,
|
||||
normalizeActorKey,
|
||||
normalizeSessionKey,
|
||||
requireReadySessionMeta,
|
||||
resolveAcpAgentFromSessionKey,
|
||||
resolveAcpSessionResolutionError,
|
||||
resolveMissingMetaError,
|
||||
resolveRuntimeIdleTtlMs,
|
||||
} from "./manager.utils.js";
|
||||
@@ -332,15 +334,7 @@ export class AcpSessionManager {
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
});
|
||||
if (resolution.kind === "none") {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`Session is not ACP-enabled: ${sessionKey}`,
|
||||
);
|
||||
}
|
||||
if (resolution.kind === "stale") {
|
||||
throw resolution.error;
|
||||
}
|
||||
const resolvedMeta = requireReadySessionMeta(resolution);
|
||||
const {
|
||||
runtime,
|
||||
handle: ensuredHandle,
|
||||
@@ -348,7 +342,7 @@ export class AcpSessionManager {
|
||||
} = await this.ensureRuntimeHandle({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
meta: resolution.meta,
|
||||
meta: resolvedMeta,
|
||||
});
|
||||
let handle = ensuredHandle;
|
||||
let meta = ensuredMeta;
|
||||
@@ -414,19 +408,11 @@ export class AcpSessionManager {
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
});
|
||||
if (resolution.kind === "none") {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`Session is not ACP-enabled: ${sessionKey}`,
|
||||
);
|
||||
}
|
||||
if (resolution.kind === "stale") {
|
||||
throw resolution.error;
|
||||
}
|
||||
const resolvedMeta = requireReadySessionMeta(resolution);
|
||||
const { runtime, handle, meta } = await this.ensureRuntimeHandle({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
meta: resolution.meta,
|
||||
meta: resolvedMeta,
|
||||
});
|
||||
const capabilities = await this.resolveRuntimeCapabilities({ runtime, handle });
|
||||
if (!capabilities.controls.includes("session/set_mode") || !runtime.setMode) {
|
||||
@@ -479,19 +465,11 @@ export class AcpSessionManager {
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
});
|
||||
if (resolution.kind === "none") {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`Session is not ACP-enabled: ${sessionKey}`,
|
||||
);
|
||||
}
|
||||
if (resolution.kind === "stale") {
|
||||
throw resolution.error;
|
||||
}
|
||||
const resolvedMeta = requireReadySessionMeta(resolution);
|
||||
const { runtime, handle, meta } = await this.ensureRuntimeHandle({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
meta: resolution.meta,
|
||||
meta: resolvedMeta,
|
||||
});
|
||||
const inferredPatch = inferRuntimeOptionPatchFromConfigOption(key, value);
|
||||
const capabilities = await this.resolveRuntimeCapabilities({ runtime, handle });
|
||||
@@ -558,17 +536,9 @@ export class AcpSessionManager {
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
});
|
||||
if (resolution.kind === "none") {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`Session is not ACP-enabled: ${sessionKey}`,
|
||||
);
|
||||
}
|
||||
if (resolution.kind === "stale") {
|
||||
throw resolution.error;
|
||||
}
|
||||
const resolvedMeta = requireReadySessionMeta(resolution);
|
||||
const nextOptions = mergeRuntimeOptions({
|
||||
current: resolveRuntimeOptionsFromMeta(resolution.meta),
|
||||
current: resolveRuntimeOptionsFromMeta(resolvedMeta),
|
||||
patch: validatedPatch,
|
||||
});
|
||||
await this.persistRuntimeOptions({
|
||||
@@ -594,19 +564,11 @@ export class AcpSessionManager {
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
});
|
||||
if (resolution.kind === "none") {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`Session is not ACP-enabled: ${sessionKey}`,
|
||||
);
|
||||
}
|
||||
if (resolution.kind === "stale") {
|
||||
throw resolution.error;
|
||||
}
|
||||
const resolvedMeta = requireReadySessionMeta(resolution);
|
||||
const { runtime, handle } = await this.ensureRuntimeHandle({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
meta: resolution.meta,
|
||||
meta: resolvedMeta,
|
||||
});
|
||||
await withAcpRuntimeErrorBoundary({
|
||||
run: async () =>
|
||||
@@ -638,15 +600,7 @@ export class AcpSessionManager {
|
||||
cfg: input.cfg,
|
||||
sessionKey,
|
||||
});
|
||||
if (resolution.kind === "none") {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`Session is not ACP-enabled: ${sessionKey}`,
|
||||
);
|
||||
}
|
||||
if (resolution.kind === "stale") {
|
||||
throw resolution.error;
|
||||
}
|
||||
const resolvedMeta = requireReadySessionMeta(resolution);
|
||||
|
||||
const {
|
||||
runtime,
|
||||
@@ -655,7 +609,7 @@ export class AcpSessionManager {
|
||||
} = await this.ensureRuntimeHandle({
|
||||
cfg: input.cfg,
|
||||
sessionKey,
|
||||
meta: resolution.meta,
|
||||
meta: resolvedMeta,
|
||||
});
|
||||
let handle = ensuredHandle;
|
||||
const meta = ensuredMeta;
|
||||
@@ -810,19 +764,11 @@ export class AcpSessionManager {
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
});
|
||||
if (resolution.kind === "none") {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`Session is not ACP-enabled: ${sessionKey}`,
|
||||
);
|
||||
}
|
||||
if (resolution.kind === "stale") {
|
||||
throw resolution.error;
|
||||
}
|
||||
const resolvedMeta = requireReadySessionMeta(resolution);
|
||||
const { runtime, handle } = await this.ensureRuntimeHandle({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
meta: resolution.meta,
|
||||
meta: resolvedMeta,
|
||||
});
|
||||
try {
|
||||
await withAcpRuntimeErrorBoundary({
|
||||
@@ -868,27 +814,17 @@ export class AcpSessionManager {
|
||||
cfg: input.cfg,
|
||||
sessionKey,
|
||||
});
|
||||
if (resolution.kind === "none") {
|
||||
const resolutionError = resolveAcpSessionResolutionError(resolution);
|
||||
if (resolutionError) {
|
||||
if (input.requireAcpSession ?? true) {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`Session is not ACP-enabled: ${sessionKey}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
runtimeClosed: false,
|
||||
metaCleared: false,
|
||||
};
|
||||
}
|
||||
if (resolution.kind === "stale") {
|
||||
if (input.requireAcpSession ?? true) {
|
||||
throw resolution.error;
|
||||
throw resolutionError;
|
||||
}
|
||||
return {
|
||||
runtimeClosed: false,
|
||||
metaCleared: false,
|
||||
};
|
||||
}
|
||||
const meta = requireReadySessionMeta(resolution);
|
||||
|
||||
let runtimeClosed = false;
|
||||
let runtimeNotice: string | undefined;
|
||||
@@ -896,7 +832,7 @@ export class AcpSessionManager {
|
||||
const { runtime, handle } = await this.ensureRuntimeHandle({
|
||||
cfg: input.cfg,
|
||||
sessionKey,
|
||||
meta: resolution.meta,
|
||||
meta,
|
||||
});
|
||||
await withAcpRuntimeErrorBoundary({
|
||||
run: async () =>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { SessionAcpMeta } from "../../config/sessions/types.js";
|
||||
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import { ACP_ERROR_CODES, AcpRuntimeError } from "../runtime/errors.js";
|
||||
import type { AcpSessionResolution } from "./manager.types.js";
|
||||
|
||||
export function resolveAcpAgentFromSessionKey(sessionKey: string, fallback = "main"): string {
|
||||
const parsed = parseAgentSessionKey(sessionKey);
|
||||
@@ -15,6 +16,28 @@ export function resolveMissingMetaError(sessionKey: string): AcpRuntimeError {
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveAcpSessionResolutionError(
|
||||
resolution: AcpSessionResolution,
|
||||
): AcpRuntimeError | null {
|
||||
if (resolution.kind === "ready") {
|
||||
return null;
|
||||
}
|
||||
if (resolution.kind === "stale") {
|
||||
return resolution.error;
|
||||
}
|
||||
return new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`Session is not ACP-enabled: ${resolution.sessionKey}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function requireReadySessionMeta(resolution: AcpSessionResolution): SessionAcpMeta {
|
||||
if (resolution.kind === "ready") {
|
||||
return resolution.meta;
|
||||
}
|
||||
throw resolveAcpSessionResolutionError(resolution);
|
||||
}
|
||||
|
||||
export function normalizeSessionKey(sessionKey: string): string {
|
||||
return sessionKey.trim();
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ const hoisted = vi.hoisted(() => {
|
||||
const initializeSessionMock = vi.fn();
|
||||
const startAcpSpawnParentStreamRelayMock = vi.fn();
|
||||
const resolveAcpSpawnStreamLogPathMock = vi.fn();
|
||||
const loadSessionStoreMock = vi.fn();
|
||||
const resolveStorePathMock = vi.fn();
|
||||
const resolveSessionTranscriptFileMock = vi.fn();
|
||||
const state = {
|
||||
cfg: createDefaultSpawnConfig(),
|
||||
};
|
||||
@@ -49,6 +52,9 @@ const hoisted = vi.hoisted(() => {
|
||||
initializeSessionMock,
|
||||
startAcpSpawnParentStreamRelayMock,
|
||||
resolveAcpSpawnStreamLogPathMock,
|
||||
loadSessionStoreMock,
|
||||
resolveStorePathMock,
|
||||
resolveSessionTranscriptFileMock,
|
||||
state,
|
||||
};
|
||||
});
|
||||
@@ -86,6 +92,24 @@ vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: (opts: unknown) => hoisted.callGatewayMock(opts),
|
||||
}));
|
||||
|
||||
vi.mock("../config/sessions.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/sessions.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadSessionStore: (storePath: string) => hoisted.loadSessionStoreMock(storePath),
|
||||
resolveStorePath: (store: unknown, opts: unknown) => hoisted.resolveStorePathMock(store, opts),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../config/sessions/transcript.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/sessions/transcript.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveSessionTranscriptFile: (params: unknown) =>
|
||||
hoisted.resolveSessionTranscriptFileMock(params),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../acp/control-plane/manager.js", () => {
|
||||
return {
|
||||
getAcpSessionManager: () => ({
|
||||
@@ -263,6 +287,34 @@ describe("spawnAcpDirect", () => {
|
||||
hoisted.resolveAcpSpawnStreamLogPathMock
|
||||
.mockReset()
|
||||
.mockReturnValue("/tmp/sess-main.acp-stream.jsonl");
|
||||
hoisted.resolveStorePathMock.mockReset().mockReturnValue("/tmp/codex-sessions.json");
|
||||
hoisted.loadSessionStoreMock.mockReset().mockImplementation(() => {
|
||||
const store: Record<string, { sessionId: string; updatedAt: number }> = {};
|
||||
return new Proxy(store, {
|
||||
get(_target, prop) {
|
||||
if (typeof prop === "string" && prop.startsWith("agent:codex:acp:")) {
|
||||
return { sessionId: "sess-123", updatedAt: Date.now() };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
});
|
||||
hoisted.resolveSessionTranscriptFileMock
|
||||
.mockReset()
|
||||
.mockImplementation(async (params: unknown) => {
|
||||
const typed = params as { threadId?: string };
|
||||
const sessionFile = typed.threadId
|
||||
? `/tmp/agents/codex/sessions/sess-123-topic-${typed.threadId}.jsonl`
|
||||
: "/tmp/agents/codex/sessions/sess-123.jsonl";
|
||||
return {
|
||||
sessionFile,
|
||||
sessionEntry: {
|
||||
sessionId: "sess-123",
|
||||
updatedAt: Date.now(),
|
||||
sessionFile,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
it("spawns ACP session, binds a new thread, and dispatches initial task", async () => {
|
||||
@@ -286,6 +338,13 @@ describe("spawnAcpDirect", () => {
|
||||
expect(result.childSessionKey).toMatch(/^agent:codex:acp:/);
|
||||
expect(result.runId).toBe("run-1");
|
||||
expect(result.mode).toBe("session");
|
||||
const patchCalls = hoisted.callGatewayMock.mock.calls
|
||||
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
|
||||
.filter((request) => request.method === "sessions.patch");
|
||||
expect(patchCalls[0]?.params).toMatchObject({
|
||||
key: result.childSessionKey,
|
||||
spawnedBy: "agent:main:main",
|
||||
});
|
||||
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
targetKind: "session",
|
||||
@@ -308,6 +367,12 @@ describe("spawnAcpDirect", () => {
|
||||
mode: "persistent",
|
||||
}),
|
||||
);
|
||||
const transcriptCalls = hoisted.resolveSessionTranscriptFileMock.mock.calls.map(
|
||||
(call: unknown[]) => call[0] as { threadId?: string },
|
||||
);
|
||||
expect(transcriptCalls).toHaveLength(2);
|
||||
expect(transcriptCalls[0]?.threadId).toBeUndefined();
|
||||
expect(transcriptCalls[1]?.threadId).toBe("child-thread");
|
||||
});
|
||||
|
||||
it("does not inline delivery for fresh oneshot ACP runs", async () => {
|
||||
@@ -328,6 +393,13 @@ describe("spawnAcpDirect", () => {
|
||||
|
||||
expect(result.status).toBe("accepted");
|
||||
expect(result.mode).toBe("run");
|
||||
expect(hoisted.resolveSessionTranscriptFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "sess-123",
|
||||
storePath: "/tmp/codex-sessions.json",
|
||||
agentId: "codex",
|
||||
}),
|
||||
);
|
||||
const agentCall = hoisted.callGatewayMock.mock.calls
|
||||
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
|
||||
.find((request) => request.method === "agent");
|
||||
@@ -337,6 +409,32 @@ describe("spawnAcpDirect", () => {
|
||||
expect(agentCall?.params?.threadId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps ACP spawn running when session-file persistence fails", async () => {
|
||||
hoisted.resolveSessionTranscriptFileMock.mockRejectedValueOnce(new Error("disk full"));
|
||||
|
||||
const result = await spawnAcpDirect(
|
||||
{
|
||||
task: "Investigate flaky tests",
|
||||
agentId: "codex",
|
||||
mode: "run",
|
||||
},
|
||||
{
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "telegram",
|
||||
agentAccountId: "default",
|
||||
agentTo: "telegram:6098642967",
|
||||
agentThreadId: "1",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe("accepted");
|
||||
expect(result.childSessionKey).toMatch(/^agent:codex:acp:/);
|
||||
const agentCall = hoisted.callGatewayMock.mock.calls
|
||||
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
|
||||
.find((request) => request.method === "agent");
|
||||
expect(agentCall?.params?.sessionKey).toBe(result.childSessionKey);
|
||||
});
|
||||
|
||||
it("includes cwd in ACP thread intro banner when provided at spawn time", async () => {
|
||||
const result = await spawnAcpDirect(
|
||||
{
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
} from "../channels/thread-bindings-policy.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loadSessionStore, resolveStorePath, type SessionEntry } from "../config/sessions.js";
|
||||
import { resolveSessionTranscriptFile } from "../config/sessions/transcript.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js";
|
||||
import {
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
isSessionBindingError,
|
||||
type SessionBindingRecord,
|
||||
} from "../infra/outbound/session-binding-service.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
|
||||
import {
|
||||
@@ -38,6 +41,9 @@ import {
|
||||
startAcpSpawnParentStreamRelay,
|
||||
} from "./acp-spawn-parent-stream.js";
|
||||
import { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js";
|
||||
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./tools/sessions-helpers.js";
|
||||
|
||||
const log = createSubsystemLogger("agents/acp-spawn");
|
||||
|
||||
export const ACP_SPAWN_MODES = ["run", "session"] as const;
|
||||
export type SpawnAcpMode = (typeof ACP_SPAWN_MODES)[number];
|
||||
@@ -162,6 +168,50 @@ function summarizeError(err: unknown): string {
|
||||
return "error";
|
||||
}
|
||||
|
||||
function resolveRequesterInternalSessionKey(params: {
|
||||
cfg: OpenClawConfig;
|
||||
requesterSessionKey?: string;
|
||||
}): string {
|
||||
const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
|
||||
const requesterSessionKey = params.requesterSessionKey?.trim();
|
||||
return requesterSessionKey
|
||||
? resolveInternalSessionKey({
|
||||
key: requesterSessionKey,
|
||||
alias,
|
||||
mainKey,
|
||||
})
|
||||
: alias;
|
||||
}
|
||||
|
||||
async function persistAcpSpawnSessionFileBestEffort(params: {
|
||||
sessionId: string;
|
||||
sessionKey: string;
|
||||
sessionEntry: SessionEntry | undefined;
|
||||
sessionStore: Record<string, SessionEntry>;
|
||||
storePath: string;
|
||||
agentId: string;
|
||||
threadId?: string | number;
|
||||
stage: "spawn" | "thread-bind";
|
||||
}): Promise<SessionEntry | undefined> {
|
||||
try {
|
||||
const resolvedSessionFile = await resolveSessionTranscriptFile({
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionEntry: params.sessionEntry,
|
||||
sessionStore: params.sessionStore,
|
||||
storePath: params.storePath,
|
||||
agentId: params.agentId,
|
||||
threadId: params.threadId,
|
||||
});
|
||||
return resolvedSessionFile.sessionEntry;
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`ACP session-file persistence failed during ${params.stage} for ${params.sessionKey}: ${summarizeError(error)}`,
|
||||
);
|
||||
return params.sessionEntry;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConversationIdForThreadBinding(params: {
|
||||
to?: string;
|
||||
threadId?: string | number;
|
||||
@@ -257,6 +307,10 @@ export async function spawnAcpDirect(
|
||||
ctx: SpawnAcpContext,
|
||||
): Promise<SpawnAcpResult> {
|
||||
const cfg = loadConfig();
|
||||
const requesterInternalKey = resolveRequesterInternalSessionKey({
|
||||
cfg,
|
||||
requesterSessionKey: ctx.agentSessionKey,
|
||||
});
|
||||
if (!isAcpEnabledByPolicy(cfg)) {
|
||||
return {
|
||||
status: "forbidden",
|
||||
@@ -346,11 +400,27 @@ export async function spawnAcpDirect(
|
||||
method: "sessions.patch",
|
||||
params: {
|
||||
key: sessionKey,
|
||||
spawnedBy: requesterInternalKey,
|
||||
...(params.label ? { label: params.label } : {}),
|
||||
},
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
sessionCreated = true;
|
||||
const storePath = resolveStorePath(cfg.session?.store, { agentId: targetAgentId });
|
||||
const sessionStore = loadSessionStore(storePath);
|
||||
let sessionEntry: SessionEntry | undefined = sessionStore[sessionKey];
|
||||
const sessionId = sessionEntry?.sessionId;
|
||||
if (sessionId) {
|
||||
sessionEntry = await persistAcpSpawnSessionFileBestEffort({
|
||||
sessionId,
|
||||
sessionKey,
|
||||
sessionStore,
|
||||
storePath,
|
||||
sessionEntry,
|
||||
agentId: targetAgentId,
|
||||
stage: "spawn",
|
||||
});
|
||||
}
|
||||
const initialized = await acpManager.initializeSession({
|
||||
cfg,
|
||||
sessionKey,
|
||||
@@ -408,6 +478,21 @@ export async function spawnAcpDirect(
|
||||
`Failed to create and bind a ${preparedBinding.channel} thread for this ACP session.`,
|
||||
);
|
||||
}
|
||||
if (sessionId) {
|
||||
const boundThreadId = String(binding.conversation.conversationId).trim() || undefined;
|
||||
if (boundThreadId) {
|
||||
sessionEntry = await persistAcpSpawnSessionFileBestEffort({
|
||||
sessionId,
|
||||
sessionKey,
|
||||
sessionStore,
|
||||
storePath,
|
||||
sessionEntry,
|
||||
agentId: targetAgentId,
|
||||
threadId: boundThreadId,
|
||||
stage: "thread-bind",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
await cleanupFailedAcpSpawn({
|
||||
|
||||
@@ -271,11 +271,14 @@ export async function resolveApiKeyForProvider(params: {
|
||||
export type EnvApiKeyResult = { apiKey: string; source: string };
|
||||
export type ModelAuthMode = "api-key" | "oauth" | "token" | "mixed" | "aws-sdk" | "unknown";
|
||||
|
||||
export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
|
||||
export function resolveEnvApiKey(
|
||||
provider: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): EnvApiKeyResult | null {
|
||||
const normalized = normalizeProviderId(provider);
|
||||
const applied = new Set(getShellEnvAppliedKeys());
|
||||
const pick = (envVar: string): EnvApiKeyResult | null => {
|
||||
const value = normalizeOptionalSecretInput(process.env[envVar]);
|
||||
const value = normalizeOptionalSecretInput(env[envVar]);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
CUSTOM_PROXY_MODELS_CONFIG,
|
||||
installModelsConfigTestHooks,
|
||||
unsetEnv,
|
||||
withModelsTempHome as withTempHome,
|
||||
@@ -14,33 +14,55 @@ installModelsConfigTestHooks();
|
||||
const TEST_ENV_VAR = "OPENCLAW_MODELS_CONFIG_TEST_ENV";
|
||||
|
||||
describe("models-config", () => {
|
||||
it("applies config env.vars entries while ensuring models.json", async () => {
|
||||
it("uses config env.vars entries for implicit provider discovery without mutating process.env", async () => {
|
||||
await withTempHome(async () => {
|
||||
await withTempEnv([TEST_ENV_VAR], async () => {
|
||||
unsetEnv([TEST_ENV_VAR]);
|
||||
await withTempEnv(["OPENROUTER_API_KEY", TEST_ENV_VAR], async () => {
|
||||
unsetEnv(["OPENROUTER_API_KEY", TEST_ENV_VAR]);
|
||||
const cfg: OpenClawConfig = {
|
||||
...CUSTOM_PROXY_MODELS_CONFIG,
|
||||
env: { vars: { [TEST_ENV_VAR]: "from-config" } },
|
||||
models: { providers: {} },
|
||||
env: {
|
||||
vars: {
|
||||
OPENROUTER_API_KEY: "from-config",
|
||||
[TEST_ENV_VAR]: "from-config",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await ensureOpenClawModelsJson(cfg);
|
||||
const { agentDir } = await ensureOpenClawModelsJson(cfg);
|
||||
|
||||
expect(process.env[TEST_ENV_VAR]).toBe("from-config");
|
||||
expect(process.env.OPENROUTER_API_KEY).toBeUndefined();
|
||||
expect(process.env[TEST_ENV_VAR]).toBeUndefined();
|
||||
|
||||
const modelsJson = JSON.parse(await fs.readFile(`${agentDir}/models.json`, "utf8")) as {
|
||||
providers?: { openrouter?: { apiKey?: string } };
|
||||
};
|
||||
expect(modelsJson.providers?.openrouter?.apiKey).toBe("OPENROUTER_API_KEY");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("does not overwrite already-set host env vars", async () => {
|
||||
it("does not overwrite already-set host env vars while ensuring models.json", async () => {
|
||||
await withTempHome(async () => {
|
||||
await withTempEnv([TEST_ENV_VAR], async () => {
|
||||
await withTempEnv(["OPENROUTER_API_KEY", TEST_ENV_VAR], async () => {
|
||||
process.env.OPENROUTER_API_KEY = "from-host";
|
||||
process.env[TEST_ENV_VAR] = "from-host";
|
||||
const cfg: OpenClawConfig = {
|
||||
...CUSTOM_PROXY_MODELS_CONFIG,
|
||||
env: { vars: { [TEST_ENV_VAR]: "from-config" } },
|
||||
models: { providers: {} },
|
||||
env: {
|
||||
vars: {
|
||||
OPENROUTER_API_KEY: "from-config",
|
||||
[TEST_ENV_VAR]: "from-config",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await ensureOpenClawModelsJson(cfg);
|
||||
const { agentDir } = await ensureOpenClawModelsJson(cfg);
|
||||
|
||||
const modelsJson = JSON.parse(await fs.readFile(`${agentDir}/models.json`, "utf8")) as {
|
||||
providers?: { openrouter?: { apiKey?: string } };
|
||||
};
|
||||
expect(modelsJson.providers?.openrouter?.apiKey).toBe("OPENROUTER_API_KEY");
|
||||
expect(process.env.OPENROUTER_API_KEY).toBe("from-host");
|
||||
expect(process.env[TEST_ENV_VAR]).toBe("from-host");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, vi } from "vitest";
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
|
||||
import { resolveImplicitProviders } from "./models-config.providers.js";
|
||||
|
||||
export async function withModelsTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
return withTempHomeBase(fn, { prefix: "openclaw-models-" });
|
||||
@@ -106,6 +107,8 @@ export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [
|
||||
"TOGETHER_API_KEY",
|
||||
"VOLCANO_ENGINE_API_KEY",
|
||||
"BYTEPLUS_API_KEY",
|
||||
"KILOCODE_API_KEY",
|
||||
"KIMI_API_KEY",
|
||||
"KIMICODE_API_KEY",
|
||||
"GEMINI_API_KEY",
|
||||
"VENICE_API_KEY",
|
||||
@@ -123,6 +126,29 @@ export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [
|
||||
"AWS_SHARED_CREDENTIALS_FILE",
|
||||
];
|
||||
|
||||
export function snapshotImplicitProviderEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const source = env ?? process.env;
|
||||
const snapshot: NodeJS.ProcessEnv = {};
|
||||
|
||||
for (const envVar of MODELS_CONFIG_IMPLICIT_ENV_VARS) {
|
||||
const value = source[envVar];
|
||||
if (value !== undefined) {
|
||||
snapshot[envVar] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export async function resolveImplicitProvidersForTest(
|
||||
params: Parameters<typeof resolveImplicitProviders>[0],
|
||||
) {
|
||||
return await resolveImplicitProviders({
|
||||
...params,
|
||||
env: snapshotImplicitProviderEnv(params.env),
|
||||
});
|
||||
}
|
||||
|
||||
export const CUSTOM_PROXY_MODELS_CONFIG: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
|
||||
@@ -65,7 +65,7 @@ async function runCustomProviderMergeTest(params: {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
api: string;
|
||||
models: Array<{ id: string; name: string; input: string[] }>;
|
||||
models: Array<{ id: string; name: string; input: string[]; api?: string }>;
|
||||
};
|
||||
existingProviderKey?: string;
|
||||
configProviderKey?: string;
|
||||
@@ -246,6 +246,43 @@ describe("models-config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces stale merged baseUrl when the provider api changes", async () => {
|
||||
await withTempHome(async () => {
|
||||
const parsed = await runCustomProviderMergeTest({
|
||||
seedProvider: {
|
||||
baseUrl: "https://agent.example/v1",
|
||||
apiKey: "AGENT_KEY", // pragma: allowlist secret
|
||||
api: "openai-completions",
|
||||
models: [{ id: "agent-model", name: "Agent model", input: ["text"] }],
|
||||
},
|
||||
});
|
||||
expect(parsed.providers.custom?.apiKey).toBe("AGENT_KEY");
|
||||
expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1");
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces stale merged baseUrl when only model-level apis change", async () => {
|
||||
await withTempHome(async () => {
|
||||
const parsed = await runCustomProviderMergeTest({
|
||||
seedProvider: {
|
||||
baseUrl: "https://agent.example/v1",
|
||||
apiKey: "AGENT_KEY", // pragma: allowlist secret
|
||||
api: "",
|
||||
models: [
|
||||
{
|
||||
id: "agent-model",
|
||||
name: "Agent model",
|
||||
input: ["text"],
|
||||
api: "openai-completions",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(parsed.providers.custom?.apiKey).toBe("AGENT_KEY");
|
||||
expect(parsed.providers.custom?.baseUrl).toBe("https://config.example/v1");
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces stale merged apiKey when provider is SecretRef-managed in current config", async () => {
|
||||
await withTempHome(async () => {
|
||||
await writeAgentModelsJson({
|
||||
|
||||
95
src/agents/models-config.merge.test.ts
Normal file
95
src/agents/models-config.merge.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mergeProviderModels,
|
||||
mergeProviders,
|
||||
mergeWithExistingProviderSecrets,
|
||||
type ExistingProviderConfig,
|
||||
} from "./models-config.merge.js";
|
||||
import type { ProviderConfig } from "./models-config.providers.js";
|
||||
|
||||
describe("models-config merge helpers", () => {
|
||||
const preservedApiKey = "AGENT_KEY"; // pragma: allowlist secret
|
||||
|
||||
it("refreshes implicit model metadata while preserving explicit reasoning overrides", () => {
|
||||
const merged = mergeProviderModels(
|
||||
{
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
name: "GPT-5.4",
|
||||
input: ["text"],
|
||||
reasoning: true,
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 100_000,
|
||||
},
|
||||
],
|
||||
} as ProviderConfig,
|
||||
{
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
name: "GPT-5.4",
|
||||
input: ["image"],
|
||||
reasoning: false,
|
||||
contextWindow: 2_000_000,
|
||||
maxTokens: 200_000,
|
||||
},
|
||||
],
|
||||
} as ProviderConfig,
|
||||
);
|
||||
|
||||
expect(merged.models).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "gpt-5.4",
|
||||
input: ["text"],
|
||||
reasoning: false,
|
||||
contextWindow: 2_000_000,
|
||||
maxTokens: 200_000,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("merges explicit providers onto trimmed keys", () => {
|
||||
const merged = mergeProviders({
|
||||
explicit: {
|
||||
" custom ": {
|
||||
api: "openai-responses",
|
||||
models: [] as ProviderConfig["models"],
|
||||
} as ProviderConfig,
|
||||
},
|
||||
});
|
||||
|
||||
expect(merged).toEqual({
|
||||
custom: expect.objectContaining({ api: "openai-responses" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces stale baseUrl when model api surface changes", () => {
|
||||
const merged = mergeWithExistingProviderSecrets({
|
||||
nextProviders: {
|
||||
custom: {
|
||||
baseUrl: "https://config.example/v1",
|
||||
models: [{ id: "model", api: "openai-responses" }],
|
||||
} as ProviderConfig,
|
||||
},
|
||||
existingProviders: {
|
||||
custom: {
|
||||
baseUrl: "https://agent.example/v1",
|
||||
apiKey: preservedApiKey,
|
||||
models: [{ id: "model", api: "openai-completions" }],
|
||||
} as ExistingProviderConfig,
|
||||
},
|
||||
secretRefManagedProviders: new Set<string>(),
|
||||
explicitBaseUrlProviders: new Set<string>(),
|
||||
});
|
||||
|
||||
expect(merged.custom).toEqual(
|
||||
expect.objectContaining({
|
||||
apiKey: preservedApiKey,
|
||||
baseUrl: "https://config.example/v1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
217
src/agents/models-config.merge.ts
Normal file
217
src/agents/models-config.merge.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { isNonSecretApiKeyMarker } from "./model-auth-markers.js";
|
||||
import type { ProviderConfig } from "./models-config.providers.js";
|
||||
|
||||
export type ExistingProviderConfig = ProviderConfig & {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
api?: string;
|
||||
};
|
||||
|
||||
function isPositiveFiniteTokenLimit(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
function resolvePreferredTokenLimit(params: {
|
||||
explicitPresent: boolean;
|
||||
explicitValue: unknown;
|
||||
implicitValue: unknown;
|
||||
}): number | undefined {
|
||||
if (params.explicitPresent && isPositiveFiniteTokenLimit(params.explicitValue)) {
|
||||
return params.explicitValue;
|
||||
}
|
||||
if (isPositiveFiniteTokenLimit(params.implicitValue)) {
|
||||
return params.implicitValue;
|
||||
}
|
||||
return isPositiveFiniteTokenLimit(params.explicitValue) ? params.explicitValue : undefined;
|
||||
}
|
||||
|
||||
function getProviderModelId(model: unknown): string {
|
||||
if (!model || typeof model !== "object") {
|
||||
return "";
|
||||
}
|
||||
const id = (model as { id?: unknown }).id;
|
||||
return typeof id === "string" ? id.trim() : "";
|
||||
}
|
||||
|
||||
export function mergeProviderModels(
|
||||
implicit: ProviderConfig,
|
||||
explicit: ProviderConfig,
|
||||
): ProviderConfig {
|
||||
const implicitModels = Array.isArray(implicit.models) ? implicit.models : [];
|
||||
const explicitModels = Array.isArray(explicit.models) ? explicit.models : [];
|
||||
if (implicitModels.length === 0) {
|
||||
return { ...implicit, ...explicit };
|
||||
}
|
||||
|
||||
const implicitById = new Map(
|
||||
implicitModels
|
||||
.map((model) => [getProviderModelId(model), model] as const)
|
||||
.filter(([id]) => Boolean(id)),
|
||||
);
|
||||
const seen = new Set<string>();
|
||||
|
||||
const mergedModels = explicitModels.map((explicitModel) => {
|
||||
const id = getProviderModelId(explicitModel);
|
||||
if (!id) {
|
||||
return explicitModel;
|
||||
}
|
||||
seen.add(id);
|
||||
const implicitModel = implicitById.get(id);
|
||||
if (!implicitModel) {
|
||||
return explicitModel;
|
||||
}
|
||||
|
||||
const contextWindow = resolvePreferredTokenLimit({
|
||||
explicitPresent: "contextWindow" in explicitModel,
|
||||
explicitValue: explicitModel.contextWindow,
|
||||
implicitValue: implicitModel.contextWindow,
|
||||
});
|
||||
const maxTokens = resolvePreferredTokenLimit({
|
||||
explicitPresent: "maxTokens" in explicitModel,
|
||||
explicitValue: explicitModel.maxTokens,
|
||||
implicitValue: implicitModel.maxTokens,
|
||||
});
|
||||
|
||||
return {
|
||||
...explicitModel,
|
||||
input: implicitModel.input,
|
||||
reasoning: "reasoning" in explicitModel ? explicitModel.reasoning : implicitModel.reasoning,
|
||||
...(contextWindow === undefined ? {} : { contextWindow }),
|
||||
...(maxTokens === undefined ? {} : { maxTokens }),
|
||||
};
|
||||
});
|
||||
|
||||
for (const implicitModel of implicitModels) {
|
||||
const id = getProviderModelId(implicitModel);
|
||||
if (!id || seen.has(id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(id);
|
||||
mergedModels.push(implicitModel);
|
||||
}
|
||||
|
||||
return {
|
||||
...implicit,
|
||||
...explicit,
|
||||
models: mergedModels,
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeProviders(params: {
|
||||
implicit?: Record<string, ProviderConfig> | null;
|
||||
explicit?: Record<string, ProviderConfig> | null;
|
||||
}): Record<string, ProviderConfig> {
|
||||
const out: Record<string, ProviderConfig> = params.implicit ? { ...params.implicit } : {};
|
||||
for (const [key, explicit] of Object.entries(params.explicit ?? {})) {
|
||||
const providerKey = key.trim();
|
||||
if (!providerKey) {
|
||||
continue;
|
||||
}
|
||||
const implicit = out[providerKey];
|
||||
out[providerKey] = implicit ? mergeProviderModels(implicit, explicit) : explicit;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolveProviderApi(entry: { api?: unknown } | undefined): string | undefined {
|
||||
if (typeof entry?.api !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const api = entry.api.trim();
|
||||
return api || undefined;
|
||||
}
|
||||
|
||||
function resolveModelApiSurface(entry: { models?: unknown } | undefined): string | undefined {
|
||||
if (!Array.isArray(entry?.models)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const apis = entry.models
|
||||
.flatMap((model) => {
|
||||
if (!model || typeof model !== "object") {
|
||||
return [];
|
||||
}
|
||||
const api = (model as { api?: unknown }).api;
|
||||
return typeof api === "string" && api.trim() ? [api.trim()] : [];
|
||||
})
|
||||
.toSorted();
|
||||
|
||||
return apis.length > 0 ? JSON.stringify(apis) : undefined;
|
||||
}
|
||||
|
||||
function resolveProviderApiSurface(
|
||||
entry: ExistingProviderConfig | ProviderConfig | undefined,
|
||||
): string | undefined {
|
||||
return resolveProviderApi(entry) ?? resolveModelApiSurface(entry);
|
||||
}
|
||||
|
||||
function shouldPreserveExistingApiKey(params: {
|
||||
providerKey: string;
|
||||
existing: ExistingProviderConfig;
|
||||
secretRefManagedProviders: ReadonlySet<string>;
|
||||
}): boolean {
|
||||
const { providerKey, existing, secretRefManagedProviders } = params;
|
||||
return (
|
||||
!secretRefManagedProviders.has(providerKey) &&
|
||||
typeof existing.apiKey === "string" &&
|
||||
existing.apiKey.length > 0 &&
|
||||
!isNonSecretApiKeyMarker(existing.apiKey, { includeEnvVarName: false })
|
||||
);
|
||||
}
|
||||
|
||||
function shouldPreserveExistingBaseUrl(params: {
|
||||
providerKey: string;
|
||||
existing: ExistingProviderConfig;
|
||||
nextEntry: ProviderConfig;
|
||||
explicitBaseUrlProviders: ReadonlySet<string>;
|
||||
}): boolean {
|
||||
const { providerKey, existing, nextEntry, explicitBaseUrlProviders } = params;
|
||||
if (
|
||||
explicitBaseUrlProviders.has(providerKey) ||
|
||||
typeof existing.baseUrl !== "string" ||
|
||||
existing.baseUrl.length === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existingApi = resolveProviderApiSurface(existing);
|
||||
const nextApi = resolveProviderApiSurface(nextEntry);
|
||||
return !existingApi || !nextApi || existingApi === nextApi;
|
||||
}
|
||||
|
||||
export function mergeWithExistingProviderSecrets(params: {
|
||||
nextProviders: Record<string, ProviderConfig>;
|
||||
existingProviders: Record<string, ExistingProviderConfig>;
|
||||
secretRefManagedProviders: ReadonlySet<string>;
|
||||
explicitBaseUrlProviders: ReadonlySet<string>;
|
||||
}): Record<string, ProviderConfig> {
|
||||
const { nextProviders, existingProviders, secretRefManagedProviders, explicitBaseUrlProviders } =
|
||||
params;
|
||||
const mergedProviders: Record<string, ProviderConfig> = {};
|
||||
for (const [key, entry] of Object.entries(existingProviders)) {
|
||||
mergedProviders[key] = entry;
|
||||
}
|
||||
for (const [key, newEntry] of Object.entries(nextProviders)) {
|
||||
const existing = existingProviders[key];
|
||||
if (!existing) {
|
||||
mergedProviders[key] = newEntry;
|
||||
continue;
|
||||
}
|
||||
const preserved: Record<string, unknown> = {};
|
||||
if (shouldPreserveExistingApiKey({ providerKey: key, existing, secretRefManagedProviders })) {
|
||||
preserved.apiKey = existing.apiKey;
|
||||
}
|
||||
if (
|
||||
shouldPreserveExistingBaseUrl({
|
||||
providerKey: key,
|
||||
existing,
|
||||
nextEntry: newEntry,
|
||||
explicitBaseUrlProviders,
|
||||
})
|
||||
) {
|
||||
preserved.baseUrl = existing.baseUrl;
|
||||
}
|
||||
mergedProviders[key] = { ...newEntry, ...preserved };
|
||||
}
|
||||
return mergedProviders;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
NON_ENV_SECRETREF_MARKER,
|
||||
QWEN_OAUTH_MARKER,
|
||||
} from "./model-auth-markers.js";
|
||||
import { resolveImplicitProviders } from "./models-config.providers.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
|
||||
describe("models-config provider auth provenance", () => {
|
||||
it("persists env keyRef and tokenRef auth profiles as env var markers", async () => {
|
||||
@@ -41,7 +41,7 @@ describe("models-config provider auth provenance", () => {
|
||||
"utf8",
|
||||
);
|
||||
try {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} });
|
||||
expect(providers?.volcengine?.apiKey).toBe("VOLCANO_ENGINE_API_KEY");
|
||||
expect(providers?.["volcengine-plan"]?.apiKey).toBe("VOLCANO_ENGINE_API_KEY");
|
||||
expect(providers?.together?.apiKey).toBe("TOGETHER_API_KEY");
|
||||
@@ -78,7 +78,7 @@ describe("models-config provider auth provenance", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} });
|
||||
expect(providers?.byteplus?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
|
||||
expect(providers?.["byteplus-plan"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
|
||||
expect(providers?.together?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
|
||||
@@ -114,7 +114,7 @@ describe("models-config provider auth provenance", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} });
|
||||
expect(providers?.["minimax-portal"]?.apiKey).toBe(MINIMAX_OAUTH_MARKER);
|
||||
expect(providers?.["qwen-portal"]?.apiKey).toBe(QWEN_OAUTH_MARKER);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
|
||||
import { resolveImplicitProviders } from "./models-config.providers.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
|
||||
describe("cloudflare-ai-gateway profile provenance", () => {
|
||||
it("prefers env keyRef marker over runtime plaintext for persistence", async () => {
|
||||
@@ -37,7 +37,7 @@ describe("cloudflare-ai-gateway profile provenance", () => {
|
||||
"utf8",
|
||||
);
|
||||
try {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.["cloudflare-ai-gateway"]?.apiKey).toBe("CLOUDFLARE_AI_GATEWAY_API_KEY");
|
||||
} finally {
|
||||
envSnapshot.restore();
|
||||
@@ -70,7 +70,7 @@ describe("cloudflare-ai-gateway profile provenance", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.["cloudflare-ai-gateway"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
|
||||
import { resolveImplicitProviders } from "./models-config.providers.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
|
||||
describe("provider discovery auth marker guardrails", () => {
|
||||
let originalVitest: string | undefined;
|
||||
@@ -63,7 +63,7 @@ describe("provider discovery auth marker guardrails", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} });
|
||||
expect(providers?.vllm?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
|
||||
const request = fetchMock.mock.calls[0]?.[1] as
|
||||
| { headers?: Record<string, string> }
|
||||
@@ -96,7 +96,7 @@ describe("provider discovery auth marker guardrails", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} });
|
||||
expect(providers?.huggingface?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
|
||||
const huggingfaceCalls = fetchMock.mock.calls.filter(([url]) =>
|
||||
String(url).includes("router.huggingface.co"),
|
||||
@@ -132,7 +132,7 @@ describe("provider discovery auth marker guardrails", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await resolveImplicitProviders({ agentDir });
|
||||
await resolveImplicitProvidersForTest({ agentDir, env: {} });
|
||||
const vllmCall = fetchMock.mock.calls.find(([url]) => String(url).includes(":8000"));
|
||||
const request = vllmCall?.[1] as { headers?: Record<string, string> } | undefined;
|
||||
expect(request?.headers?.Authorization).toBe("Bearer ALLCAPS_SAMPLE");
|
||||
|
||||
@@ -3,7 +3,8 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { buildKilocodeProvider, resolveImplicitProviders } from "./models-config.providers.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
import { buildKilocodeProvider } from "./models-config.providers.js";
|
||||
|
||||
const KILOCODE_MODEL_IDS = ["kilo/auto"];
|
||||
|
||||
@@ -14,7 +15,7 @@ describe("Kilo Gateway implicit provider", () => {
|
||||
process.env.KILOCODE_API_KEY = "test-key"; // pragma: allowlist secret
|
||||
|
||||
try {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.kilocode).toBeDefined();
|
||||
expect(providers?.kilocode?.models?.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
@@ -28,7 +29,7 @@ describe("Kilo Gateway implicit provider", () => {
|
||||
delete process.env.KILOCODE_API_KEY;
|
||||
|
||||
try {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.kilocode).toBeUndefined();
|
||||
} finally {
|
||||
envSnapshot.restore();
|
||||
|
||||
@@ -3,7 +3,8 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { buildKimiCodingProvider, resolveImplicitProviders } from "./models-config.providers.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
import { buildKimiCodingProvider } from "./models-config.providers.js";
|
||||
|
||||
describe("kimi-coding implicit provider (#22409)", () => {
|
||||
it("should include kimi-coding when KIMI_API_KEY is configured", async () => {
|
||||
@@ -12,7 +13,7 @@ describe("kimi-coding implicit provider (#22409)", () => {
|
||||
process.env.KIMI_API_KEY = "test-key"; // pragma: allowlist secret
|
||||
|
||||
try {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.["kimi-coding"]).toBeDefined();
|
||||
expect(providers?.["kimi-coding"]?.api).toBe("anthropic-messages");
|
||||
expect(providers?.["kimi-coding"]?.baseUrl).toBe("https://api.kimi.com/coding/");
|
||||
@@ -36,7 +37,7 @@ describe("kimi-coding implicit provider (#22409)", () => {
|
||||
delete process.env.KIMI_API_KEY;
|
||||
|
||||
try {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.["kimi-coding"]).toBeUndefined();
|
||||
} finally {
|
||||
envSnapshot.restore();
|
||||
|
||||
175
src/agents/models-config.providers.matrix.test.ts
Normal file
175
src/agents/models-config.providers.matrix.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
MINIMAX_OAUTH_MARKER,
|
||||
NON_ENV_SECRETREF_MARKER,
|
||||
OLLAMA_LOCAL_AUTH_MARKER,
|
||||
} from "./model-auth-markers.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
|
||||
type ProvidersMap = Awaited<ReturnType<typeof resolveImplicitProvidersForTest>>;
|
||||
type ExplicitProviders = NonNullable<NonNullable<OpenClawConfig["models"]>["providers"]>;
|
||||
type MatrixCase = {
|
||||
name: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
authProfiles?: Record<string, unknown>;
|
||||
explicitProviders?: ExplicitProviders;
|
||||
assertProviders: (providers: ProvidersMap) => void;
|
||||
};
|
||||
|
||||
async function writeAuthProfiles(
|
||||
agentDir: string,
|
||||
profiles: Record<string, unknown> | undefined,
|
||||
): Promise<void> {
|
||||
if (!profiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
join(agentDir, "auth-profiles.json"),
|
||||
JSON.stringify({ version: 1, profiles }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
const MATRIX_CASES: MatrixCase[] = [
|
||||
{
|
||||
name: "env api key injects a simple provider",
|
||||
env: { NVIDIA_API_KEY: "test-nvidia-key" },
|
||||
assertProviders(providers) {
|
||||
expect(providers?.nvidia?.apiKey).toBe("NVIDIA_API_KEY");
|
||||
expect(providers?.nvidia?.baseUrl).toBe("https://integrate.api.nvidia.com/v1");
|
||||
expect(providers?.nvidia?.models?.length).toBeGreaterThan(0);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "env api key injects paired plan providers",
|
||||
env: { VOLCANO_ENGINE_API_KEY: "test-volcengine-key" },
|
||||
assertProviders(providers) {
|
||||
expect(providers?.volcengine?.apiKey).toBe("VOLCANO_ENGINE_API_KEY");
|
||||
expect(providers?.["volcengine-plan"]?.apiKey).toBe("VOLCANO_ENGINE_API_KEY");
|
||||
expect(providers?.["volcengine-plan"]?.api).toBe("openai-completions");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "env-backed auth profiles persist env markers",
|
||||
env: {},
|
||||
authProfiles: {
|
||||
"together:default": {
|
||||
type: "token",
|
||||
provider: "together",
|
||||
tokenRef: { source: "env", provider: "default", id: "TOGETHER_API_KEY" },
|
||||
},
|
||||
},
|
||||
assertProviders(providers) {
|
||||
expect(providers?.together?.apiKey).toBe("TOGETHER_API_KEY");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non-env secret refs preserve compatibility markers",
|
||||
env: {},
|
||||
authProfiles: {
|
||||
"byteplus:default": {
|
||||
type: "api_key",
|
||||
provider: "byteplus",
|
||||
key: "runtime-byteplus-key",
|
||||
keyRef: { source: "file", provider: "vault", id: "/byteplus/apiKey" },
|
||||
},
|
||||
},
|
||||
assertProviders(providers) {
|
||||
expect(providers?.byteplus?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
|
||||
expect(providers?.["byteplus-plan"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "oauth profiles still inject compatibility providers",
|
||||
env: {},
|
||||
authProfiles: {
|
||||
"openai-codex:default": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "codex-access-token",
|
||||
refresh: "codex-refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
"minimax-portal:default": {
|
||||
type: "oauth",
|
||||
provider: "minimax-portal",
|
||||
access: "minimax-access-token",
|
||||
refresh: "minimax-refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
assertProviders(providers) {
|
||||
expect(providers?.["openai-codex"]).toMatchObject({
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
api: "openai-codex-responses",
|
||||
models: [],
|
||||
});
|
||||
expect(providers?.["openai-codex"]).not.toHaveProperty("apiKey");
|
||||
expect(providers?.["minimax-portal"]?.apiKey).toBe(MINIMAX_OAUTH_MARKER);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explicit vllm config suppresses implicit vllm injection",
|
||||
env: { VLLM_API_KEY: "test-vllm-key" },
|
||||
explicitProviders: {
|
||||
vllm: {
|
||||
baseUrl: "http://127.0.0.1:8000/v1",
|
||||
api: "openai-completions",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
assertProviders(providers) {
|
||||
expect(providers?.vllm).toBeUndefined();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explicit ollama models still normalize the returned provider",
|
||||
env: {},
|
||||
explicitProviders: {
|
||||
ollama: {
|
||||
baseUrl: "http://remote-ollama:11434/v1",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-oss:20b",
|
||||
name: "GPT-OSS 20B",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 81920,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
assertProviders(providers) {
|
||||
expect(providers?.ollama?.baseUrl).toBe("http://remote-ollama:11434");
|
||||
expect(providers?.ollama?.api).toBe("ollama");
|
||||
expect(providers?.ollama?.apiKey).toBe(OLLAMA_LOCAL_AUTH_MARKER);
|
||||
expect(providers?.ollama?.models).toHaveLength(1);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("implicit provider resolution matrix", () => {
|
||||
it.each(MATRIX_CASES)(
|
||||
"$name",
|
||||
async ({ env, authProfiles, explicitProviders, assertProviders }) => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
await writeAuthProfiles(agentDir, authProfiles);
|
||||
|
||||
const providers = await resolveImplicitProvidersForTest({
|
||||
agentDir,
|
||||
env,
|
||||
explicitProviders,
|
||||
});
|
||||
|
||||
assertProviders(providers);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveImplicitProviders } from "./models-config.providers.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
|
||||
describe("minimax provider catalog", () => {
|
||||
it("does not advertise the removed lightning model for api-key or oauth providers", async () => {
|
||||
@@ -34,7 +34,7 @@ describe("minimax provider catalog", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.minimax?.models?.map((model) => model.id)).toEqual([
|
||||
"MiniMax-VL-01",
|
||||
"MiniMax-M2.5",
|
||||
|
||||
@@ -5,13 +5,14 @@ import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { resolveApiKeyForProvider } from "./model-auth.js";
|
||||
import { buildNvidiaProvider, resolveImplicitProviders } from "./models-config.providers.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
import { buildNvidiaProvider } from "./models-config.providers.js";
|
||||
|
||||
describe("NVIDIA provider", () => {
|
||||
it("should include nvidia when NVIDIA_API_KEY is configured", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
await withEnvAsync({ NVIDIA_API_KEY: "test-key" }, async () => {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.nvidia).toBeDefined();
|
||||
expect(providers?.nvidia?.models?.length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -52,7 +53,7 @@ describe("MiniMax implicit provider (#15275)", () => {
|
||||
it("should use anthropic-messages API for API-key provider", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
await withEnvAsync({ MINIMAX_API_KEY: "test-key" }, async () => {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.minimax).toBeDefined();
|
||||
expect(providers?.minimax?.api).toBe("anthropic-messages");
|
||||
expect(providers?.minimax?.authHeader).toBe(true);
|
||||
@@ -83,14 +84,14 @@ describe("MiniMax implicit provider (#15275)", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.["minimax-portal"]?.authHeader).toBe(true);
|
||||
});
|
||||
|
||||
it("should include minimax portal provider when MINIMAX_OAUTH_TOKEN is configured", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
await withEnvAsync({ MINIMAX_OAUTH_TOKEN: "portal-token" }, async () => {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.["minimax-portal"]).toBeDefined();
|
||||
expect(providers?.["minimax-portal"]?.authHeader).toBe(true);
|
||||
expect(providers?.["minimax-portal"]?.models?.some((m) => m.id === "MiniMax-VL-01")).toBe(
|
||||
@@ -104,7 +105,7 @@ describe("vLLM provider", () => {
|
||||
it("should not include vllm when no API key is configured", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
await withEnvAsync({ VLLM_API_KEY: undefined }, async () => {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.vllm).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -112,7 +113,7 @@ describe("vLLM provider", () => {
|
||||
it("should include vllm when VLLM_API_KEY is set", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
await withEnvAsync({ VLLM_API_KEY: "test-key" }, async () => {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
|
||||
expect(providers?.vllm).toBeDefined();
|
||||
expect(providers?.vllm?.apiKey).toBe("VLLM_API_KEY");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveImplicitProviders } from "./models-config.providers.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
|
||||
describe("Ollama auto-discovery", () => {
|
||||
let originalVitest: string | undefined;
|
||||
@@ -55,7 +55,7 @@ describe("Ollama auto-discovery", () => {
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
|
||||
expect(providers?.ollama).toBeDefined();
|
||||
expect(providers?.ollama?.apiKey).toBe("ollama-local");
|
||||
@@ -73,7 +73,7 @@ describe("Ollama auto-discovery", () => {
|
||||
mockOllamaUnreachable();
|
||||
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
|
||||
expect(providers?.ollama).toBeUndefined();
|
||||
const ollamaWarnings = warnSpy.mock.calls.filter(
|
||||
@@ -89,7 +89,7 @@ describe("Ollama auto-discovery", () => {
|
||||
mockOllamaUnreachable();
|
||||
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
await resolveImplicitProviders({
|
||||
await resolveImplicitProvidersForTest({
|
||||
agentDir,
|
||||
explicitProviders: {
|
||||
ollama: {
|
||||
|
||||
@@ -3,7 +3,8 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ModelDefinitionConfig } from "../config/types.models.js";
|
||||
import { resolveImplicitProviders, resolveOllamaApiBase } from "./models-config.providers.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
import { resolveOllamaApiBase } from "./models-config.providers.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
@@ -60,7 +61,7 @@ describe("Ollama provider", () => {
|
||||
}
|
||||
|
||||
async function resolveProvidersWithOllamaKey(agentDir: string) {
|
||||
return await withOllamaApiKey(async () => await resolveImplicitProviders({ agentDir }));
|
||||
return await withOllamaApiKey(async () => await resolveImplicitProvidersForTest({ agentDir }));
|
||||
}
|
||||
|
||||
const createTagModel = (name: string) => ({ name, modified_at: "", size: 1, digest: "" });
|
||||
@@ -78,7 +79,7 @@ describe("Ollama provider", () => {
|
||||
|
||||
it("should not include ollama when no API key is configured", async () => {
|
||||
const agentDir = createAgentDir();
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
|
||||
expect(providers?.ollama).toBeUndefined();
|
||||
});
|
||||
@@ -86,7 +87,7 @@ describe("Ollama provider", () => {
|
||||
it("should use native ollama api type", async () => {
|
||||
const agentDir = createAgentDir();
|
||||
await withOllamaApiKey(async () => {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
|
||||
expect(providers?.ollama).toBeDefined();
|
||||
expect(providers?.ollama?.apiKey).toBe("OLLAMA_API_KEY");
|
||||
@@ -98,7 +99,7 @@ describe("Ollama provider", () => {
|
||||
it("should preserve explicit ollama baseUrl on implicit provider injection", async () => {
|
||||
const agentDir = createAgentDir();
|
||||
await withOllamaApiKey(async () => {
|
||||
const providers = await resolveImplicitProviders({
|
||||
const providers = await resolveImplicitProvidersForTest({
|
||||
agentDir,
|
||||
explicitProviders: {
|
||||
ollama: {
|
||||
@@ -239,7 +240,7 @@ describe("Ollama provider", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const providers = await resolveImplicitProviders({
|
||||
const providers = await resolveImplicitProvidersForTest({
|
||||
agentDir,
|
||||
explicitProviders: {
|
||||
ollama: {
|
||||
@@ -264,7 +265,7 @@ describe("Ollama provider", () => {
|
||||
it("should preserve explicit apiKey when discovery path has no models and no env key", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
|
||||
const providers = await resolveImplicitProviders({
|
||||
const providers = await resolveImplicitProvidersForTest({
|
||||
agentDir,
|
||||
explicitProviders: {
|
||||
ollama: {
|
||||
|
||||
@@ -5,12 +5,12 @@ import { resolveOpenClawAgentDir } from "./agent-paths.js";
|
||||
import {
|
||||
installModelsConfigTestHooks,
|
||||
MODELS_CONFIG_IMPLICIT_ENV_VARS,
|
||||
resolveImplicitProvidersForTest,
|
||||
unsetEnv,
|
||||
withModelsTempHome,
|
||||
withTempEnv,
|
||||
} from "./models-config.e2e-harness.js";
|
||||
import { ensureOpenClawModelsJson } from "./models-config.js";
|
||||
import { resolveImplicitProviders } from "./models-config.providers.js";
|
||||
import { readGeneratedModelsJson } from "./models-config.test-utils.js";
|
||||
|
||||
installModelsConfigTestHooks();
|
||||
@@ -50,7 +50,7 @@ describe("openai-codex implicit provider", () => {
|
||||
const agentDir = resolveOpenClawAgentDir();
|
||||
await writeCodexOauthProfile(agentDir);
|
||||
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.["openai-codex"]).toMatchObject({
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
api: "openai-codex-responses",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { resolveImplicitProviders } from "./models-config.providers.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
|
||||
const qianfanApiKeyEnv = ["QIANFAN_API", "KEY"].join("_");
|
||||
|
||||
@@ -13,7 +13,7 @@ describe("Qianfan provider", () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const qianfanApiKey = "test-key"; // pragma: allowlist secret
|
||||
await withEnvAsync({ [qianfanApiKeyEnv]: qianfanApiKey }, async () => {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.qianfan).toBeDefined();
|
||||
expect(providers?.qianfan?.apiKey).toBe("QIANFAN_API_KEY");
|
||||
});
|
||||
|
||||
440
src/agents/models-config.providers.static.ts
Normal file
440
src/agents/models-config.providers.static.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
KILOCODE_BASE_URL,
|
||||
KILOCODE_DEFAULT_CONTEXT_WINDOW,
|
||||
KILOCODE_DEFAULT_COST,
|
||||
KILOCODE_DEFAULT_MAX_TOKENS,
|
||||
KILOCODE_MODEL_CATALOG,
|
||||
} from "../providers/kilocode-shared.js";
|
||||
import {
|
||||
buildBytePlusModelDefinition,
|
||||
BYTEPLUS_BASE_URL,
|
||||
BYTEPLUS_MODEL_CATALOG,
|
||||
BYTEPLUS_CODING_BASE_URL,
|
||||
BYTEPLUS_CODING_MODEL_CATALOG,
|
||||
} from "./byteplus-models.js";
|
||||
import {
|
||||
buildDoubaoModelDefinition,
|
||||
DOUBAO_BASE_URL,
|
||||
DOUBAO_MODEL_CATALOG,
|
||||
DOUBAO_CODING_BASE_URL,
|
||||
DOUBAO_CODING_MODEL_CATALOG,
|
||||
} from "./doubao-models.js";
|
||||
import {
|
||||
buildSyntheticModelDefinition,
|
||||
SYNTHETIC_BASE_URL,
|
||||
SYNTHETIC_MODEL_CATALOG,
|
||||
} from "./synthetic-models.js";
|
||||
import {
|
||||
TOGETHER_BASE_URL,
|
||||
TOGETHER_MODEL_CATALOG,
|
||||
buildTogetherModelDefinition,
|
||||
} from "./together-models.js";
|
||||
|
||||
type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
|
||||
type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
|
||||
type ProviderModelConfig = NonNullable<ProviderConfig["models"]>[number];
|
||||
|
||||
const MINIMAX_PORTAL_BASE_URL = "https://api.minimax.io/anthropic";
|
||||
const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.5";
|
||||
const MINIMAX_DEFAULT_VISION_MODEL_ID = "MiniMax-VL-01";
|
||||
const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000;
|
||||
const MINIMAX_DEFAULT_MAX_TOKENS = 8192;
|
||||
const MINIMAX_API_COST = {
|
||||
input: 0.3,
|
||||
output: 1.2,
|
||||
cacheRead: 0.03,
|
||||
cacheWrite: 0.12,
|
||||
};
|
||||
|
||||
function buildMinimaxModel(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
input: ProviderModelConfig["input"];
|
||||
}): ProviderModelConfig {
|
||||
return {
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
reasoning: params.reasoning,
|
||||
input: params.input,
|
||||
cost: MINIMAX_API_COST,
|
||||
contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: MINIMAX_DEFAULT_MAX_TOKENS,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMinimaxTextModel(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
}): ProviderModelConfig {
|
||||
return buildMinimaxModel({ ...params, input: ["text"] });
|
||||
}
|
||||
|
||||
const XIAOMI_BASE_URL = "https://api.xiaomimimo.com/anthropic";
|
||||
export const XIAOMI_DEFAULT_MODEL_ID = "mimo-v2-flash";
|
||||
const XIAOMI_DEFAULT_CONTEXT_WINDOW = 262144;
|
||||
const XIAOMI_DEFAULT_MAX_TOKENS = 8192;
|
||||
const XIAOMI_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1";
|
||||
const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2.5";
|
||||
const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000;
|
||||
const MOONSHOT_DEFAULT_MAX_TOKENS = 8192;
|
||||
const MOONSHOT_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/";
|
||||
const KIMI_CODING_DEFAULT_MODEL_ID = "k2p5";
|
||||
const KIMI_CODING_DEFAULT_CONTEXT_WINDOW = 262144;
|
||||
const KIMI_CODING_DEFAULT_MAX_TOKENS = 32768;
|
||||
const KIMI_CODING_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
const QWEN_PORTAL_BASE_URL = "https://portal.qwen.ai/v1";
|
||||
const QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW = 128000;
|
||||
const QWEN_PORTAL_DEFAULT_MAX_TOKENS = 8192;
|
||||
const QWEN_PORTAL_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
||||
const OPENROUTER_DEFAULT_MODEL_ID = "auto";
|
||||
const OPENROUTER_DEFAULT_CONTEXT_WINDOW = 200000;
|
||||
const OPENROUTER_DEFAULT_MAX_TOKENS = 8192;
|
||||
const OPENROUTER_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
export const QIANFAN_BASE_URL = "https://qianfan.baidubce.com/v2";
|
||||
export const QIANFAN_DEFAULT_MODEL_ID = "deepseek-v3.2";
|
||||
const QIANFAN_DEFAULT_CONTEXT_WINDOW = 98304;
|
||||
const QIANFAN_DEFAULT_MAX_TOKENS = 32768;
|
||||
const QIANFAN_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
|
||||
const NVIDIA_DEFAULT_MODEL_ID = "nvidia/llama-3.1-nemotron-70b-instruct";
|
||||
const NVIDIA_DEFAULT_CONTEXT_WINDOW = 131072;
|
||||
const NVIDIA_DEFAULT_MAX_TOKENS = 4096;
|
||||
const NVIDIA_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
||||
|
||||
export function buildMinimaxProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: MINIMAX_PORTAL_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
authHeader: true,
|
||||
models: [
|
||||
buildMinimaxModel({
|
||||
id: MINIMAX_DEFAULT_VISION_MODEL_ID,
|
||||
name: "MiniMax VL 01",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
}),
|
||||
buildMinimaxTextModel({
|
||||
id: "MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
reasoning: true,
|
||||
}),
|
||||
buildMinimaxTextModel({
|
||||
id: "MiniMax-M2.5-highspeed",
|
||||
name: "MiniMax M2.5 Highspeed",
|
||||
reasoning: true,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMinimaxPortalProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: MINIMAX_PORTAL_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
authHeader: true,
|
||||
models: [
|
||||
buildMinimaxModel({
|
||||
id: MINIMAX_DEFAULT_VISION_MODEL_ID,
|
||||
name: "MiniMax VL 01",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
}),
|
||||
buildMinimaxTextModel({
|
||||
id: MINIMAX_DEFAULT_MODEL_ID,
|
||||
name: "MiniMax M2.5",
|
||||
reasoning: true,
|
||||
}),
|
||||
buildMinimaxTextModel({
|
||||
id: "MiniMax-M2.5-highspeed",
|
||||
name: "MiniMax M2.5 Highspeed",
|
||||
reasoning: true,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMoonshotProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: MOONSHOT_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: MOONSHOT_DEFAULT_MODEL_ID,
|
||||
name: "Kimi K2.5",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: MOONSHOT_DEFAULT_COST,
|
||||
contextWindow: MOONSHOT_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: MOONSHOT_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildKimiCodingProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: KIMI_CODING_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: KIMI_CODING_DEFAULT_MODEL_ID,
|
||||
name: "Kimi for Coding",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: KIMI_CODING_DEFAULT_COST,
|
||||
contextWindow: KIMI_CODING_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: KIMI_CODING_DEFAULT_MAX_TOKENS,
|
||||
compat: {
|
||||
requiresOpenAiAnthropicToolPayload: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildQwenPortalProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: QWEN_PORTAL_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "coder-model",
|
||||
name: "Qwen Coder",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: QWEN_PORTAL_DEFAULT_COST,
|
||||
contextWindow: QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: QWEN_PORTAL_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
{
|
||||
id: "vision-model",
|
||||
name: "Qwen Vision",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: QWEN_PORTAL_DEFAULT_COST,
|
||||
contextWindow: QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: QWEN_PORTAL_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSyntheticProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: SYNTHETIC_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
models: SYNTHETIC_MODEL_CATALOG.map(buildSyntheticModelDefinition),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDoubaoProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: DOUBAO_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: DOUBAO_MODEL_CATALOG.map(buildDoubaoModelDefinition),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDoubaoCodingProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: DOUBAO_CODING_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: DOUBAO_CODING_MODEL_CATALOG.map(buildDoubaoModelDefinition),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBytePlusProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: BYTEPLUS_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: BYTEPLUS_MODEL_CATALOG.map(buildBytePlusModelDefinition),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBytePlusCodingProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: BYTEPLUS_CODING_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: BYTEPLUS_CODING_MODEL_CATALOG.map(buildBytePlusModelDefinition),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildXiaomiProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: XIAOMI_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: XIAOMI_DEFAULT_MODEL_ID,
|
||||
name: "Xiaomi MiMo V2 Flash",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: XIAOMI_DEFAULT_COST,
|
||||
contextWindow: XIAOMI_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: XIAOMI_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTogetherProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: TOGETHER_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: TOGETHER_MODEL_CATALOG.map(buildTogetherModelDefinition),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOpenrouterProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: OPENROUTER_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: OPENROUTER_DEFAULT_MODEL_ID,
|
||||
name: "OpenRouter Auto",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: OPENROUTER_DEFAULT_COST,
|
||||
contextWindow: OPENROUTER_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: OPENROUTER_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOpenAICodexProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: OPENAI_CODEX_BASE_URL,
|
||||
api: "openai-codex-responses",
|
||||
models: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildQianfanProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: QIANFAN_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: QIANFAN_DEFAULT_MODEL_ID,
|
||||
name: "DEEPSEEK V3.2",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: QIANFAN_DEFAULT_COST,
|
||||
contextWindow: QIANFAN_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: QIANFAN_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
{
|
||||
id: "ernie-5.0-thinking-preview",
|
||||
name: "ERNIE-5.0-Thinking-Preview",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: QIANFAN_DEFAULT_COST,
|
||||
contextWindow: 119000,
|
||||
maxTokens: 64000,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNvidiaProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: NVIDIA_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: NVIDIA_DEFAULT_MODEL_ID,
|
||||
name: "NVIDIA Llama 3.1 Nemotron 70B Instruct",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: NVIDIA_DEFAULT_COST,
|
||||
contextWindow: NVIDIA_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: NVIDIA_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
{
|
||||
id: "meta/llama-3.3-70b-instruct",
|
||||
name: "Meta Llama 3.3 70B Instruct",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: NVIDIA_DEFAULT_COST,
|
||||
contextWindow: 131072,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
{
|
||||
id: "nvidia/mistral-nemo-minitron-8b-8k-instruct",
|
||||
name: "NVIDIA Mistral NeMo Minitron 8B Instruct",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: NVIDIA_DEFAULT_COST,
|
||||
contextWindow: 8192,
|
||||
maxTokens: 2048,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildKilocodeProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: KILOCODE_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: KILOCODE_MODEL_CATALOG.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
reasoning: model.reasoning,
|
||||
input: model.input,
|
||||
cost: KILOCODE_DEFAULT_COST,
|
||||
contextWindow: model.contextWindow ?? KILOCODE_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: model.maxTokens ?? KILOCODE_DEFAULT_MAX_TOKENS,
|
||||
})),
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user