Compare commits

..

61 Commits

Author SHA1 Message Date
Peter Steinberger
55300ea850 fix: preserve loopback ws cdp tab ops (#31085) (thanks @shrey150) 2026-03-08 18:47:48 +00:00
Shrey Pandya
8e5f702adf style(browser): fix oxfmt formatting in config.ts
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
Shrey Pandya
56d2662f9d chore: remove vendor-specific references from code comments 2026-03-08 18:41:49 +00:00
Shrey Pandya
e2ecd0a321 fix(browser): preserve wss:// cdpUrl in legacy default profile resolution 2026-03-08 18:41:49 +00:00
shrey150
7fce53976e fix(browser): update existing tests for ws/wss protocol support
Two pre-existing tests still expected ws:// URLs to be rejected by
parseHttpUrl, which now accepts them. Switch the invalid-protocol
fixture to ftp:// and tighten the assertion to match the full
"must be http(s) or ws(s)" error message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
shrey150
1cc021251e test+docs: comprehensive coverage and generic framing
- Add 12 new tests covering: isWebSocketUrl detection, parseHttpUrl WSS
  acceptance/rejection, direct WS target creation with query params,
  SSRF enforcement on WS URLs, WS reachability probing bypasses HTTP
- Reframe docs section as generic "Direct WebSocket CDP providers" with
  Browserbase as one example — any WSS-based provider works
- Update security tips to mention WSS alongside HTTPS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
shrey150
a8ad7e42af feat(browser): support direct WebSocket CDP URLs for Browserbase
Browserbase uses direct WebSocket connections (wss://) rather than the
standard HTTP-based /json/version CDP discovery flow used by Browserless.
This change teaches the browser tool to accept ws:// and wss:// URLs as
cdpUrl values: when a WebSocket URL is detected, OpenClaw connects
directly instead of attempting HTTP discovery.

Changes:
- config.ts: accept ws:// and wss:// in cdpUrl validation
- cdp.helpers.ts: add isWebSocketUrl() helper
- cdp.ts: skip /json/version when cdpUrl is already a WebSocket URL
- chrome.ts: probe WSS endpoints via WebSocket handshake instead of HTTP
- cdp.test.ts: add test for direct WebSocket target creation
- docs/tools/browser.md: update Browserbase section with correct URL
  format and notes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
Shrey Pandya
42320281c6 docs: simplify Browserbase section, drop pricing details
Restore platform-level feature description (CAPTCHA solving, stealth
mode, proxies) without plan-specific pricing gating. Keep free tier
note brief.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
Shrey Pandya
f60168b735 docs: fact-check Browserbase section against official docs
- Fix CAPTCHA/stealth/proxy claims: these are Developer plan+ only,
  not available on free tier
- Fix free tier limits: 1 browser hour, 15-min session duration
  (not "60 minutes of monthly usage")
- Add link to pricing page for paid plan details
- Simplify structure to match Browserless section format
- Remove sub-headings to match Browserless section style

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
Shrey Pandya
0a5701f468 docs: restore direct wss://connect.browserbase.com URL
Browserbase exposes a direct WebSocket connect endpoint that
auto-creates a session, similar to how Browserless works. Simplified
the section to use this static URL pattern instead of requiring
manual session creation via the API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
Shrey Pandya
07f65838ed docs: fix Browserbase section to match official docs
Browserbase requires creating a session via their API to get a CDP
connect URL, unlike Browserless which uses a static endpoint. Updated
to show the correct curl-based session creation flow, removed
unverified static WebSocket URL, and added the 5-minute connect
timeout note from official docs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
Shrey Pandya
4d326271f0 docs: fix duplicate heading lint error
Rename "Configuration" sub-heading to "Profile setup" to avoid
MD024/no-duplicate-heading conflict with the existing top-level
"Configuration" heading.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
Shrey Pandya
eb4ff4464e docs: add Browserbase as hosted remote CDP option
Add Browserbase documentation section alongside the existing Browserless
section in the browser docs. Includes signup instructions, CDP connection
configuration, and environment variable setup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:41:49 +00:00
Shrey Pandya
cbcf9d0811 Revert "docs: add Browserbase as hosted remote CDP option"
This reverts commit c469657c97848c7a3e1e5135bf4ce735d07d6614.
2026-03-08 18:41:49 +00:00
Shrey Pandya
83a854bfa0 docs: add Browserbase as hosted remote CDP option
Add Browserbase documentation section alongside the existing Browserless
section in the browser docs. Includes signup instructions, CDP connection
configuration, and environment variable setup for both English and Chinese
(zh-CN) translations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-08 18:41:48 +00:00
Peter Steinberger
3ada30e670 fix: restore gate after rebase 2026-03-08 18:40:15 +00:00
Peter Steinberger
c5095153b0 refactor: extract qmd process runner 2026-03-08 18:40:15 +00:00
Peter Steinberger
68775745d2 fix: restore acp session meta narrowing 2026-03-08 18:40:15 +00:00
Peter Steinberger
f399a818ef refactor: extract ios watch reply coordinator 2026-03-08 18:40:15 +00:00
Peter Steinberger
6bd5735519 refactor: split doctor config analysis helpers 2026-03-08 18:40:15 +00:00
Peter Steinberger
11be305609 refactor: neutralize context engine runtime bridge 2026-03-08 18:40:15 +00:00
Peter Steinberger
f6cb77134c refactor: centralize acp session resolution guards 2026-03-08 18:40:14 +00:00
Peter Steinberger
25d0aa7296 refactor: simplify plugin sdk compatibility aliases 2026-03-08 18:40:14 +00:00
Peter Steinberger
dd7470730d test: isolate git commit resolution fallbacks 2026-03-08 18:40:14 +00:00
Peter Steinberger
c70151e873 test: isolate legacy plugin-sdk root import check 2026-03-08 18:40:14 +00:00
Peter Steinberger
a007bed375 test: isolate plugin loader from mocked module cache 2026-03-08 18:40:14 +00:00
Peter Steinberger
fa580e33c1 refactor: split android talk voice resolution 2026-03-08 18:40:14 +00:00
Peter Steinberger
371c53b282 test: expand talk config contract fixtures 2026-03-08 18:40:14 +00:00
Peter Steinberger
cee2f3e8b4 refactor: dedupe android talk config parsing 2026-03-08 18:40:14 +00:00
Peter Steinberger
2ed644f5d3 fix: require talk resolved payload 2026-03-08 18:40:14 +00:00
Mariano
404b1527e6 fix(acp): persist spawned child session history (#40137)
Merged via squash.

Prepared head SHA: 62de5d5669
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-08 19:37:00 +01:00
Peter Steinberger
72ebaf97c3 test: add implicit provider matrix coverage 2026-03-08 18:26:36 +00:00
Peter Steinberger
8ab762c005 test: standardize hermetic provider env snapshots 2026-03-08 18:26:36 +00:00
Peter Steinberger
d307a7ca1a refactor: extract bundled extension manifest parser 2026-03-08 18:26:36 +00:00
Peter Steinberger
52bc809143 refactor: extract provider stream wrappers 2026-03-08 18:26:36 +00:00
Peter Steinberger
6094035054 refactor: extract static provider builders 2026-03-08 18:26:36 +00:00
Peter Steinberger
f493b03202 refactor: validate bundled extension release metadata 2026-03-08 18:26:36 +00:00
Peter Steinberger
e53d840fed refactor: extract openai stream wrappers 2026-03-08 18:26:36 +00:00
Peter Steinberger
f66bd105a4 refactor: decompose implicit provider resolution 2026-03-08 18:26:36 +00:00
Peter Steinberger
ef2541ceb3 refactor: centralize transcript provider quirks 2026-03-08 18:26:35 +00:00
Peter Steinberger
8a18e2598f refactor: split models registry loading from persistence 2026-03-08 18:26:35 +00:00
Peter Steinberger
749eb4efea refactor: thread config runtime env through models config 2026-03-08 18:26:35 +00:00
Peter Steinberger
64d4d9aabb refactor: move bundled extension gap allowlists into manifests 2026-03-08 18:26:35 +00:00
Peter Steinberger
e5c06dd64a refactor: use model compat for anthropic tool payload normalization 2026-03-08 18:26:35 +00:00
Vincent Koc
efcca3d2ea Tests: format daemon lifecycle CLI coverage 2026-03-08 11:22:41 -07:00
Vincent Koc
0b452a5665 CLI: set local gateway mode in setup 2026-03-08 11:17:29 -07:00
Vincent Koc
4c71176c9f Chore: refresh detect-secrets baseline for Feishu docs 2026-03-08 11:16:03 -07:00
Vincent Koc
c5bba6628e Chore: refresh detect-secrets baseline after final scan 2026-03-08 11:16:03 -07:00
Vincent Koc
3b68d3fded Chore: refresh detect-secrets baseline after docs line changes 2026-03-08 11:16:03 -07:00
Vincent Koc
7856f5730c Web search: allowlist Perplexity auth source type name 2026-03-08 11:16:03 -07:00
Vincent Koc
aebfce7a36 Chore: refresh detect-secrets baseline 2026-03-08 11:16:03 -07:00
Vincent Koc
e19b3679d1 Chore: widen xxxxx detect-secrets allowlist 2026-03-08 11:16:03 -07:00
Vincent Koc
d23d36a2f9 Tests: lower entropy git commit fixtures 2026-03-08 11:16:03 -07:00
Vincent Koc
2ae58542a0 Fixtures: normalize talk config API key placeholder 2026-03-08 11:16:03 -07:00
Vincent Koc
55465d86d9 Docs: use placeholder OpenRouter key in web tool docs 2026-03-08 11:16:03 -07:00
Vincent Koc
615466bdf4 Docs: use placeholder OpenRouter key in Perplexity guide 2026-03-08 11:16:03 -07:00
Vincent Koc
6f4de3cc23 Web search: rename Perplexity auth source helper 2026-03-08 11:16:03 -07:00
Vincent Koc
f19761cefa Tests: reduce web search secret-scan noise 2026-03-08 11:16:03 -07:00
Vincent Koc
5387faa718 CI: satisfy provider merge fixture typing 2026-03-08 11:15:48 -07:00
Tak Hoffman
bdf9739e59 Add too-many-prs override label handling 2026-03-08 13:13:53 -05:00
Rémi
2970d72554 docs: update Brave Search API docs for Feb 2026 plan restructuring (#40111)
Merged via squash.

Prepared head SHA: c651f07855
Co-authored-by: remusao <1299873+remusao@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-08 14:06:21 -04:00
115 changed files with 4643 additions and 2896 deletions

View File

@@ -44,5 +44,4 @@ runs:
exit 0
fi
echo "::error::Base commit still unavailable after fetch attempts: $BASE_SHA"
exit 1
echo "Base commit still unavailable after fetch attempts: $BASE_SHA"

View File

@@ -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;

View File

@@ -267,13 +267,6 @@ jobs:
with:
submodules: false
- name: Ensure secrets base commit
if: github.event_name == 'pull_request'
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event.pull_request.base.ref }}
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
@@ -303,44 +296,37 @@ jobs:
python -m pip install --upgrade pip
python -m pip install pre-commit
- name: Detect secrets and private keys
- name: Detect secrets
run: |
set -euo pipefail
detect_secrets_exit=0
detect_private_key_exit=0
if [ "${{ github.event_name }}" = "push" ]; then
echo "Running full detect-secrets scan on push."
pre-commit run --all-files detect-secrets || detect_secrets_exit=$?
pre-commit run --all-files detect-private-key || detect_private_key_exit=$?
test "$detect_secrets_exit" -eq 0 -a "$detect_private_key_exit" -eq 0
pre-commit run --all-files detect-secrets
exit 0
fi
BASE="${{ github.event.pull_request.base.sha }}"
if ! git rev-parse --verify "$BASE^{commit}" >/dev/null 2>&1; then
echo "::error::PR base commit is unavailable after fetch attempts: $BASE"
echo "Refusing to fall back to a full-repo secrets scan for pull requests."
exit 1
fi
changed_files=()
while IFS= read -r path; do
[ -n "$path" ] || continue
[ -f "$path" ] || continue
changed_files+=("$path")
done < <(git diff --name-only --diff-filter=ACMR "$BASE" HEAD)
if git rev-parse --verify "$BASE^{commit}" >/dev/null 2>&1; then
while IFS= read -r path; do
[ -n "$path" ] || continue
[ -f "$path" ] || continue
changed_files+=("$path")
done < <(git diff --name-only --diff-filter=ACMR "$BASE" HEAD)
fi
if [ "${#changed_files[@]}" -gt 0 ]; then
echo "Running secret scans on ${#changed_files[@]} changed file(s)."
pre-commit run detect-secrets --files "${changed_files[@]}" || detect_secrets_exit=$?
pre-commit run detect-private-key --files "${changed_files[@]}" || detect_private_key_exit=$?
test "$detect_secrets_exit" -eq 0 -a "$detect_private_key_exit" -eq 0
echo "Running detect-secrets on ${#changed_files[@]} changed file(s)."
pre-commit run detect-secrets --files "${changed_files[@]}"
else
echo "No added/copied/modified/renamed files to scan in this pull request."
echo "Falling back to full detect-secrets scan."
pre-commit run --all-files detect-secrets
fi
- name: Detect committed private keys
run: pre-commit run --all-files detect-private-key
- name: Audit changed GitHub workflows with zizmor
run: |
set -euo pipefail

View File

@@ -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({

View File

@@ -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

View File

@@ -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": [
@@ -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": 129
"line_number": 135
},
{
"type": "Secret Keyword",
"filename": "docs/tools/web.md",
"hashed_secret": "491d458f895b9213facb2ee9375b1b044eaea3ac",
"is_verified": false,
"line_number": 202
"line_number": 228
},
{
"type": "Secret Keyword",
"filename": "docs/tools/web.md",
"hashed_secret": "674397e2c0c2faaa85961c708d2a96a7cc7af217",
"is_verified": false,
"line_number": 303
"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": [
@@ -11680,7 +11680,7 @@
"filename": "src/agents/tools/web-search.ts",
"hashed_secret": "dfba7aade0868074c2861c98e2a9a92f3178a51b",
"is_verified": false,
"line_number": 266
"line_number": 292
}
],
"src/agents/tools/web-tools.enabled-defaults.e2e.test.ts": [
@@ -13034,5 +13034,5 @@
}
]
},
"generated_at": "2026-03-08T14:28:30Z"
"generated_at": "2026-03-08T18:30:57Z"
}

View File

@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
- 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
@@ -25,6 +26,8 @@ Docs: https://docs.openclaw.ai
- 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

View File

@@ -4,9 +4,16 @@ 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,
@@ -22,6 +29,8 @@ internal data class TalkModeGatewayConfigState(
)
internal object TalkModeGatewayConfigParser {
private const val defaultTalkProvider = "elevenlabs"
fun parse(
config: JsonObject?,
defaultProvider: String,
@@ -32,7 +41,7 @@ internal object TalkModeGatewayConfigParser {
envKey: String?,
): TalkModeGatewayConfigState {
val talk = config?.get("talk").asObjectOrNull()
val selection = TalkModeManager.selectTalkProviderConfig(talk)
val selection = selectTalkProviderConfig(talk)
val activeProvider = selection?.provider ?: defaultProvider
val activeConfig = selection?.config
val sessionCfg = config?.get("session").asObjectOrNull()
@@ -48,7 +57,7 @@ internal object TalkModeGatewayConfigParser {
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 = TalkModeManager.resolvedSilenceTimeoutMs(talk)
val silenceTimeoutMs = resolvedSilenceTimeoutMs(talk)
return TalkModeGatewayConfigState(
activeProvider = activeProvider,
@@ -91,6 +100,48 @@ internal object TalkModeGatewayConfigParser {
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 =

View File

@@ -64,54 +64,6 @@ class TalkModeManager(
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() }
}
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,
)
}
internal 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,
)
}
internal 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 val mainHandler = Handler(Looper.getMainLooper())
@@ -861,7 +813,7 @@ class TalkModeManager(
_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")
}
@@ -884,12 +836,35 @@ class TalkModeManager(
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
@@ -1751,82 +1726,6 @@ class TalkModeManager(
}
}
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?) {

View File

@@ -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

View File

@@ -14,6 +14,7 @@ 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(
@@ -29,6 +30,15 @@ private data class TalkConfigContractFixture(
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,
)
}
@@ -38,7 +48,7 @@ class TalkModeConfigContractTest {
@Test
fun selectionFixtures() {
for (fixture in loadFixtures().selectionCases) {
val selection = TalkModeManager.selectTalkProviderConfig(fixture.talk)
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(fixture.talk)
val expected = fixture.expectedSelection
if (expected == null) {
assertNull(fixture.id, selection)
@@ -52,10 +62,24 @@ class TalkModeConfigContractTest {
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())

View File

@@ -36,7 +36,7 @@ class TalkModeConfigParsingTest {
)
.jsonObject
val selection = TalkModeManager.selectTalkProviderConfig(talk)
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
assertNotNull(selection)
assertEquals("elevenlabs", selection?.provider)
assertTrue(selection?.normalizedPayload == true)
@@ -61,7 +61,7 @@ class TalkModeConfigParsingTest {
)
.jsonObject
val selection = TalkModeManager.selectTalkProviderConfig(talk)
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
assertEquals(null, selection)
}
@@ -82,7 +82,7 @@ class TalkModeConfigParsingTest {
)
.jsonObject
val selection = TalkModeManager.selectTalkProviderConfig(talk)
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
assertEquals(null, selection)
}
@@ -105,7 +105,7 @@ class TalkModeConfigParsingTest {
)
.jsonObject
val selection = TalkModeManager.selectTalkProviderConfig(talk)
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
assertEquals(null, selection)
}
@@ -118,7 +118,7 @@ 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)
@@ -130,25 +130,34 @@ class TalkModeConfigParsingTest {
fun readsConfiguredSilenceTimeoutMs() {
val talk = buildJsonObject { put("silenceTimeoutMs", 1500) }
assertEquals(1500L, TalkModeManager.resolvedSilenceTimeoutMs(talk))
assertEquals(1500L, TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk))
}
@Test
fun defaultsSilenceTimeoutMsWhenMissing() {
assertEquals(TalkDefaults.defaultSilenceTimeoutMs, TalkModeManager.resolvedSilenceTimeoutMs(null))
assertEquals(
TalkDefaults.defaultSilenceTimeoutMs,
TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(null),
)
}
@Test
fun defaultsSilenceTimeoutMsWhenInvalid() {
val talk = buildJsonObject { put("silenceTimeoutMs", 0) }
assertEquals(TalkDefaults.defaultSilenceTimeoutMs, TalkModeManager.resolvedSilenceTimeoutMs(talk))
assertEquals(
TalkDefaults.defaultSilenceTimeoutMs,
TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk),
)
}
@Test
fun defaultsSilenceTimeoutMsWhenString() {
val talk = buildJsonObject { put("silenceTimeoutMs", "1500") }
assertEquals(TalkDefaults.defaultSilenceTimeoutMs, TalkModeManager.resolvedSilenceTimeoutMs(talk))
assertEquals(
TalkDefaults.defaultSilenceTimeoutMs,
TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk),
)
}
}

View File

@@ -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)
}
}

View File

@@ -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) {

View 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
}
}

View File

@@ -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

View File

@@ -4,6 +4,7 @@ import Testing
private struct TalkConfigContractFixture: Decodable {
let selectionCases: [SelectionCase]
let timeoutCases: [TimeoutCase]
struct SelectionCase: Decodable {
let id: String
@@ -17,6 +18,14 @@ private struct TalkConfigContractFixture: 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]
}
}
@@ -51,10 +60,21 @@ struct TalkConfigContractTests {
#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)")
}
}
}

View File

@@ -13,7 +13,7 @@ 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.
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.

View File

@@ -57,7 +57,7 @@ Optional legacy controls:
search: {
provider: "perplexity",
perplexity: {
apiKey: "sk-or-v1-...",
apiKey: "<openrouter-api-key>",
baseUrl: "https://openrouter.ai/api/v1",
model: "perplexity/sonar-pro",
},

View File

@@ -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).

View File

@@ -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)

View File

@@ -56,10 +56,14 @@ Use `openclaw configure --section web` to set up your API key and choose a provi
### 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.
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.
Brave provides paid plans; check the Brave API portal for the current limits and pricing.
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
@@ -146,7 +150,7 @@ In this mode, `country` and `language` / `search_lang` still work, but `ui_lang`
enabled: true,
provider: "perplexity",
perplexity: {
apiKey: "sk-or-v1-...", // optional if OPENROUTER_API_KEY is set
apiKey: "<openrouter-api-key>", // optional if OPENROUTER_API_KEY is set
baseUrl: "https://openrouter.ai/api/v1",
model: "perplexity/sonar-pro",
},

View File

@@ -37,6 +37,11 @@
"npmSpec": "@openclaw/googlechat",
"localPath": "extensions/googlechat",
"defaultChoice": "npm"
},
"releaseChecks": {
"rootDependencyMirrorAllowlist": [
"google-auth-library"
]
}
}
}

View File

@@ -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"
]
}
}
}

View File

@@ -27,6 +27,11 @@
"npmSpec": "@openclaw/msteams",
"localPath": "extensions/msteams",
"defaultChoice": "npm"
},
"releaseChecks": {
"rootDependencyMirrorAllowlist": [
"@microsoft/agents-hosting"
]
}
}
}

View File

@@ -25,6 +25,11 @@
"npmSpec": "@openclaw/nostr",
"localPath": "extensions/nostr",
"defaultChoice": "npm"
},
"releaseChecks": {
"rootDependencyMirrorAllowlist": [
"nostr-tools"
]
}
}
}

View File

@@ -27,6 +27,13 @@
"npmSpec": "@openclaw/tlon",
"localPath": "extensions/tlon",
"defaultChoice": "npm"
},
"releaseChecks": {
"rootDependencyMirrorAllowlist": [
"@tloncorp/api",
"@tloncorp/tlon-skill",
"@urbit/aura"
]
}
}
}

View File

@@ -29,6 +29,11 @@
"npmSpec": "@openclaw/zalouser",
"localPath": "extensions/zalouser",
"defaultChoice": "npm"
},
"releaseChecks": {
"rootDependencyMirrorAllowlist": [
"zca-js"
]
}
}
}

View 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;
}

View File

@@ -4,21 +4,18 @@ 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[] };
type PackageJson = {
name?: string;
version?: string;
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
openclaw?: {
install?: {
npmSpec?: string;
};
};
};
const requiredPathGroups = [
["dist/index.js", "dist/index.mjs"],
@@ -128,18 +125,9 @@ function normalizePluginSyncVersion(version: string): string {
return normalized.replace(/[-+].*$/, "");
}
const ALLOWLISTED_BUNDLED_EXTENSION_ROOT_DEP_GAPS: Record<string, string[]> = {
googlechat: ["google-auth-library"],
matrix: ["@matrix-org/matrix-sdk-crypto-nodejs", "@vector-im/matrix-bot-sdk", "music-metadata"],
msteams: ["@microsoft/agents-hosting"],
nostr: ["nostr-tools"],
tlon: ["@tloncorp/api", "@tloncorp/tlon-skill", "@urbit/aura"],
zalouser: ["zca-js"],
};
export function collectBundledExtensionRootDependencyGapErrors(params: {
rootPackage: PackageJson;
extensions: Array<{ id: string; packageJson: PackageJson }>;
extensions: BundledExtension[];
}): string[] {
const rootDeps = {
...params.rootPackage.dependencies,
@@ -147,17 +135,15 @@ export function collectBundledExtensionRootDependencyGapErrors(params: {
};
const errors: string[] = [];
for (const extension of params.extensions) {
if (!extension.packageJson.openclaw?.install?.npmSpec) {
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 = [
...(ALLOWLISTED_BUNDLED_EXTENSION_ROOT_DEP_GAPS[extension.id] ?? []),
].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));
@@ -178,7 +164,7 @@ export function collectBundledExtensionRootDependencyGapErrors(params: {
return errors;
}
function collectBundledExtensions(): Array<{ id: string; packageJson: PackageJson }> {
function collectBundledExtensions(): BundledExtension[] {
const extensionsDir = resolve("extensions");
const entries = readdirSync(extensionsDir, { withFileTypes: true }).filter((entry) =>
entry.isDirectory(),
@@ -201,9 +187,18 @@ function collectBundledExtensions(): Array<{ id: string; packageJson: PackageJso
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: collectBundledExtensions(),
extensions,
});
if (errors.length > 0) {
console.error("release-check: bundled extension root dependency mirror validation failed:");

View File

@@ -123,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
? [

View File

@@ -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 () =>

View File

@@ -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();
}

View File

@@ -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(
{

View File

@@ -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({

View File

@@ -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");
});
});

View File

@@ -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: {

View File

@@ -8,6 +8,8 @@ import {
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(
{
@@ -75,7 +77,7 @@ describe("models-config merge helpers", () => {
existingProviders: {
custom: {
baseUrl: "https://agent.example/v1",
apiKey: "AGENT_KEY",
apiKey: preservedApiKey,
models: [{ id: "model", api: "openai-completions" }],
} as ExistingProviderConfig,
},
@@ -85,7 +87,7 @@ describe("models-config merge helpers", () => {
expect(merged.custom).toEqual(
expect.objectContaining({
apiKey: "AGENT_KEY",
apiKey: preservedApiKey,
baseUrl: "https://config.example/v1",
}),
);

View File

@@ -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);
});

View File

@@ -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);
});
});

View File

@@ -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, env: {} });
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, env: {} });
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, env: {} });
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");

View File

@@ -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();

View File

@@ -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();

View 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);
},
);
});

View File

@@ -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",

View File

@@ -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");

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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",

View File

@@ -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");
});

View 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

View File

@@ -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";
import { VERCEL_AI_GATEWAY_BASE_URL } from "./vercel-ai-gateway.js";
describe("vercel-ai-gateway provider resolution", () => {
@@ -14,7 +14,7 @@ describe("vercel-ai-gateway provider resolution", () => {
process.env.AI_GATEWAY_API_KEY = "vercel-gateway-test-key"; // pragma: allowlist secret
try {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir });
const provider = providers?.["vercel-ai-gateway"];
expect(provider?.apiKey).toBe("AI_GATEWAY_API_KEY");
expect(provider?.api).toBe("anthropic-messages");
@@ -52,7 +52,7 @@ describe("vercel-ai-gateway provider resolution", () => {
);
try {
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.["vercel-ai-gateway"]?.apiKey).toBe("AI_GATEWAY_API_KEY");
} finally {
envSnapshot.restore();
@@ -81,7 +81,7 @@ describe("vercel-ai-gateway provider resolution", () => {
"utf8",
);
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.["vercel-ai-gateway"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
});
});

View File

@@ -4,7 +4,7 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { captureEnv } from "../test-utils/env.js";
import { upsertAuthProfile } from "./auth-profiles.js";
import { resolveImplicitProviders } from "./models-config.providers.js";
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
describe("Volcengine and BytePlus providers", () => {
it("includes volcengine and volcengine-plan when VOLCANO_ENGINE_API_KEY is configured", async () => {
@@ -13,7 +13,7 @@ describe("Volcengine and BytePlus providers", () => {
process.env.VOLCANO_ENGINE_API_KEY = "test-key"; // pragma: allowlist secret
try {
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.volcengine).toBeDefined();
expect(providers?.["volcengine-plan"]).toBeDefined();
expect(providers?.volcengine?.apiKey).toBe("VOLCANO_ENGINE_API_KEY");
@@ -29,7 +29,7 @@ describe("Volcengine and BytePlus providers", () => {
process.env.BYTEPLUS_API_KEY = "test-key"; // pragma: allowlist secret
try {
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.byteplus).toBeDefined();
expect(providers?.["byteplus-plan"]).toBeDefined();
expect(providers?.byteplus?.apiKey).toBe("BYTEPLUS_API_KEY");
@@ -65,7 +65,7 @@ describe("Volcengine and BytePlus providers", () => {
});
try {
const providers = await resolveImplicitProviders({ agentDir });
const providers = await resolveImplicitProvidersForTest({ agentDir });
expect(providers?.volcengine?.apiKey).toBe("VOLCANO_ENGINE_API_KEY");
expect(providers?.["volcengine-plan"]?.apiKey).toBe("VOLCANO_ENGINE_API_KEY");
expect(providers?.byteplus?.apiKey).toBe("BYTEPLUS_API_KEY");

View File

@@ -6,7 +6,7 @@ import {
type OpenClawConfig,
loadConfig,
} from "../config/config.js";
import { applyConfigEnvVars } from "../config/env-vars.js";
import { createConfigRuntimeEnv } from "../config/env-vars.js";
import { isRecord } from "../utils.js";
import { resolveOpenClawAgentDir } from "./agent-paths.js";
import {
@@ -46,12 +46,14 @@ async function readExistingModelsFile(pathname: string): Promise<{
async function resolveProvidersForModelsJson(params: {
cfg: OpenClawConfig;
agentDir: string;
env: NodeJS.ProcessEnv;
}): Promise<Record<string, ProviderConfig>> {
const { cfg, agentDir } = params;
const { cfg, agentDir, env } = params;
const explicitProviders = cfg.models?.providers ?? {};
const implicitProviders = await resolveImplicitProviders({
agentDir,
config: cfg,
env,
explicitProviders,
});
const providers: Record<string, ProviderConfig> = mergeProviders({
@@ -143,12 +145,10 @@ export async function ensureOpenClawModelsJson(
return await withModelsJsonWriteLock(targetPath, async () => {
// Ensure config env vars (e.g. AWS_PROFILE, AWS_ACCESS_KEY_ID) are
// available in process.env before implicit provider discovery. Some
// callers (agent runner, tools) pass config objects that haven't gone
// through the full loadConfig() pipeline which applies these.
applyConfigEnvVars(cfg);
// are available to provider discovery without mutating process.env.
const env = createConfigRuntimeEnv(cfg);
const providers = await resolveProvidersForModelsJson({ cfg, agentDir });
const providers = await resolveProvidersForModelsJson({ cfg, agentDir, env });
if (Object.keys(providers).length === 0) {
return { agentDir, wrote: false };
@@ -170,6 +170,7 @@ export async function ensureOpenClawModelsJson(
normalizeProviders({
providers,
agentDir,
env,
secretDefaults: cfg.secrets?.defaults,
secretRefManagedProviders,
}) ?? providers;

View File

@@ -880,6 +880,57 @@ describe("applyExtraParamsToAgent", () => {
]);
});
it("uses explicit compat metadata for anthropic tool payload normalization", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = {
tools: [
{
name: "read",
description: "Read file",
input_schema: { type: "object", properties: {} },
},
],
};
options?.onPayload?.(payload);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
const agent = { streamFn: baseStreamFn };
applyExtraParamsToAgent(
agent,
undefined,
"custom-anthropic-proxy",
"proxy-model",
undefined,
"low",
);
const model = {
api: "anthropic-messages",
provider: "custom-anthropic-proxy",
id: "proxy-model",
compat: {
requiresOpenAiAnthropicToolPayload: true,
},
} as unknown as Model<"anthropic-messages">;
const context: Context = { messages: [] };
void agent.streamFn?.(model, context, {});
expect(payloads).toHaveLength(1);
expect(payloads[0]?.tools).toEqual([
{
type: "function",
function: {
name: "read",
description: "Read file",
parameters: { type: "object", properties: {} },
},
},
]);
});
it("removes invalid negative Google thinkingBudget and maps Gemini 3.1 to thinkingLevel", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {

View File

@@ -0,0 +1,319 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { streamSimple } from "@mariozechner/pi-ai";
import {
requiresOpenAiCompatibleAnthropicToolPayload,
usesOpenAiFunctionAnthropicToolSchema,
usesOpenAiStringModeAnthropicToolChoice,
} from "../provider-capabilities.js";
import { log } from "./logger.js";
const ANTHROPIC_CONTEXT_1M_BETA = "context-1m-2025-08-07";
const ANTHROPIC_1M_MODEL_PREFIXES = ["claude-opus-4", "claude-sonnet-4"] as const;
const PI_AI_DEFAULT_ANTHROPIC_BETAS = [
"fine-grained-tool-streaming-2025-05-14",
"interleaved-thinking-2025-05-14",
] as const;
const PI_AI_OAUTH_ANTHROPIC_BETAS = [
"claude-code-20250219",
"oauth-2025-04-20",
...PI_AI_DEFAULT_ANTHROPIC_BETAS,
] as const;
type CacheRetention = "none" | "short" | "long";
function isAnthropic1MModel(modelId: string): boolean {
const normalized = modelId.trim().toLowerCase();
return ANTHROPIC_1M_MODEL_PREFIXES.some((prefix) => normalized.startsWith(prefix));
}
function parseHeaderList(value: unknown): string[] {
if (typeof value !== "string") {
return [];
}
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function mergeAnthropicBetaHeader(
headers: Record<string, string> | undefined,
betas: string[],
): Record<string, string> {
const merged = { ...headers };
const existingKey = Object.keys(merged).find((key) => key.toLowerCase() === "anthropic-beta");
const existing = existingKey ? parseHeaderList(merged[existingKey]) : [];
const values = Array.from(new Set([...existing, ...betas]));
const key = existingKey ?? "anthropic-beta";
merged[key] = values.join(",");
return merged;
}
function isAnthropicOAuthApiKey(apiKey: unknown): boolean {
return typeof apiKey === "string" && apiKey.includes("sk-ant-oat");
}
function requiresAnthropicToolPayloadCompatibilityForModel(model: {
api?: unknown;
provider?: unknown;
compat?: unknown;
}): boolean {
if (model.api !== "anthropic-messages") {
return false;
}
if (
typeof model.provider === "string" &&
requiresOpenAiCompatibleAnthropicToolPayload(model.provider)
) {
return true;
}
if (!model.compat || typeof model.compat !== "object" || Array.isArray(model.compat)) {
return false;
}
return (
(model.compat as { requiresOpenAiAnthropicToolPayload?: unknown })
.requiresOpenAiAnthropicToolPayload === true
);
}
function usesOpenAiFunctionAnthropicToolSchemaForModel(model: {
provider?: unknown;
compat?: unknown;
}): boolean {
if (typeof model.provider === "string" && usesOpenAiFunctionAnthropicToolSchema(model.provider)) {
return true;
}
if (!model.compat || typeof model.compat !== "object" || Array.isArray(model.compat)) {
return false;
}
return (
(model.compat as { requiresOpenAiAnthropicToolPayload?: unknown })
.requiresOpenAiAnthropicToolPayload === true
);
}
function usesOpenAiStringModeAnthropicToolChoiceForModel(model: {
provider?: unknown;
compat?: unknown;
}): boolean {
if (
typeof model.provider === "string" &&
usesOpenAiStringModeAnthropicToolChoice(model.provider)
) {
return true;
}
if (!model.compat || typeof model.compat !== "object" || Array.isArray(model.compat)) {
return false;
}
return (
(model.compat as { requiresOpenAiAnthropicToolPayload?: unknown })
.requiresOpenAiAnthropicToolPayload === true
);
}
function normalizeOpenAiFunctionAnthropicToolDefinition(
tool: unknown,
): Record<string, unknown> | undefined {
if (!tool || typeof tool !== "object" || Array.isArray(tool)) {
return undefined;
}
const toolObj = tool as Record<string, unknown>;
if (toolObj.function && typeof toolObj.function === "object") {
return toolObj;
}
const rawName = typeof toolObj.name === "string" ? toolObj.name.trim() : "";
if (!rawName) {
return toolObj;
}
const functionSpec: Record<string, unknown> = {
name: rawName,
parameters:
toolObj.input_schema && typeof toolObj.input_schema === "object"
? toolObj.input_schema
: toolObj.parameters && typeof toolObj.parameters === "object"
? toolObj.parameters
: { type: "object", properties: {} },
};
if (typeof toolObj.description === "string" && toolObj.description.trim()) {
functionSpec.description = toolObj.description;
}
if (typeof toolObj.strict === "boolean") {
functionSpec.strict = toolObj.strict;
}
return {
type: "function",
function: functionSpec,
};
}
function normalizeOpenAiStringModeAnthropicToolChoice(toolChoice: unknown): unknown {
if (!toolChoice || typeof toolChoice !== "object" || Array.isArray(toolChoice)) {
return toolChoice;
}
const choice = toolChoice as Record<string, unknown>;
if (choice.type === "auto") {
return "auto";
}
if (choice.type === "none") {
return "none";
}
if (choice.type === "required" || choice.type === "any") {
return "required";
}
if (choice.type === "tool" && typeof choice.name === "string" && choice.name.trim()) {
return {
type: "function",
function: { name: choice.name.trim() },
};
}
return toolChoice;
}
export function resolveCacheRetention(
extraParams: Record<string, unknown> | undefined,
provider: string,
): CacheRetention | undefined {
const isAnthropicDirect = provider === "anthropic";
const hasBedrockOverride =
extraParams?.cacheRetention !== undefined || extraParams?.cacheControlTtl !== undefined;
const isAnthropicBedrock = provider === "amazon-bedrock" && hasBedrockOverride;
if (!isAnthropicDirect && !isAnthropicBedrock) {
return undefined;
}
const newVal = extraParams?.cacheRetention;
if (newVal === "none" || newVal === "short" || newVal === "long") {
return newVal;
}
const legacy = extraParams?.cacheControlTtl;
if (legacy === "5m") {
return "short";
}
if (legacy === "1h") {
return "long";
}
return isAnthropicDirect ? "short" : undefined;
}
export function resolveAnthropicBetas(
extraParams: Record<string, unknown> | undefined,
provider: string,
modelId: string,
): string[] | undefined {
if (provider !== "anthropic") {
return undefined;
}
const betas = new Set<string>();
const configured = extraParams?.anthropicBeta;
if (typeof configured === "string" && configured.trim()) {
betas.add(configured.trim());
} else if (Array.isArray(configured)) {
for (const beta of configured) {
if (typeof beta === "string" && beta.trim()) {
betas.add(beta.trim());
}
}
}
if (extraParams?.context1m === true) {
if (isAnthropic1MModel(modelId)) {
betas.add(ANTHROPIC_CONTEXT_1M_BETA);
} else {
log.warn(`ignoring context1m for non-opus/sonnet model: ${provider}/${modelId}`);
}
}
return betas.size > 0 ? [...betas] : undefined;
}
export function createAnthropicBetaHeadersWrapper(
baseStreamFn: StreamFn | undefined,
betas: string[],
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const isOauth = isAnthropicOAuthApiKey(options?.apiKey);
const requestedContext1m = betas.includes(ANTHROPIC_CONTEXT_1M_BETA);
const effectiveBetas =
isOauth && requestedContext1m
? betas.filter((beta) => beta !== ANTHROPIC_CONTEXT_1M_BETA)
: betas;
if (isOauth && requestedContext1m) {
log.warn(
`ignoring context1m for OAuth token auth on ${model.provider}/${model.id}; Anthropic rejects context-1m beta with OAuth auth`,
);
}
const piAiBetas = isOauth
? (PI_AI_OAUTH_ANTHROPIC_BETAS as readonly string[])
: (PI_AI_DEFAULT_ANTHROPIC_BETAS as readonly string[]);
const allBetas = [...new Set([...piAiBetas, ...effectiveBetas])];
return underlying(model, context, {
...options,
headers: mergeAnthropicBetaHeader(options?.headers, allBetas),
});
};
}
export function createAnthropicToolPayloadCompatibilityWrapper(
baseStreamFn: StreamFn | undefined,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const originalOnPayload = options?.onPayload;
return underlying(model, context, {
...options,
onPayload: (payload) => {
if (
payload &&
typeof payload === "object" &&
requiresAnthropicToolPayloadCompatibilityForModel(model)
) {
const payloadObj = payload as Record<string, unknown>;
if (
Array.isArray(payloadObj.tools) &&
usesOpenAiFunctionAnthropicToolSchemaForModel(model)
) {
payloadObj.tools = payloadObj.tools
.map((tool) => normalizeOpenAiFunctionAnthropicToolDefinition(tool))
.filter((tool): tool is Record<string, unknown> => !!tool);
}
if (usesOpenAiStringModeAnthropicToolChoiceForModel(model)) {
payloadObj.tool_choice = normalizeOpenAiStringModeAnthropicToolChoice(
payloadObj.tool_choice,
);
}
}
originalOnPayload?.(payload);
},
});
};
}
export function createBedrockNoCacheWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) =>
underlying(model, context, {
...options,
cacheRetention: "none",
});
}
export function isAnthropicBedrockModel(modelId: string): boolean {
const normalized = modelId.toLowerCase();
return normalized.includes("anthropic.claude") || normalized.includes("anthropic/claude");
}

View File

@@ -933,7 +933,7 @@ export async function compactEmbeddedPiSession(
tokenBudget: ceCtxInfo.tokens,
customInstructions: params.customInstructions,
force: params.trigger === "manual",
legacyParams: params as Record<string, unknown>,
runtimeContext: params as Record<string, unknown>,
});
return {
ok: result.ok,

View File

@@ -4,31 +4,33 @@ import { streamSimple } from "@mariozechner/pi-ai";
import type { ThinkLevel } from "../../auto-reply/thinking.js";
import type { OpenClawConfig } from "../../config/config.js";
import {
requiresOpenAiCompatibleAnthropicToolPayload,
usesOpenAiFunctionAnthropicToolSchema,
usesOpenAiStringModeAnthropicToolChoice,
} from "../provider-capabilities.js";
createAnthropicBetaHeadersWrapper,
createAnthropicToolPayloadCompatibilityWrapper,
createBedrockNoCacheWrapper,
isAnthropicBedrockModel,
resolveAnthropicBetas,
resolveCacheRetention,
} from "./anthropic-stream-wrappers.js";
import { log } from "./logger.js";
const OPENROUTER_APP_HEADERS: Record<string, string> = {
"HTTP-Referer": "https://openclaw.ai",
"X-Title": "OpenClaw",
};
const KILOCODE_FEATURE_HEADER = "X-KILOCODE-FEATURE";
const KILOCODE_FEATURE_DEFAULT = "openclaw";
const KILOCODE_FEATURE_ENV_VAR = "KILOCODE_FEATURE";
function resolveKilocodeAppHeaders(): Record<string, string> {
const feature = process.env[KILOCODE_FEATURE_ENV_VAR]?.trim() || KILOCODE_FEATURE_DEFAULT;
return { [KILOCODE_FEATURE_HEADER]: feature };
}
const ANTHROPIC_CONTEXT_1M_BETA = "context-1m-2025-08-07";
const ANTHROPIC_1M_MODEL_PREFIXES = ["claude-opus-4", "claude-sonnet-4"] as const;
// NOTE: We only force `store=true` for *direct* OpenAI Responses.
// Codex responses (chatgpt.com/backend-api/codex/responses) require `store=false`.
const OPENAI_RESPONSES_APIS = new Set(["openai-responses"]);
const OPENAI_RESPONSES_PROVIDERS = new Set(["openai", "azure-openai-responses"]);
import {
createMoonshotThinkingWrapper,
createSiliconFlowThinkingWrapper,
resolveMoonshotThinkingType,
shouldApplySiliconFlowThinkingOffCompat,
} from "./moonshot-stream-wrappers.js";
import {
createCodexDefaultTransportWrapper,
createOpenAIDefaultTransportWrapper,
createOpenAIResponsesContextManagementWrapper,
createOpenAIServiceTierWrapper,
resolveOpenAIServiceTier,
} from "./openai-stream-wrappers.js";
import {
createKilocodeWrapper,
createOpenRouterSystemCacheWrapper,
createOpenRouterWrapper,
isProxyReasoningUnsupported,
} from "./proxy-stream-wrappers.js";
/**
* Resolve provider-specific extra params from model config.
@@ -68,66 +70,11 @@ export function resolveExtraParams(params: {
return merged;
}
type CacheRetention = "none" | "short" | "long";
type OpenAIServiceTier = "auto" | "default" | "flex" | "priority";
type CacheRetentionStreamOptions = Partial<SimpleStreamOptions> & {
cacheRetention?: CacheRetention;
cacheRetention?: "none" | "short" | "long";
openaiWsWarmup?: boolean;
};
/**
* Resolve cacheRetention from extraParams, supporting both new `cacheRetention`
* and legacy `cacheControlTtl` values for backwards compatibility.
*
* Mapping: "5m" → "short", "1h" → "long"
*
* Applies to:
* - direct Anthropic provider
* - Anthropic Claude models on Bedrock when cache retention is explicitly configured
*
* OpenRouter uses openai-completions API with hardcoded cache_control instead
* of the cacheRetention stream option.
*
* Defaults to "short" for direct Anthropic when not explicitly configured.
*/
function resolveCacheRetention(
extraParams: Record<string, unknown> | undefined,
provider: string,
): CacheRetention | undefined {
const isAnthropicDirect = provider === "anthropic";
const hasBedrockOverride =
extraParams?.cacheRetention !== undefined || extraParams?.cacheControlTtl !== undefined;
const isAnthropicBedrock = provider === "amazon-bedrock" && hasBedrockOverride;
if (!isAnthropicDirect && !isAnthropicBedrock) {
return undefined;
}
// Prefer new cacheRetention if present
const newVal = extraParams?.cacheRetention;
if (newVal === "none" || newVal === "short" || newVal === "long") {
return newVal;
}
// Fall back to legacy cacheControlTtl with mapping
const legacy = extraParams?.cacheControlTtl;
if (legacy === "5m") {
return "short";
}
if (legacy === "1h") {
return "long";
}
// Default to "short" only for direct Anthropic when not explicitly configured.
// Bedrock retains upstream provider defaults unless explicitly set.
if (!isAnthropicDirect) {
return undefined;
}
// Default to "short" for direct Anthropic when not explicitly configured
return "short";
}
function createStreamFnWithExtraParams(
baseStreamFn: StreamFn | undefined,
extraParams: Record<string, unknown> | undefined,
@@ -200,833 +147,6 @@ function createStreamFnWithExtraParams(
return wrappedStreamFn;
}
function isAnthropicBedrockModel(modelId: string): boolean {
const normalized = modelId.toLowerCase();
return normalized.includes("anthropic.claude") || normalized.includes("anthropic/claude");
}
function createBedrockNoCacheWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) =>
underlying(model, context, {
...options,
cacheRetention: "none",
});
}
function isDirectOpenAIBaseUrl(baseUrl: unknown): boolean {
if (typeof baseUrl !== "string" || !baseUrl.trim()) {
return false;
}
try {
const host = new URL(baseUrl).hostname.toLowerCase();
return (
host === "api.openai.com" || host === "chatgpt.com" || host.endsWith(".openai.azure.com")
);
} catch {
const normalized = baseUrl.toLowerCase();
return (
normalized.includes("api.openai.com") ||
normalized.includes("chatgpt.com") ||
normalized.includes(".openai.azure.com")
);
}
}
function isOpenAIPublicApiBaseUrl(baseUrl: unknown): boolean {
if (typeof baseUrl !== "string" || !baseUrl.trim()) {
return false;
}
try {
return new URL(baseUrl).hostname.toLowerCase() === "api.openai.com";
} catch {
return baseUrl.toLowerCase().includes("api.openai.com");
}
}
function shouldForceResponsesStore(model: {
api?: unknown;
provider?: unknown;
baseUrl?: unknown;
compat?: { supportsStore?: boolean };
}): boolean {
// Never force store=true when the model explicitly declares supportsStore=false
// (e.g. Azure OpenAI Responses API without server-side persistence).
if (model.compat?.supportsStore === false) {
return false;
}
if (typeof model.api !== "string" || typeof model.provider !== "string") {
return false;
}
if (!OPENAI_RESPONSES_APIS.has(model.api)) {
return false;
}
if (!OPENAI_RESPONSES_PROVIDERS.has(model.provider)) {
return false;
}
return isDirectOpenAIBaseUrl(model.baseUrl);
}
function parsePositiveInteger(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return Math.floor(value);
}
if (typeof value === "string") {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return undefined;
}
function resolveOpenAIResponsesCompactThreshold(model: { contextWindow?: unknown }): number {
const contextWindow = parsePositiveInteger(model.contextWindow);
if (contextWindow) {
return Math.max(1_000, Math.floor(contextWindow * 0.7));
}
return 80_000;
}
function shouldEnableOpenAIResponsesServerCompaction(
model: {
api?: unknown;
provider?: unknown;
baseUrl?: unknown;
compat?: { supportsStore?: boolean };
},
extraParams: Record<string, unknown> | undefined,
): boolean {
const configured = extraParams?.responsesServerCompaction;
if (configured === false) {
return false;
}
if (!shouldForceResponsesStore(model)) {
return false;
}
if (configured === true) {
return true;
}
// Auto-enable for direct OpenAI Responses models.
return model.provider === "openai";
}
function shouldStripResponsesStore(
model: { api?: unknown; compat?: { supportsStore?: boolean } },
forceStore: boolean,
): boolean {
if (forceStore) {
return false;
}
if (typeof model.api !== "string") {
return false;
}
return OPENAI_RESPONSES_APIS.has(model.api) && model.compat?.supportsStore === false;
}
function applyOpenAIResponsesPayloadOverrides(params: {
payloadObj: Record<string, unknown>;
forceStore: boolean;
stripStore: boolean;
useServerCompaction: boolean;
compactThreshold: number;
}): void {
if (params.forceStore) {
params.payloadObj.store = true;
}
if (params.stripStore) {
delete params.payloadObj.store;
}
if (params.useServerCompaction && params.payloadObj.context_management === undefined) {
params.payloadObj.context_management = [
{
type: "compaction",
compact_threshold: params.compactThreshold,
},
];
}
}
function createOpenAIResponsesContextManagementWrapper(
baseStreamFn: StreamFn | undefined,
extraParams: Record<string, unknown> | undefined,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const forceStore = shouldForceResponsesStore(model);
const useServerCompaction = shouldEnableOpenAIResponsesServerCompaction(model, extraParams);
// Strip `store` from the payload when the model declares supportsStore=false.
// pi-ai upstream hardcodes `store: false` for Responses API; strict
// OpenAI-compatible endpoints (e.g. Gemini via Cloudflare) reject it.
const stripStore = shouldStripResponsesStore(model, forceStore);
if (!forceStore && !useServerCompaction && !stripStore) {
return underlying(model, context, options);
}
const compactThreshold =
parsePositiveInteger(extraParams?.responsesCompactThreshold) ??
resolveOpenAIResponsesCompactThreshold(model);
const originalOnPayload = options?.onPayload;
return underlying(model, context, {
...options,
onPayload: (payload) => {
if (payload && typeof payload === "object") {
applyOpenAIResponsesPayloadOverrides({
payloadObj: payload as Record<string, unknown>,
forceStore,
stripStore,
useServerCompaction,
compactThreshold,
});
}
originalOnPayload?.(payload);
},
});
};
}
function normalizeOpenAIServiceTier(value: unknown): OpenAIServiceTier | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (
normalized === "auto" ||
normalized === "default" ||
normalized === "flex" ||
normalized === "priority"
) {
return normalized;
}
return undefined;
}
function resolveOpenAIServiceTier(
extraParams: Record<string, unknown> | undefined,
): OpenAIServiceTier | undefined {
const raw = extraParams?.serviceTier ?? extraParams?.service_tier;
const normalized = normalizeOpenAIServiceTier(raw);
if (raw !== undefined && normalized === undefined) {
const rawSummary = typeof raw === "string" ? raw : typeof raw;
log.warn(`ignoring invalid OpenAI service tier param: ${rawSummary}`);
}
return normalized;
}
function createOpenAIServiceTierWrapper(
baseStreamFn: StreamFn | undefined,
serviceTier: OpenAIServiceTier,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
if (
model.api !== "openai-responses" ||
model.provider !== "openai" ||
!isOpenAIPublicApiBaseUrl(model.baseUrl)
) {
return underlying(model, context, options);
}
const originalOnPayload = options?.onPayload;
return underlying(model, context, {
...options,
onPayload: (payload) => {
if (payload && typeof payload === "object") {
const payloadObj = payload as Record<string, unknown>;
if (payloadObj.service_tier === undefined) {
payloadObj.service_tier = serviceTier;
}
}
originalOnPayload?.(payload);
},
});
};
}
function createCodexDefaultTransportWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) =>
underlying(model, context, {
...options,
transport: options?.transport ?? "auto",
});
}
function createOpenAIDefaultTransportWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const typedOptions = options as
| (SimpleStreamOptions & { openaiWsWarmup?: boolean })
| undefined;
const mergedOptions = {
...options,
transport: options?.transport ?? "auto",
// Warm-up is optional in OpenAI docs; enabled by default here for lower
// first-turn latency on WebSocket sessions. Set params.openaiWsWarmup=false
// to disable per model.
openaiWsWarmup: typedOptions?.openaiWsWarmup ?? true,
} as SimpleStreamOptions;
return underlying(model, context, mergedOptions);
};
}
function isAnthropic1MModel(modelId: string): boolean {
const normalized = modelId.trim().toLowerCase();
return ANTHROPIC_1M_MODEL_PREFIXES.some((prefix) => normalized.startsWith(prefix));
}
function parseHeaderList(value: unknown): string[] {
if (typeof value !== "string") {
return [];
}
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function resolveAnthropicBetas(
extraParams: Record<string, unknown> | undefined,
provider: string,
modelId: string,
): string[] | undefined {
if (provider !== "anthropic") {
return undefined;
}
const betas = new Set<string>();
const configured = extraParams?.anthropicBeta;
if (typeof configured === "string" && configured.trim()) {
betas.add(configured.trim());
} else if (Array.isArray(configured)) {
for (const beta of configured) {
if (typeof beta === "string" && beta.trim()) {
betas.add(beta.trim());
}
}
}
if (extraParams?.context1m === true) {
if (isAnthropic1MModel(modelId)) {
betas.add(ANTHROPIC_CONTEXT_1M_BETA);
} else {
log.warn(`ignoring context1m for non-opus/sonnet model: ${provider}/${modelId}`);
}
}
return betas.size > 0 ? [...betas] : undefined;
}
function mergeAnthropicBetaHeader(
headers: Record<string, string> | undefined,
betas: string[],
): Record<string, string> {
const merged = { ...headers };
const existingKey = Object.keys(merged).find((key) => key.toLowerCase() === "anthropic-beta");
const existing = existingKey ? parseHeaderList(merged[existingKey]) : [];
const values = Array.from(new Set([...existing, ...betas]));
const key = existingKey ?? "anthropic-beta";
merged[key] = values.join(",");
return merged;
}
// Betas that pi-ai's createClient injects for standard Anthropic API key calls.
// Must be included when injecting anthropic-beta via options.headers, because
// pi-ai's mergeHeaders uses Object.assign (last-wins), which would otherwise
// overwrite the hardcoded defaultHeaders["anthropic-beta"].
const PI_AI_DEFAULT_ANTHROPIC_BETAS = [
"fine-grained-tool-streaming-2025-05-14",
"interleaved-thinking-2025-05-14",
] as const;
// Additional betas pi-ai injects when the API key is an OAuth token (sk-ant-oat-*).
// These are required for Anthropic to accept OAuth Bearer auth. Losing oauth-2025-04-20
// causes a 401 "OAuth authentication is currently not supported".
const PI_AI_OAUTH_ANTHROPIC_BETAS = [
"claude-code-20250219",
"oauth-2025-04-20",
...PI_AI_DEFAULT_ANTHROPIC_BETAS,
] as const;
function isAnthropicOAuthApiKey(apiKey: unknown): boolean {
return typeof apiKey === "string" && apiKey.includes("sk-ant-oat");
}
function createAnthropicBetaHeadersWrapper(
baseStreamFn: StreamFn | undefined,
betas: string[],
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const isOauth = isAnthropicOAuthApiKey(options?.apiKey);
const requestedContext1m = betas.includes(ANTHROPIC_CONTEXT_1M_BETA);
const effectiveBetas =
isOauth && requestedContext1m
? betas.filter((beta) => beta !== ANTHROPIC_CONTEXT_1M_BETA)
: betas;
if (isOauth && requestedContext1m) {
log.warn(
`ignoring context1m for OAuth token auth on ${model.provider}/${model.id}; Anthropic rejects context-1m beta with OAuth auth`,
);
}
// Preserve the betas pi-ai's createClient would inject for the given token type.
// Without this, our options.headers["anthropic-beta"] overwrites the pi-ai
// defaultHeaders via Object.assign, stripping critical betas like oauth-2025-04-20.
const piAiBetas = isOauth
? (PI_AI_OAUTH_ANTHROPIC_BETAS as readonly string[])
: (PI_AI_DEFAULT_ANTHROPIC_BETAS as readonly string[]);
const allBetas = [...new Set([...piAiBetas, ...effectiveBetas])];
return underlying(model, context, {
...options,
headers: mergeAnthropicBetaHeader(options?.headers, allBetas),
});
};
}
function isOpenRouterAnthropicModel(provider: string, modelId: string): boolean {
return provider.toLowerCase() === "openrouter" && modelId.toLowerCase().startsWith("anthropic/");
}
type PayloadMessage = {
role?: string;
content?: unknown;
};
/**
* Inject cache_control into the system message for OpenRouter Anthropic models.
* OpenRouter passes through Anthropic's cache_control field — caching the system
* prompt avoids re-processing it on every request.
*/
function createOpenRouterSystemCacheWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
if (
typeof model.provider !== "string" ||
typeof model.id !== "string" ||
!isOpenRouterAnthropicModel(model.provider, model.id)
) {
return underlying(model, context, options);
}
const originalOnPayload = options?.onPayload;
return underlying(model, context, {
...options,
onPayload: (payload) => {
const messages = (payload as Record<string, unknown>)?.messages;
if (Array.isArray(messages)) {
for (const msg of messages as PayloadMessage[]) {
if (msg.role !== "system" && msg.role !== "developer") {
continue;
}
if (typeof msg.content === "string") {
msg.content = [
{ type: "text", text: msg.content, cache_control: { type: "ephemeral" } },
];
} else if (Array.isArray(msg.content) && msg.content.length > 0) {
const last = msg.content[msg.content.length - 1];
if (last && typeof last === "object") {
(last as Record<string, unknown>).cache_control = { type: "ephemeral" };
}
}
}
}
originalOnPayload?.(payload);
},
});
};
}
/**
* Map OpenClaw's ThinkLevel to OpenRouter's reasoning.effort values.
* "off" maps to "none"; all other levels pass through as-is.
*/
function mapThinkingLevelToOpenRouterReasoningEffort(
thinkingLevel: ThinkLevel,
): "none" | "minimal" | "low" | "medium" | "high" | "xhigh" {
if (thinkingLevel === "off") {
return "none";
}
if (thinkingLevel === "adaptive") {
return "medium";
}
return thinkingLevel;
}
function shouldApplySiliconFlowThinkingOffCompat(params: {
provider: string;
modelId: string;
thinkingLevel?: ThinkLevel;
}): boolean {
return (
params.provider === "siliconflow" &&
params.thinkingLevel === "off" &&
params.modelId.startsWith("Pro/")
);
}
/**
* SiliconFlow's Pro/* models reject string thinking modes (including "off")
* with HTTP 400 invalid-parameter errors. Normalize to `thinking: null` to
* preserve "thinking disabled" intent without sending an invalid enum value.
*/
function createSiliconFlowThinkingWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const originalOnPayload = options?.onPayload;
return underlying(model, context, {
...options,
onPayload: (payload) => {
if (payload && typeof payload === "object") {
const payloadObj = payload as Record<string, unknown>;
if (payloadObj.thinking === "off") {
payloadObj.thinking = null;
}
}
originalOnPayload?.(payload);
},
});
};
}
type MoonshotThinkingType = "enabled" | "disabled";
function normalizeMoonshotThinkingType(value: unknown): MoonshotThinkingType | undefined {
if (typeof value === "boolean") {
return value ? "enabled" : "disabled";
}
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (
normalized === "enabled" ||
normalized === "enable" ||
normalized === "on" ||
normalized === "true"
) {
return "enabled";
}
if (
normalized === "disabled" ||
normalized === "disable" ||
normalized === "off" ||
normalized === "false"
) {
return "disabled";
}
return undefined;
}
if (value && typeof value === "object" && !Array.isArray(value)) {
const typeValue = (value as Record<string, unknown>).type;
return normalizeMoonshotThinkingType(typeValue);
}
return undefined;
}
function resolveMoonshotThinkingType(params: {
configuredThinking: unknown;
thinkingLevel?: ThinkLevel;
}): MoonshotThinkingType | undefined {
const configured = normalizeMoonshotThinkingType(params.configuredThinking);
if (configured) {
return configured;
}
if (!params.thinkingLevel) {
return undefined;
}
return params.thinkingLevel === "off" ? "disabled" : "enabled";
}
function isMoonshotToolChoiceCompatible(toolChoice: unknown): boolean {
if (toolChoice == null) {
return true;
}
if (toolChoice === "auto" || toolChoice === "none") {
return true;
}
if (typeof toolChoice === "object" && !Array.isArray(toolChoice)) {
const typeValue = (toolChoice as Record<string, unknown>).type;
return typeValue === "auto" || typeValue === "none";
}
return false;
}
/**
* Moonshot Kimi supports native binary thinking mode:
* - { thinking: { type: "enabled" } }
* - { thinking: { type: "disabled" } }
*
* When thinking is enabled, Moonshot only accepts tool_choice auto|none.
* Normalize incompatible values to auto instead of failing the request.
*/
function createMoonshotThinkingWrapper(
baseStreamFn: StreamFn | undefined,
thinkingType?: MoonshotThinkingType,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const originalOnPayload = options?.onPayload;
return underlying(model, context, {
...options,
onPayload: (payload) => {
if (payload && typeof payload === "object") {
const payloadObj = payload as Record<string, unknown>;
let effectiveThinkingType = normalizeMoonshotThinkingType(payloadObj.thinking);
if (thinkingType) {
payloadObj.thinking = { type: thinkingType };
effectiveThinkingType = thinkingType;
}
if (
effectiveThinkingType === "enabled" &&
!isMoonshotToolChoiceCompatible(payloadObj.tool_choice)
) {
payloadObj.tool_choice = "auto";
}
}
originalOnPayload?.(payload);
},
});
};
}
function requiresAnthropicToolPayloadCompatibilityForModel(model: {
api?: unknown;
provider?: unknown;
baseUrl?: unknown;
}): boolean {
if (model.api !== "anthropic-messages") {
return false;
}
if (
typeof model.provider === "string" &&
requiresOpenAiCompatibleAnthropicToolPayload(model.provider)
) {
return true;
}
if (typeof model.baseUrl !== "string" || !model.baseUrl.trim()) {
return false;
}
try {
const parsed = new URL(model.baseUrl);
const host = parsed.hostname.toLowerCase();
const pathname = parsed.pathname.toLowerCase();
return host.endsWith("kimi.com") && pathname.startsWith("/coding");
} catch {
const normalized = model.baseUrl.toLowerCase();
return normalized.includes("kimi.com/coding");
}
}
function normalizeOpenAiFunctionAnthropicToolDefinition(
tool: unknown,
): Record<string, unknown> | undefined {
if (!tool || typeof tool !== "object" || Array.isArray(tool)) {
return undefined;
}
const toolObj = tool as Record<string, unknown>;
if (toolObj.function && typeof toolObj.function === "object") {
return toolObj;
}
const rawName = typeof toolObj.name === "string" ? toolObj.name.trim() : "";
if (!rawName) {
return toolObj;
}
const functionSpec: Record<string, unknown> = {
name: rawName,
parameters:
toolObj.input_schema && typeof toolObj.input_schema === "object"
? toolObj.input_schema
: toolObj.parameters && typeof toolObj.parameters === "object"
? toolObj.parameters
: { type: "object", properties: {} },
};
if (typeof toolObj.description === "string" && toolObj.description.trim()) {
functionSpec.description = toolObj.description;
}
if (typeof toolObj.strict === "boolean") {
functionSpec.strict = toolObj.strict;
}
return {
type: "function",
function: functionSpec,
};
}
function normalizeOpenAiStringModeAnthropicToolChoice(toolChoice: unknown): unknown {
if (!toolChoice || typeof toolChoice !== "object" || Array.isArray(toolChoice)) {
return toolChoice;
}
const choice = toolChoice as Record<string, unknown>;
if (choice.type === "auto") {
return "auto";
}
if (choice.type === "none") {
return "none";
}
if (choice.type === "required") {
return "required";
}
if (choice.type === "any") {
return "required";
}
if (choice.type === "tool" && typeof choice.name === "string" && choice.name.trim()) {
return {
type: "function",
function: { name: choice.name.trim() },
};
}
return toolChoice;
}
/**
* Some anthropic-messages providers accept Anthropic framing but still expect
* OpenAI-style tool payloads (`tools[].function`, string tool_choice modes).
*/
function createAnthropicToolPayloadCompatibilityWrapper(
baseStreamFn: StreamFn | undefined,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const originalOnPayload = options?.onPayload;
return underlying(model, context, {
...options,
onPayload: (payload) => {
const provider = typeof model.provider === "string" ? model.provider : undefined;
if (
payload &&
typeof payload === "object" &&
requiresAnthropicToolPayloadCompatibilityForModel(model)
) {
const payloadObj = payload as Record<string, unknown>;
if (Array.isArray(payloadObj.tools) && usesOpenAiFunctionAnthropicToolSchema(provider)) {
payloadObj.tools = payloadObj.tools
.map((tool) => normalizeOpenAiFunctionAnthropicToolDefinition(tool))
.filter((tool): tool is Record<string, unknown> => !!tool);
}
if (usesOpenAiStringModeAnthropicToolChoice(provider)) {
payloadObj.tool_choice = normalizeOpenAiStringModeAnthropicToolChoice(
payloadObj.tool_choice,
);
}
}
originalOnPayload?.(payload);
},
});
};
}
/**
* Create a streamFn wrapper that adds OpenRouter app attribution headers
* and injects reasoning.effort based on the configured thinking level.
*/
function normalizeProxyReasoningPayload(payload: unknown, thinkingLevel?: ThinkLevel): void {
if (!payload || typeof payload !== "object") {
return;
}
const payloadObj = payload as Record<string, unknown>;
// pi-ai may inject a top-level reasoning_effort (OpenAI flat format).
// OpenRouter-compatible proxy gateways expect the nested reasoning.effort
// shape instead, and some models reject the flat field outright.
delete payloadObj.reasoning_effort;
// When thinking is "off", or provider/model guards disable injection,
// leave reasoning unset after normalizing away the legacy flat field.
if (!thinkingLevel || thinkingLevel === "off") {
return;
}
const existingReasoning = payloadObj.reasoning;
// OpenRouter treats reasoning.effort and reasoning.max_tokens as
// alternative controls. If max_tokens is already present, do not inject
// effort and do not overwrite caller-supplied reasoning.
if (
existingReasoning &&
typeof existingReasoning === "object" &&
!Array.isArray(existingReasoning)
) {
const reasoningObj = existingReasoning as Record<string, unknown>;
if (!("max_tokens" in reasoningObj) && !("effort" in reasoningObj)) {
reasoningObj.effort = mapThinkingLevelToOpenRouterReasoningEffort(thinkingLevel);
}
} else if (!existingReasoning) {
payloadObj.reasoning = {
effort: mapThinkingLevelToOpenRouterReasoningEffort(thinkingLevel),
};
}
}
function createOpenRouterWrapper(
baseStreamFn: StreamFn | undefined,
thinkingLevel?: ThinkLevel,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const onPayload = options?.onPayload;
return underlying(model, context, {
...options,
headers: {
...OPENROUTER_APP_HEADERS,
...options?.headers,
},
onPayload: (payload) => {
normalizeProxyReasoningPayload(payload, thinkingLevel);
onPayload?.(payload);
},
});
};
}
/**
* Models on OpenRouter-style proxy providers that reject `reasoning.effort`.
*/
function isProxyReasoningUnsupported(modelId: string): boolean {
const id = modelId.toLowerCase();
return id.startsWith("x-ai/");
}
/**
* Create a streamFn wrapper that adds the Kilocode feature attribution header
* and injects reasoning.effort based on the configured thinking level.
*
* The Kilocode provider gateway manages provider-specific quirks (e.g. cache
* control) server-side, so we only handle header injection and reasoning here.
*/
function createKilocodeWrapper(
baseStreamFn: StreamFn | undefined,
thinkingLevel?: ThinkLevel,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const onPayload = options?.onPayload;
return underlying(model, context, {
...options,
headers: {
...options?.headers,
...resolveKilocodeAppHeaders(),
},
onPayload: (payload) => {
normalizeProxyReasoningPayload(payload, thinkingLevel);
onPayload?.(payload);
},
});
};
}
function isGemini31Model(modelId: string): boolean {
const normalized = modelId.toLowerCase();
return normalized.includes("gemini-3.1-pro") || normalized.includes("gemini-3.1-flash");

View File

@@ -0,0 +1,113 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { streamSimple } from "@mariozechner/pi-ai";
import type { ThinkLevel } from "../../auto-reply/thinking.js";
type MoonshotThinkingType = "enabled" | "disabled";
function normalizeMoonshotThinkingType(value: unknown): MoonshotThinkingType | undefined {
if (typeof value === "boolean") {
return value ? "enabled" : "disabled";
}
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (["enabled", "enable", "on", "true"].includes(normalized)) {
return "enabled";
}
if (["disabled", "disable", "off", "false"].includes(normalized)) {
return "disabled";
}
return undefined;
}
if (value && typeof value === "object" && !Array.isArray(value)) {
return normalizeMoonshotThinkingType((value as Record<string, unknown>).type);
}
return undefined;
}
function isMoonshotToolChoiceCompatible(toolChoice: unknown): boolean {
if (toolChoice == null || toolChoice === "auto" || toolChoice === "none") {
return true;
}
if (typeof toolChoice === "object" && !Array.isArray(toolChoice)) {
const typeValue = (toolChoice as Record<string, unknown>).type;
return typeValue === "auto" || typeValue === "none";
}
return false;
}
export function shouldApplySiliconFlowThinkingOffCompat(params: {
provider: string;
modelId: string;
thinkingLevel?: ThinkLevel;
}): boolean {
return (
params.provider === "siliconflow" &&
params.thinkingLevel === "off" &&
params.modelId.startsWith("Pro/")
);
}
export function createSiliconFlowThinkingWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const originalOnPayload = options?.onPayload;
return underlying(model, context, {
...options,
onPayload: (payload) => {
if (payload && typeof payload === "object") {
const payloadObj = payload as Record<string, unknown>;
if (payloadObj.thinking === "off") {
payloadObj.thinking = null;
}
}
originalOnPayload?.(payload);
},
});
};
}
export function resolveMoonshotThinkingType(params: {
configuredThinking: unknown;
thinkingLevel?: ThinkLevel;
}): MoonshotThinkingType | undefined {
const configured = normalizeMoonshotThinkingType(params.configuredThinking);
if (configured) {
return configured;
}
if (!params.thinkingLevel) {
return undefined;
}
return params.thinkingLevel === "off" ? "disabled" : "enabled";
}
export function createMoonshotThinkingWrapper(
baseStreamFn: StreamFn | undefined,
thinkingType?: MoonshotThinkingType,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const originalOnPayload = options?.onPayload;
return underlying(model, context, {
...options,
onPayload: (payload) => {
if (payload && typeof payload === "object") {
const payloadObj = payload as Record<string, unknown>;
let effectiveThinkingType = normalizeMoonshotThinkingType(payloadObj.thinking);
if (thinkingType) {
payloadObj.thinking = { type: thinkingType };
effectiveThinkingType = thinkingType;
}
if (
effectiveThinkingType === "enabled" &&
!isMoonshotToolChoiceCompatible(payloadObj.tool_choice)
) {
payloadObj.tool_choice = "auto";
}
}
originalOnPayload?.(payload);
},
});
};
}

View File

@@ -0,0 +1,257 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type { SimpleStreamOptions } from "@mariozechner/pi-ai";
import { streamSimple } from "@mariozechner/pi-ai";
import { log } from "./logger.js";
type OpenAIServiceTier = "auto" | "default" | "flex" | "priority";
const OPENAI_RESPONSES_APIS = new Set(["openai-responses"]);
const OPENAI_RESPONSES_PROVIDERS = new Set(["openai", "azure-openai-responses"]);
function isDirectOpenAIBaseUrl(baseUrl: unknown): boolean {
if (typeof baseUrl !== "string" || !baseUrl.trim()) {
return false;
}
try {
const host = new URL(baseUrl).hostname.toLowerCase();
return (
host === "api.openai.com" || host === "chatgpt.com" || host.endsWith(".openai.azure.com")
);
} catch {
const normalized = baseUrl.toLowerCase();
return (
normalized.includes("api.openai.com") ||
normalized.includes("chatgpt.com") ||
normalized.includes(".openai.azure.com")
);
}
}
function isOpenAIPublicApiBaseUrl(baseUrl: unknown): boolean {
if (typeof baseUrl !== "string" || !baseUrl.trim()) {
return false;
}
try {
return new URL(baseUrl).hostname.toLowerCase() === "api.openai.com";
} catch {
return baseUrl.toLowerCase().includes("api.openai.com");
}
}
function shouldForceResponsesStore(model: {
api?: unknown;
provider?: unknown;
baseUrl?: unknown;
compat?: { supportsStore?: boolean };
}): boolean {
if (model.compat?.supportsStore === false) {
return false;
}
if (typeof model.api !== "string" || typeof model.provider !== "string") {
return false;
}
if (!OPENAI_RESPONSES_APIS.has(model.api)) {
return false;
}
if (!OPENAI_RESPONSES_PROVIDERS.has(model.provider)) {
return false;
}
return isDirectOpenAIBaseUrl(model.baseUrl);
}
function parsePositiveInteger(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return Math.floor(value);
}
if (typeof value === "string") {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return undefined;
}
function resolveOpenAIResponsesCompactThreshold(model: { contextWindow?: unknown }): number {
const contextWindow = parsePositiveInteger(model.contextWindow);
if (contextWindow) {
return Math.max(1_000, Math.floor(contextWindow * 0.7));
}
return 80_000;
}
function shouldEnableOpenAIResponsesServerCompaction(
model: {
api?: unknown;
provider?: unknown;
baseUrl?: unknown;
compat?: { supportsStore?: boolean };
},
extraParams: Record<string, unknown> | undefined,
): boolean {
const configured = extraParams?.responsesServerCompaction;
if (configured === false) {
return false;
}
if (!shouldForceResponsesStore(model)) {
return false;
}
if (configured === true) {
return true;
}
return model.provider === "openai";
}
function shouldStripResponsesStore(
model: { api?: unknown; compat?: { supportsStore?: boolean } },
forceStore: boolean,
): boolean {
if (forceStore) {
return false;
}
if (typeof model.api !== "string") {
return false;
}
return OPENAI_RESPONSES_APIS.has(model.api) && model.compat?.supportsStore === false;
}
function applyOpenAIResponsesPayloadOverrides(params: {
payloadObj: Record<string, unknown>;
forceStore: boolean;
stripStore: boolean;
useServerCompaction: boolean;
compactThreshold: number;
}): void {
if (params.forceStore) {
params.payloadObj.store = true;
}
if (params.stripStore) {
delete params.payloadObj.store;
}
if (params.useServerCompaction && params.payloadObj.context_management === undefined) {
params.payloadObj.context_management = [
{
type: "compaction",
compact_threshold: params.compactThreshold,
},
];
}
}
function normalizeOpenAIServiceTier(value: unknown): OpenAIServiceTier | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (
normalized === "auto" ||
normalized === "default" ||
normalized === "flex" ||
normalized === "priority"
) {
return normalized;
}
return undefined;
}
export function resolveOpenAIServiceTier(
extraParams: Record<string, unknown> | undefined,
): OpenAIServiceTier | undefined {
const raw = extraParams?.serviceTier ?? extraParams?.service_tier;
const normalized = normalizeOpenAIServiceTier(raw);
if (raw !== undefined && normalized === undefined) {
const rawSummary = typeof raw === "string" ? raw : typeof raw;
log.warn(`ignoring invalid OpenAI service tier param: ${rawSummary}`);
}
return normalized;
}
export function createOpenAIResponsesContextManagementWrapper(
baseStreamFn: StreamFn | undefined,
extraParams: Record<string, unknown> | undefined,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const forceStore = shouldForceResponsesStore(model);
const useServerCompaction = shouldEnableOpenAIResponsesServerCompaction(model, extraParams);
const stripStore = shouldStripResponsesStore(model, forceStore);
if (!forceStore && !useServerCompaction && !stripStore) {
return underlying(model, context, options);
}
const compactThreshold =
parsePositiveInteger(extraParams?.responsesCompactThreshold) ??
resolveOpenAIResponsesCompactThreshold(model);
const originalOnPayload = options?.onPayload;
return underlying(model, context, {
...options,
onPayload: (payload) => {
if (payload && typeof payload === "object") {
applyOpenAIResponsesPayloadOverrides({
payloadObj: payload as Record<string, unknown>,
forceStore,
stripStore,
useServerCompaction,
compactThreshold,
});
}
originalOnPayload?.(payload);
},
});
};
}
export function createOpenAIServiceTierWrapper(
baseStreamFn: StreamFn | undefined,
serviceTier: OpenAIServiceTier,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
if (
model.api !== "openai-responses" ||
model.provider !== "openai" ||
!isOpenAIPublicApiBaseUrl(model.baseUrl)
) {
return underlying(model, context, options);
}
const originalOnPayload = options?.onPayload;
return underlying(model, context, {
...options,
onPayload: (payload) => {
if (payload && typeof payload === "object") {
const payloadObj = payload as Record<string, unknown>;
if (payloadObj.service_tier === undefined) {
payloadObj.service_tier = serviceTier;
}
}
originalOnPayload?.(payload);
},
});
};
}
export function createCodexDefaultTransportWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) =>
underlying(model, context, {
...options,
transport: options?.transport ?? "auto",
});
}
export function createOpenAIDefaultTransportWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const typedOptions = options as
| (SimpleStreamOptions & { openaiWsWarmup?: boolean })
| undefined;
const mergedOptions = {
...options,
transport: options?.transport ?? "auto",
openaiWsWarmup: typedOptions?.openaiWsWarmup ?? true,
} as SimpleStreamOptions;
return underlying(model, context, mergedOptions);
};
}

View File

@@ -0,0 +1,145 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { streamSimple } from "@mariozechner/pi-ai";
import type { ThinkLevel } from "../../auto-reply/thinking.js";
const OPENROUTER_APP_HEADERS: Record<string, string> = {
"HTTP-Referer": "https://openclaw.ai",
"X-Title": "OpenClaw",
};
const KILOCODE_FEATURE_HEADER = "X-KILOCODE-FEATURE";
const KILOCODE_FEATURE_DEFAULT = "openclaw";
const KILOCODE_FEATURE_ENV_VAR = "KILOCODE_FEATURE";
function resolveKilocodeAppHeaders(): Record<string, string> {
const feature = process.env[KILOCODE_FEATURE_ENV_VAR]?.trim() || KILOCODE_FEATURE_DEFAULT;
return { [KILOCODE_FEATURE_HEADER]: feature };
}
function isOpenRouterAnthropicModel(provider: string, modelId: string): boolean {
return provider.toLowerCase() === "openrouter" && modelId.toLowerCase().startsWith("anthropic/");
}
function mapThinkingLevelToOpenRouterReasoningEffort(
thinkingLevel: ThinkLevel,
): "none" | "minimal" | "low" | "medium" | "high" | "xhigh" {
if (thinkingLevel === "off") {
return "none";
}
if (thinkingLevel === "adaptive") {
return "medium";
}
return thinkingLevel;
}
function normalizeProxyReasoningPayload(payload: unknown, thinkingLevel?: ThinkLevel): void {
if (!payload || typeof payload !== "object") {
return;
}
const payloadObj = payload as Record<string, unknown>;
delete payloadObj.reasoning_effort;
if (!thinkingLevel || thinkingLevel === "off") {
return;
}
const existingReasoning = payloadObj.reasoning;
if (
existingReasoning &&
typeof existingReasoning === "object" &&
!Array.isArray(existingReasoning)
) {
const reasoningObj = existingReasoning as Record<string, unknown>;
if (!("max_tokens" in reasoningObj) && !("effort" in reasoningObj)) {
reasoningObj.effort = mapThinkingLevelToOpenRouterReasoningEffort(thinkingLevel);
}
} else if (!existingReasoning) {
payloadObj.reasoning = {
effort: mapThinkingLevelToOpenRouterReasoningEffort(thinkingLevel),
};
}
}
export function createOpenRouterSystemCacheWrapper(baseStreamFn: StreamFn | undefined): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
if (
typeof model.provider !== "string" ||
typeof model.id !== "string" ||
!isOpenRouterAnthropicModel(model.provider, model.id)
) {
return underlying(model, context, options);
}
const originalOnPayload = options?.onPayload;
return underlying(model, context, {
...options,
onPayload: (payload) => {
const messages = (payload as Record<string, unknown>)?.messages;
if (Array.isArray(messages)) {
for (const msg of messages as Array<{ role?: string; content?: unknown }>) {
if (msg.role !== "system" && msg.role !== "developer") {
continue;
}
if (typeof msg.content === "string") {
msg.content = [
{ type: "text", text: msg.content, cache_control: { type: "ephemeral" } },
];
} else if (Array.isArray(msg.content) && msg.content.length > 0) {
const last = msg.content[msg.content.length - 1];
if (last && typeof last === "object") {
(last as Record<string, unknown>).cache_control = { type: "ephemeral" };
}
}
}
}
originalOnPayload?.(payload);
},
});
};
}
export function createOpenRouterWrapper(
baseStreamFn: StreamFn | undefined,
thinkingLevel?: ThinkLevel,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const onPayload = options?.onPayload;
return underlying(model, context, {
...options,
headers: {
...OPENROUTER_APP_HEADERS,
...options?.headers,
},
onPayload: (payload) => {
normalizeProxyReasoningPayload(payload, thinkingLevel);
onPayload?.(payload);
},
});
};
}
export function isProxyReasoningUnsupported(modelId: string): boolean {
return modelId.toLowerCase().startsWith("x-ai/");
}
export function createKilocodeWrapper(
baseStreamFn: StreamFn | undefined,
thinkingLevel?: ThinkLevel,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
const onPayload = options?.onPayload;
return underlying(model, context, {
...options,
headers: {
...options?.headers,
...resolveKilocodeAppHeaders(),
},
onPayload: (payload) => {
normalizeProxyReasoningPayload(payload, thinkingLevel);
onPayload?.(payload);
},
});
};
}

View File

@@ -1025,7 +1025,7 @@ export async function runEmbeddedPiAgent(
tokenBudget: ctxInfo.tokens,
force: true,
compactionTarget: "budget",
legacyParams: {
runtimeContext: {
sessionKey: params.sessionKey,
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,

View File

@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import { resolveOllamaBaseUrlForRun } from "../../ollama-stream.js";
import {
buildAfterTurnLegacyCompactionParams,
buildAfterTurnRuntimeContext,
composeSystemPromptWithHookContext,
isOllamaCompatProvider,
prependSystemPromptAddition,
@@ -638,9 +638,9 @@ describe("prependSystemPromptAddition", () => {
});
});
describe("buildAfterTurnLegacyCompactionParams", () => {
describe("buildAfterTurnRuntimeContext", () => {
it("uses primary model when compaction.model is not set", () => {
const legacy = buildAfterTurnLegacyCompactionParams({
const legacy = buildAfterTurnRuntimeContext({
attempt: {
sessionKey: "agent:main:session:abc",
messageChannel: "slack",
@@ -668,7 +668,7 @@ describe("buildAfterTurnLegacyCompactionParams", () => {
});
it("passes primary model through even when compaction.model is set (override resolved in compactDirect)", () => {
const legacy = buildAfterTurnLegacyCompactionParams({
const legacy = buildAfterTurnRuntimeContext({
attempt: {
sessionKey: "agent:main:session:abc",
messageChannel: "slack",
@@ -704,9 +704,8 @@ describe("buildAfterTurnLegacyCompactionParams", () => {
model: "gpt-5.3-codex",
});
});
it("includes resolved auth profile fields for context-engine afterTurn compaction", () => {
const legacy = buildAfterTurnLegacyCompactionParams({
const legacy = buildAfterTurnRuntimeContext({
attempt: {
sessionKey: "agent:main:session:abc",
messageChannel: "slack",

View File

@@ -636,8 +636,8 @@ export function prependSystemPromptAddition(params: {
return `${params.systemPromptAddition}\n\n${params.systemPrompt}`;
}
/** Build legacy compaction params passed into context-engine afterTurn hooks. */
export function buildAfterTurnLegacyCompactionParams(params: {
/** Build runtime context passed into context-engine afterTurn hooks. */
export function buildAfterTurnRuntimeContext(params: {
attempt: Pick<
EmbeddedRunAttemptParams,
| "sessionKey"
@@ -1884,7 +1884,7 @@ export async function runEmbeddedAttempt(
// Let the active context engine run its post-turn lifecycle.
if (params.contextEngine) {
const afterTurnLegacyCompactionParams = buildAfterTurnLegacyCompactionParams({
const afterTurnRuntimeContext = buildAfterTurnRuntimeContext({
attempt: params,
workspaceDir: effectiveWorkspace,
agentDir,
@@ -1898,7 +1898,7 @@ export async function runEmbeddedAttempt(
messages: messagesSnapshot,
prePromptMessageCount,
tokenBudget: params.contextTokenBudget,
legacyCompactionParams: afterTurnLegacyCompactionParams,
runtimeContext: afterTurnRuntimeContext,
});
} catch (afterTurnErr) {
log.warn(`context engine afterTurn failed: ${String(afterTurnErr)}`);

View File

@@ -1,9 +1,12 @@
import { describe, expect, it } from "vitest";
import {
isAnthropicProviderFamily,
isOpenAiProviderFamily,
requiresOpenAiCompatibleAnthropicToolPayload,
resolveProviderCapabilities,
resolveTranscriptToolCallIdMode,
sanitizesGeminiThoughtSignatures,
shouldDropThinkingBlocksForModel,
shouldSanitizeGeminiThoughtSignaturesForModel,
supportsOpenAiCompatTurnValidation,
} from "./provider-capabilities.js";
@@ -12,10 +15,14 @@ describe("resolveProviderCapabilities", () => {
expect(resolveProviderCapabilities("anthropic")).toEqual({
anthropicToolSchemaMode: "native",
anthropicToolChoiceMode: "native",
providerFamily: "anthropic",
preserveAnthropicThinkingSignatures: true,
openAiCompatTurnValidation: true,
geminiThoughtSignatureSanitization: false,
transcriptToolCallIdMode: "default",
transcriptToolCallIdModelHints: [],
geminiThoughtSignatureModelHints: [],
dropThinkingBlockModelHints: [],
});
});
@@ -26,10 +33,14 @@ describe("resolveProviderCapabilities", () => {
expect(resolveProviderCapabilities("kimi-code")).toEqual({
anthropicToolSchemaMode: "openai-functions",
anthropicToolChoiceMode: "openai-string-modes",
providerFamily: "default",
preserveAnthropicThinkingSignatures: false,
openAiCompatTurnValidation: true,
geminiThoughtSignatureSanitization: false,
transcriptToolCallIdMode: "default",
transcriptToolCallIdModelHints: [],
geminiThoughtSignatureModelHints: [],
dropThinkingBlockModelHints: [],
});
});
@@ -40,9 +51,19 @@ describe("resolveProviderCapabilities", () => {
});
it("resolves transcript thought-signature and tool-call quirks through the registry", () => {
expect(sanitizesGeminiThoughtSignatures("openrouter")).toBe(true);
expect(sanitizesGeminiThoughtSignatures("kilocode")).toBe(true);
expect(resolveTranscriptToolCallIdMode("mistral")).toBe("strict9");
expect(
shouldSanitizeGeminiThoughtSignaturesForModel({
provider: "openrouter",
modelId: "google/gemini-2.5-pro-preview",
}),
).toBe(true);
expect(
shouldSanitizeGeminiThoughtSignaturesForModel({
provider: "kilocode",
modelId: "gemini-2.0-flash",
}),
).toBe(true);
expect(resolveTranscriptToolCallIdMode("mistral", "mistral-large-latest")).toBe("strict9");
});
it("treats kimi aliases as anthropic tool payload compatibility providers", () => {
@@ -50,4 +71,15 @@ describe("resolveProviderCapabilities", () => {
expect(requiresOpenAiCompatibleAnthropicToolPayload("kimi-code")).toBe(true);
expect(requiresOpenAiCompatibleAnthropicToolPayload("anthropic")).toBe(false);
});
it("tracks provider families and model-specific transcript quirks in the registry", () => {
expect(isOpenAiProviderFamily("openai")).toBe(true);
expect(isAnthropicProviderFamily("amazon-bedrock")).toBe(true);
expect(
shouldDropThinkingBlocksForModel({
provider: "github-copilot",
modelId: "claude-3.7-sonnet",
}),
).toBe(true);
});
});

View File

@@ -3,22 +3,36 @@ import { normalizeProviderId } from "./model-selection.js";
export type ProviderCapabilities = {
anthropicToolSchemaMode: "native" | "openai-functions";
anthropicToolChoiceMode: "native" | "openai-string-modes";
providerFamily: "default" | "openai" | "anthropic";
preserveAnthropicThinkingSignatures: boolean;
openAiCompatTurnValidation: boolean;
geminiThoughtSignatureSanitization: boolean;
transcriptToolCallIdMode: "default" | "strict9";
transcriptToolCallIdModelHints: string[];
geminiThoughtSignatureModelHints: string[];
dropThinkingBlockModelHints: string[];
};
const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = {
anthropicToolSchemaMode: "native",
anthropicToolChoiceMode: "native",
providerFamily: "default",
preserveAnthropicThinkingSignatures: true,
openAiCompatTurnValidation: true,
geminiThoughtSignatureSanitization: false,
transcriptToolCallIdMode: "default",
transcriptToolCallIdModelHints: [],
geminiThoughtSignatureModelHints: [],
dropThinkingBlockModelHints: [],
};
const PROVIDER_CAPABILITIES: Record<string, Partial<ProviderCapabilities>> = {
anthropic: {
providerFamily: "anthropic",
},
"amazon-bedrock": {
providerFamily: "anthropic",
},
"kimi-coding": {
anthropicToolSchemaMode: "openai-functions",
anthropicToolChoiceMode: "openai-string-modes",
@@ -26,17 +40,38 @@ const PROVIDER_CAPABILITIES: Record<string, Partial<ProviderCapabilities>> = {
},
mistral: {
transcriptToolCallIdMode: "strict9",
transcriptToolCallIdModelHints: [
"mistral",
"mixtral",
"codestral",
"pixtral",
"devstral",
"ministral",
"mistralai",
],
},
openai: {
providerFamily: "openai",
},
"openai-codex": {
providerFamily: "openai",
},
openrouter: {
openAiCompatTurnValidation: false,
geminiThoughtSignatureSanitization: true,
geminiThoughtSignatureModelHints: ["gemini"],
},
opencode: {
openAiCompatTurnValidation: false,
geminiThoughtSignatureSanitization: true,
geminiThoughtSignatureModelHints: ["gemini"],
},
kilocode: {
geminiThoughtSignatureSanitization: true,
geminiThoughtSignatureModelHints: ["gemini"],
},
"github-copilot": {
dropThinkingBlockModelHints: ["claude"],
},
};
@@ -76,7 +111,51 @@ export function sanitizesGeminiThoughtSignatures(provider?: string | null): bool
return resolveProviderCapabilities(provider).geminiThoughtSignatureSanitization;
}
export function resolveTranscriptToolCallIdMode(provider?: string | null): "strict9" | undefined {
const mode = resolveProviderCapabilities(provider).transcriptToolCallIdMode;
return mode === "strict9" ? mode : undefined;
function modelIncludesAnyHint(modelId: string | null | undefined, hints: string[]): boolean {
const normalized = (modelId ?? "").toLowerCase();
return Boolean(normalized) && hints.some((hint) => normalized.includes(hint));
}
export function isOpenAiProviderFamily(provider?: string | null): boolean {
return resolveProviderCapabilities(provider).providerFamily === "openai";
}
export function isAnthropicProviderFamily(provider?: string | null): boolean {
return resolveProviderCapabilities(provider).providerFamily === "anthropic";
}
export function shouldDropThinkingBlocksForModel(params: {
provider?: string | null;
modelId?: string | null;
}): boolean {
return modelIncludesAnyHint(
params.modelId,
resolveProviderCapabilities(params.provider).dropThinkingBlockModelHints,
);
}
export function shouldSanitizeGeminiThoughtSignaturesForModel(params: {
provider?: string | null;
modelId?: string | null;
}): boolean {
const capabilities = resolveProviderCapabilities(params.provider);
return (
capabilities.geminiThoughtSignatureSanitization &&
modelIncludesAnyHint(params.modelId, capabilities.geminiThoughtSignatureModelHints)
);
}
export function resolveTranscriptToolCallIdMode(
provider?: string | null,
modelId?: string | null,
): "strict9" | undefined {
const capabilities = resolveProviderCapabilities(provider);
const mode = capabilities.transcriptToolCallIdMode;
if (mode === "strict9") {
return mode;
}
if (modelIncludesAnyHint(modelId, capabilities.transcriptToolCallIdModelHints)) {
return "strict9";
}
return undefined;
}

View File

@@ -27,6 +27,11 @@ const {
const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_");
const moonshotApiKeyEnv = ["MOONSHOT_API", "KEY"].join("_");
const openRouterApiKeyEnv = ["OPENROUTER_API", "KEY"].join("_");
const perplexityApiKeyEnv = ["PERPLEXITY_API", "KEY"].join("_");
const openRouterPerplexityApiKey = ["sk", "or", "v1", "test"].join("-");
const directPerplexityApiKey = ["pplx", "test"].join("-");
const enterprisePerplexityApiKey = ["enterprise", "perplexity", "test"].join("-");
describe("web_search perplexity compatibility routing", () => {
it("detects API key prefixes", () => {
@@ -42,27 +47,33 @@ describe("web_search perplexity compatibility routing", () => {
});
it("resolves OpenRouter env auth and transport", () => {
withEnv({ PERPLEXITY_API_KEY: undefined, OPENROUTER_API_KEY: "sk-or-v1-test" }, () => {
expect(resolvePerplexityApiKey(undefined)).toEqual({
apiKey: "sk-or-v1-test",
source: "openrouter_env",
});
expect(resolvePerplexityTransport(undefined)).toMatchObject({
baseUrl: "https://openrouter.ai/api/v1",
model: "perplexity/sonar-pro",
transport: "chat_completions",
});
});
withEnv(
{ [perplexityApiKeyEnv]: undefined, [openRouterApiKeyEnv]: openRouterPerplexityApiKey },
() => {
expect(resolvePerplexityApiKey(undefined)).toEqual({
apiKey: openRouterPerplexityApiKey,
source: "openrouter_env",
});
expect(resolvePerplexityTransport(undefined)).toMatchObject({
baseUrl: "https://openrouter.ai/api/v1",
model: "perplexity/sonar-pro",
transport: "chat_completions",
});
},
);
});
it("uses native Search API for direct Perplexity when no legacy overrides exist", () => {
withEnv({ PERPLEXITY_API_KEY: "pplx-test", OPENROUTER_API_KEY: undefined }, () => {
expect(resolvePerplexityTransport(undefined)).toMatchObject({
baseUrl: "https://api.perplexity.ai",
model: "perplexity/sonar-pro",
transport: "search_api",
});
});
withEnv(
{ [perplexityApiKeyEnv]: directPerplexityApiKey, [openRouterApiKeyEnv]: undefined },
() => {
expect(resolvePerplexityTransport(undefined)).toMatchObject({
baseUrl: "https://api.perplexity.ai",
model: "perplexity/sonar-pro",
transport: "search_api",
});
},
);
});
it("switches direct Perplexity to chat completions when model override is configured", () => {
@@ -71,7 +82,7 @@ describe("web_search perplexity compatibility routing", () => {
);
expect(
resolvePerplexityTransport({
apiKey: "pplx-test",
apiKey: directPerplexityApiKey,
model: "perplexity/sonar-reasoning-pro",
}),
).toMatchObject({
@@ -84,7 +95,7 @@ describe("web_search perplexity compatibility routing", () => {
it("treats unrecognized configured keys as direct Perplexity by default", () => {
expect(
resolvePerplexityTransport({
apiKey: "enterprise-perplexity-test",
apiKey: enterprisePerplexityApiKey,
}),
).toMatchObject({
baseUrl: "https://api.perplexity.ai",

View File

@@ -667,8 +667,8 @@ function inferPerplexityBaseUrlFromApiKey(apiKey?: string): PerplexityBaseUrlHin
function resolvePerplexityBaseUrl(
perplexity?: PerplexityConfig,
apiKeySource: PerplexityApiKeySource = "none",
apiKey?: string,
authSource: PerplexityApiKeySource = "none", // pragma: allowlist secret
configuredKey?: string,
): string {
const fromConfig =
perplexity && "baseUrl" in perplexity && typeof perplexity.baseUrl === "string"
@@ -677,14 +677,14 @@ function resolvePerplexityBaseUrl(
if (fromConfig) {
return fromConfig;
}
if (apiKeySource === "perplexity_env") {
if (authSource === "perplexity_env") {
return PERPLEXITY_DIRECT_BASE_URL;
}
if (apiKeySource === "openrouter_env") {
if (authSource === "openrouter_env") {
return DEFAULT_PERPLEXITY_BASE_URL;
}
if (apiKeySource === "config") {
const inferred = inferPerplexityBaseUrlFromApiKey(apiKey);
if (authSource === "config") {
const inferred = inferPerplexityBaseUrlFromApiKey(configuredKey);
if (inferred === "openrouter") {
return DEFAULT_PERPLEXITY_BASE_URL;
}

View File

@@ -1,9 +1,12 @@
import { normalizeProviderId } from "./model-selection.js";
import { isGoogleModelApi } from "./pi-embedded-helpers/google.js";
import {
isAnthropicProviderFamily,
isOpenAiProviderFamily,
preservesAnthropicThinkingSignatures,
resolveTranscriptToolCallIdMode,
sanitizesGeminiThoughtSignatures,
shouldDropThinkingBlocksForModel,
shouldSanitizeGeminiThoughtSignaturesForModel,
supportsOpenAiCompatTurnValidation,
} from "./provider-capabilities.js";
import type { ToolCallIdMode } from "./tool-call-id.js";
@@ -28,22 +31,12 @@ export type TranscriptPolicy = {
allowSyntheticToolResults: boolean;
};
const MISTRAL_MODEL_HINTS = [
"mistral",
"mixtral",
"codestral",
"pixtral",
"devstral",
"ministral",
"mistralai",
];
const OPENAI_MODEL_APIS = new Set([
"openai",
"openai-completions",
"openai-responses",
"openai-codex-responses",
]);
const OPENAI_PROVIDERS = new Set(["openai", "openai-codex"]);
function isOpenAiApi(modelApi?: string | null): boolean {
if (!modelApi) {
@@ -53,41 +46,15 @@ function isOpenAiApi(modelApi?: string | null): boolean {
}
function isOpenAiProvider(provider?: string | null): boolean {
if (!provider) {
return false;
}
return OPENAI_PROVIDERS.has(normalizeProviderId(provider));
return isOpenAiProviderFamily(provider);
}
function isAnthropicApi(modelApi?: string | null, provider?: string | null): boolean {
if (modelApi === "anthropic-messages" || modelApi === "bedrock-converse-stream") {
return true;
}
const normalized = normalizeProviderId(provider ?? "");
// MiniMax now uses openai-completions API, not anthropic-messages
return normalized === "anthropic" || normalized === "amazon-bedrock";
}
function isMistralModel(modelId?: string | null): boolean {
const normalizedModelId = (modelId ?? "").toLowerCase();
if (!normalizedModelId) {
return false;
}
return MISTRAL_MODEL_HINTS.some((hint) => normalizedModelId.includes(hint));
}
function shouldSanitizeGeminiThoughtSignatures(params: {
provider?: string | null;
modelId?: string | null;
}): boolean {
if (!sanitizesGeminiThoughtSignatures(params.provider)) {
return false;
}
const modelId = (params.modelId ?? "").toLowerCase();
if (!modelId) {
return false;
}
return modelId.includes("gemini");
return isAnthropicProviderFamily(provider);
}
export function resolveTranscriptPolicy(params: {
@@ -104,19 +71,19 @@ export function resolveTranscriptPolicy(params: {
params.modelApi === "openai-completions" &&
!isOpenAi &&
supportsOpenAiCompatTurnValidation(provider);
const providerToolCallIdMode = resolveTranscriptToolCallIdMode(provider);
const isMistral = providerToolCallIdMode === "strict9" || isMistralModel(modelId);
const shouldSanitizeGeminiThoughtSignaturesForProvider = shouldSanitizeGeminiThoughtSignatures({
provider,
modelId,
});
const isCopilotClaude = provider === "github-copilot" && modelId.toLowerCase().includes("claude");
const providerToolCallIdMode = resolveTranscriptToolCallIdMode(provider, modelId);
const isMistral = providerToolCallIdMode === "strict9";
const shouldSanitizeGeminiThoughtSignaturesForProvider =
shouldSanitizeGeminiThoughtSignaturesForModel({
provider,
modelId,
});
const requiresOpenAiCompatibleToolIdSanitization = params.modelApi === "openai-completions";
// GitHub Copilot's Claude endpoints can reject persisted `thinking` blocks with
// non-binary/non-base64 signatures (e.g. thinkingSignature: "reasoning_text").
// Drop these blocks at send-time to keep sessions usable.
const dropThinkingBlocks = isCopilotClaude;
const dropThinkingBlocks = shouldDropThinkingBlocksForModel({ provider, modelId });
const needsNonImageSanitize =
isGoogle || isAnthropic || isMistral || shouldSanitizeGeminiThoughtSignaturesForProvider;

View File

@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { getAcpSessionManager } from "../../../acp/control-plane/manager.js";
import { resolveAcpSessionResolutionError } from "../../../acp/control-plane/manager.utils.js";
import {
cleanupFailedAcpSpawn,
type AcpSpawnRuntimeCloseHandle,
@@ -10,7 +11,6 @@ import {
resolveAcpDispatchPolicyError,
resolveAcpDispatchPolicyMessage,
} from "../../../acp/policy.js";
import { AcpRuntimeError } from "../../../acp/runtime/errors.js";
import {
resolveAcpSessionCwd,
resolveAcpThreadSessionDetailLines,
@@ -390,24 +390,13 @@ function resolveAcpSessionForCommandOrStop(params: {
cfg: params.cfg,
sessionKey: params.sessionKey,
});
if (resolved.kind === "none") {
const error = resolveAcpSessionResolutionError(resolved);
if (error) {
return stopWithText(
collectAcpErrorText({
error: new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
`Session is not ACP-enabled: ${params.sessionKey}`,
),
error,
fallbackCode: "ACP_SESSION_INIT_FAILED",
fallbackMessage: "Session is not ACP-enabled.",
}),
);
}
if (resolved.kind === "stale") {
return stopWithText(
collectAcpErrorText({
error: resolved.error,
fallbackCode: "ACP_SESSION_INIT_FAILED",
fallbackMessage: resolved.error.message,
fallbackMessage: error.message,
}),
);
}

View File

@@ -1,5 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { appendCdpPath, getHeadersWithAuth } from "./cdp.helpers.js";
import {
appendCdpPath,
getHeadersWithAuth,
normalizeCdpHttpBaseForJsonEndpoints,
} from "./cdp.helpers.js";
import { __test } from "./client-fetch.js";
import { resolveBrowserConfig, resolveProfile } from "./config.js";
import { shouldRejectBrowserMutation } from "./csrf.js";
@@ -155,6 +159,18 @@ describe("cdp.helpers", () => {
expect(url).toBe("https://example.com/chrome/json/list?token=abc");
});
it("normalizes direct WebSocket CDP URLs to an HTTP base for /json endpoints", () => {
const url = normalizeCdpHttpBaseForJsonEndpoints(
"wss://connect.example.com/devtools/browser/ABC?token=abc",
);
expect(url).toBe("https://connect.example.com/?token=abc");
});
it("strips a trailing /cdp suffix when normalizing HTTP bases", () => {
const url = normalizeCdpHttpBaseForJsonEndpoints("ws://127.0.0.1:9222/cdp?token=abc");
expect(url).toBe("http://127.0.0.1:9222/?token=abc");
});
it("adds basic auth headers when credentials are present", () => {
const headers = getHeadersWithAuth("https://user:pass@example.com");
expect(headers.Authorization).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`);

View File

@@ -7,6 +7,20 @@ import { getChromeExtensionRelayAuthHeaders } from "./extension-relay.js";
export { isLoopbackHost };
/**
* Returns true when the URL uses a WebSocket protocol (ws: or wss:).
* Used to distinguish direct-WebSocket CDP endpoints
* from HTTP(S) endpoints that require /json/version discovery.
*/
export function isWebSocketUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "ws:" || parsed.protocol === "wss:";
} catch {
return false;
}
}
type CdpResponse = {
id: number;
result?: unknown;
@@ -53,6 +67,28 @@ export function appendCdpPath(cdpUrl: string, path: string): string {
return url.toString();
}
export function normalizeCdpHttpBaseForJsonEndpoints(cdpUrl: string): string {
try {
const url = new URL(cdpUrl);
if (url.protocol === "ws:") {
url.protocol = "http:";
} else if (url.protocol === "wss:") {
url.protocol = "https:";
}
url.pathname = url.pathname.replace(/\/devtools\/browser\/.*$/, "");
url.pathname = url.pathname.replace(/\/cdp$/, "");
return url.toString().replace(/\/$/, "");
} catch {
// Best-effort fallback for non-URL-ish inputs.
return cdpUrl
.replace(/^ws:/, "http:")
.replace(/^wss:/, "https:")
.replace(/\/devtools\/browser\/.*$/, "")
.replace(/\/cdp$/, "")
.replace(/\/$/, "");
}
}
function createCdpSender(ws: WebSocket) {
let nextId = 1;
const pending = new Map<number, Pending>();

View File

@@ -3,7 +3,9 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { type WebSocket, WebSocketServer } from "ws";
import { SsrFBlockedError } from "../infra/net/ssrf.js";
import { rawDataToString } from "../infra/ws.js";
import { isWebSocketUrl } from "./cdp.helpers.js";
import { createTargetViaCdp, evaluateJavaScript, normalizeCdpWsUrl, snapshotAria } from "./cdp.js";
import { parseHttpUrl } from "./config.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
describe("cdp", () => {
@@ -95,6 +97,79 @@ describe("cdp", () => {
expect(created.targetId).toBe("TARGET_123");
});
it("creates a target via direct WebSocket URL (skips /json/version)", async () => {
const wsPort = await startWsServerWithMessages((msg, socket) => {
if (msg.method !== "Target.createTarget") {
return;
}
socket.send(
JSON.stringify({
id: msg.id,
result: { targetId: "TARGET_WS_DIRECT" },
}),
);
});
const fetchSpy = vi.spyOn(globalThis, "fetch");
try {
const created = await createTargetViaCdp({
cdpUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST`,
url: "https://example.com",
});
expect(created.targetId).toBe("TARGET_WS_DIRECT");
// /json/version should NOT have been called — direct WS skips HTTP discovery
expect(fetchSpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
});
it("preserves query params when connecting via direct WebSocket URL", async () => {
let receivedHeaders: Record<string, string> = {};
const wsPort = await startWsServer();
if (!wsServer) {
throw new Error("ws server not initialized");
}
wsServer.on("headers", (headers, req) => {
receivedHeaders = Object.fromEntries(
Object.entries(req.headers).map(([k, v]) => [k, String(v)]),
);
});
wsServer.on("connection", (socket) => {
socket.on("message", (data) => {
const msg = JSON.parse(rawDataToString(data)) as { id?: number; method?: string };
if (msg.method === "Target.createTarget") {
socket.send(JSON.stringify({ id: msg.id, result: { targetId: "T_QP" } }));
}
});
});
const created = await createTargetViaCdp({
cdpUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST?apiKey=secret123`,
url: "https://example.com",
});
expect(created.targetId).toBe("T_QP");
// The WebSocket upgrade request should have been made to the URL with the query param
expect(receivedHeaders.host).toBe(`127.0.0.1:${wsPort}`);
});
it("still enforces SSRF policy for direct WebSocket URLs", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
try {
await expect(
createTargetViaCdp({
cdpUrl: "ws://127.0.0.1:9222",
url: "http://127.0.0.1:8080",
}),
).rejects.toBeInstanceOf(SsrFBlockedError);
// SSRF check happens before any connection attempt
expect(fetchSpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
});
it("blocks private navigation targets by default", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
try {
@@ -253,3 +328,58 @@ describe("cdp", () => {
expect(normalized).toBe("wss://production-sfo.browserless.io/?token=abc");
});
});
describe("isWebSocketUrl", () => {
it("returns true for ws:// URLs", () => {
expect(isWebSocketUrl("ws://127.0.0.1:9222")).toBe(true);
expect(isWebSocketUrl("ws://example.com/devtools/browser/ABC")).toBe(true);
});
it("returns true for wss:// URLs", () => {
expect(isWebSocketUrl("wss://connect.example.com")).toBe(true);
expect(isWebSocketUrl("wss://connect.example.com?apiKey=abc")).toBe(true);
});
it("returns false for http:// and https:// URLs", () => {
expect(isWebSocketUrl("http://127.0.0.1:9222")).toBe(false);
expect(isWebSocketUrl("https://production-sfo.browserless.io?token=abc")).toBe(false);
});
it("returns false for invalid or non-URL strings", () => {
expect(isWebSocketUrl("not-a-url")).toBe(false);
expect(isWebSocketUrl("")).toBe(false);
expect(isWebSocketUrl("ftp://example.com")).toBe(false);
});
});
describe("parseHttpUrl with WebSocket protocols", () => {
it("accepts wss:// URLs and defaults to port 443", () => {
const result = parseHttpUrl("wss://connect.example.com?apiKey=abc", "test");
expect(result.parsed.protocol).toBe("wss:");
expect(result.port).toBe(443);
expect(result.normalized).toContain("wss://connect.example.com");
});
it("accepts ws:// URLs and defaults to port 80", () => {
const result = parseHttpUrl("ws://127.0.0.1/devtools", "test");
expect(result.parsed.protocol).toBe("ws:");
expect(result.port).toBe(80);
});
it("preserves explicit ports in wss:// URLs", () => {
const result = parseHttpUrl("wss://connect.example.com:8443/path", "test");
expect(result.port).toBe(8443);
});
it("still accepts http:// and https:// URLs", () => {
const http = parseHttpUrl("http://127.0.0.1:9222", "test");
expect(http.port).toBe(9222);
const https = parseHttpUrl("https://browserless.example?token=abc", "test");
expect(https.port).toBe(443);
});
it("rejects unsupported protocols", () => {
expect(() => parseHttpUrl("ftp://example.com", "test")).toThrow("must be http(s) or ws(s)");
expect(() => parseHttpUrl("file:///etc/passwd", "test")).toThrow("must be http(s) or ws(s)");
});
});

View File

@@ -1,8 +1,20 @@
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { appendCdpPath, fetchJson, isLoopbackHost, withCdpSocket } from "./cdp.helpers.js";
import {
appendCdpPath,
fetchJson,
isLoopbackHost,
isWebSocketUrl,
withCdpSocket,
} from "./cdp.helpers.js";
import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js";
export { appendCdpPath, fetchJson, fetchOk, getHeadersWithAuth } from "./cdp.helpers.js";
export {
appendCdpPath,
fetchJson,
fetchOk,
getHeadersWithAuth,
isWebSocketUrl,
} from "./cdp.helpers.js";
export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
const ws = new URL(wsUrl);
@@ -94,14 +106,21 @@ export async function createTargetViaCdp(opts: {
...withBrowserNavigationPolicy(opts.ssrfPolicy),
});
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
appendCdpPath(opts.cdpUrl, "/json/version"),
1500,
);
const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim();
const wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";
if (!wsUrl) {
throw new Error("CDP /json/version missing webSocketDebuggerUrl");
let wsUrl: string;
if (isWebSocketUrl(opts.cdpUrl)) {
// Direct WebSocket URL — skip /json/version discovery.
wsUrl = opts.cdpUrl;
} else {
// Standard HTTP(S) CDP endpoint — discover WebSocket URL via /json/version.
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
appendCdpPath(opts.cdpUrl, "/json/version"),
1500,
);
const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim();
wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";
if (!wsUrl) {
throw new Error("CDP /json/version missing webSocketDebuggerUrl");
}
}
return await withCdpSocket(wsUrl, async (send) => {

View File

@@ -350,6 +350,16 @@ describe("browser chrome helpers", () => {
});
});
it("probes WebSocket URLs via handshake instead of HTTP", async () => {
// For ws:// URLs, isChromeReachable should NOT call fetch at all —
// it should attempt a WebSocket handshake instead.
const fetchSpy = vi.fn().mockRejectedValue(new Error("should not be called"));
vi.stubGlobal("fetch", fetchSpy);
// No WS server listening → handshake fails → not reachable
await expect(isChromeReachable("ws://127.0.0.1:19999", 50)).resolves.toBe(false);
expect(fetchSpy).not.toHaveBeenCalled();
});
it("stopOpenClawChrome no-ops when process is already killed", async () => {
const proc = makeChromeTestProc({ killed: true });
await stopChromeWithProc(proc, 10);

View File

@@ -17,7 +17,7 @@ import {
CHROME_STOP_TIMEOUT_MS,
CHROME_WS_READY_TIMEOUT_MS,
} from "./cdp-timeouts.js";
import { appendCdpPath, fetchCdpChecked, openCdpWebSocket } from "./cdp.helpers.js";
import { appendCdpPath, fetchCdpChecked, isWebSocketUrl, openCdpWebSocket } from "./cdp.helpers.js";
import { normalizeCdpWsUrl } from "./cdp.js";
import {
type BrowserExecutable,
@@ -78,10 +78,29 @@ function cdpUrlForPort(cdpPort: number) {
return `http://127.0.0.1:${cdpPort}`;
}
async function canOpenWebSocket(url: string, timeoutMs: number): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const ws = openCdpWebSocket(url, { handshakeTimeoutMs: timeoutMs });
ws.once("open", () => {
try {
ws.close();
} catch {
// ignore
}
resolve(true);
});
ws.once("error", () => resolve(false));
});
}
export async function isChromeReachable(
cdpUrl: string,
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
): Promise<boolean> {
if (isWebSocketUrl(cdpUrl)) {
// Direct WebSocket endpoint — probe via WS handshake.
return await canOpenWebSocket(cdpUrl, timeoutMs);
}
const version = await fetchChromeVersion(cdpUrl, timeoutMs);
return Boolean(version);
}
@@ -117,6 +136,10 @@ export async function getChromeWebSocketUrl(
cdpUrl: string,
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
): Promise<string | null> {
if (isWebSocketUrl(cdpUrl)) {
// Direct WebSocket endpoint — the cdpUrl is already the WebSocket URL.
return cdpUrl;
}
const version = await fetchChromeVersion(cdpUrl, timeoutMs);
const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim();
if (!wsUrl) {

View File

@@ -165,8 +165,21 @@ describe("browser config", () => {
expect(work?.cdpUrl).toBe("https://example.com:18801");
});
it("preserves wss:// cdpUrl with query params for the default profile", () => {
const resolved = resolveBrowserConfig({
cdpUrl: "wss://connect.browserbase.com?apiKey=test-key",
});
const profile = resolveProfile(resolved, "openclaw");
expect(profile?.cdpUrl).toBe("wss://connect.browserbase.com/?apiKey=test-key");
expect(profile?.cdpHost).toBe("connect.browserbase.com");
expect(profile?.cdpPort).toBe(443);
expect(profile?.cdpIsLoopback).toBe(false);
});
it("rejects unsupported protocols", () => {
expect(() => resolveBrowserConfig({ cdpUrl: "ws://127.0.0.1:18791" })).toThrow(/must be http/i);
expect(() => resolveBrowserConfig({ cdpUrl: "ftp://127.0.0.1:18791" })).toThrow(
"must be http(s) or ws(s)",
);
});
it("does not add the built-in chrome extension profile if the derived relay port is already used", () => {

View File

@@ -129,14 +129,16 @@ function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy |
export function parseHttpUrl(raw: string, label: string) {
const trimmed = raw.trim();
const parsed = new URL(trimmed);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`${label} must be http(s), got: ${parsed.protocol.replace(":", "")}`);
const allowed = ["http:", "https:", "ws:", "wss:"];
if (!allowed.includes(parsed.protocol)) {
throw new Error(`${label} must be http(s) or ws(s), got: ${parsed.protocol.replace(":", "")}`);
}
const isSecure = parsed.protocol === "https:" || parsed.protocol === "wss:";
const port =
parsed.port && Number.parseInt(parsed.port, 10) > 0
? Number.parseInt(parsed.port, 10)
: parsed.protocol === "https:"
: isSecure
? 443
: 80;
@@ -160,12 +162,17 @@ function ensureDefaultProfile(
defaultColor: string,
legacyCdpPort?: number,
derivedDefaultCdpPort?: number,
legacyCdpUrl?: string,
): Record<string, BrowserProfileConfig> {
const result = { ...profiles };
if (!result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]) {
result[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME] = {
cdpPort: legacyCdpPort ?? derivedDefaultCdpPort ?? CDP_PORT_RANGE_START,
color: defaultColor,
// Preserve the full cdpUrl for ws/wss endpoints so resolveProfile()
// doesn't reconstruct from cdpProtocol/cdpHost/cdpPort (which drops
// the WebSocket protocol and query params like API keys).
...(legacyCdpUrl ? { cdpUrl: legacyCdpUrl } : {}),
};
}
return result;
@@ -258,8 +265,16 @@ export function resolveBrowserConfig(
const defaultProfileFromConfig = cfg?.defaultProfile?.trim() || undefined;
// Use legacy cdpUrl port for backward compatibility when no profiles configured
const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined;
const isWsUrl = cdpInfo.parsed.protocol === "ws:" || cdpInfo.parsed.protocol === "wss:";
const legacyCdpUrl = rawCdpUrl && isWsUrl ? cdpInfo.normalized : undefined;
const profiles = ensureDefaultChromeExtensionProfile(
ensureDefaultProfile(cfg?.profiles, defaultColor, legacyCdpPort, cdpPortRangeStart),
ensureDefaultProfile(
cfg?.profiles,
defaultColor,
legacyCdpPort,
cdpPortRangeStart,
legacyCdpUrl,
),
controlPort,
);
const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http";

View File

@@ -10,7 +10,13 @@ import { chromium } from "playwright-core";
import { formatErrorMessage } from "../infra/errors.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js";
import { appendCdpPath, fetchJson, getHeadersWithAuth, withCdpSocket } from "./cdp.helpers.js";
import {
appendCdpPath,
fetchJson,
getHeadersWithAuth,
normalizeCdpHttpBaseForJsonEndpoints,
withCdpSocket,
} from "./cdp.helpers.js";
import { normalizeCdpWsUrl } from "./cdp.js";
import { getChromeWebSocketUrl } from "./chrome.js";
import {
@@ -546,28 +552,6 @@ export async function closePlaywrightBrowserConnection(): Promise<void> {
await cur.browser.close().catch(() => {});
}
function normalizeCdpHttpBaseForJsonEndpoints(cdpUrl: string): string {
try {
const url = new URL(cdpUrl);
if (url.protocol === "ws:") {
url.protocol = "http:";
} else if (url.protocol === "wss:") {
url.protocol = "https:";
}
url.pathname = url.pathname.replace(/\/devtools\/browser\/.*$/, "");
url.pathname = url.pathname.replace(/\/cdp$/, "");
return url.toString().replace(/\/$/, "");
} catch {
// Best-effort fallback for non-URL-ish inputs.
return cdpUrl
.replace(/^ws:/, "http:")
.replace(/^wss:/, "https:")
.replace(/\/devtools\/browser\/.*$/, "")
.replace(/\/cdp$/, "")
.replace(/\/$/, "");
}
}
function cdpSocketNeedsAttach(wsUrl: string): boolean {
try {
const pathname = new URL(wsUrl).pathname;

View File

@@ -0,0 +1,100 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import * as cdpModule from "./cdp.js";
import { createBrowserRouteContext } from "./server-context.js";
import { makeState, originalFetch } from "./server-context.remote-tab-ops.harness.js";
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
describe("browser server-context loopback direct WebSocket profiles", () => {
it("uses an HTTP /json/list base when opening tabs", async () => {
const createTargetViaCdp = vi
.spyOn(cdpModule, "createTargetViaCdp")
.mockResolvedValue({ targetId: "CREATED" });
const fetchMock = vi.fn(async (url: unknown) => {
const u = String(url);
expect(u).toBe("http://127.0.0.1:18800/json/list?token=abc");
return {
ok: true,
json: async () => [
{
id: "CREATED",
title: "New Tab",
url: "http://127.0.0.1:8080",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED",
type: "page",
},
],
} as unknown as Response;
});
global.fetch = withFetchPreconnect(fetchMock);
const state = makeState("openclaw");
state.resolved.profiles.openclaw = {
cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc",
color: "#FF4500",
};
const ctx = createBrowserRouteContext({ getState: () => state });
const openclaw = ctx.forProfile("openclaw");
const opened = await openclaw.openTab("http://127.0.0.1:8080");
expect(opened.targetId).toBe("CREATED");
expect(createTargetViaCdp).toHaveBeenCalledWith({
cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc",
url: "http://127.0.0.1:8080",
ssrfPolicy: { allowPrivateNetwork: true },
});
});
it("uses an HTTP /json base for focus and close", async () => {
const fetchMock = vi.fn(async (url: unknown) => {
const u = String(url);
if (u === "http://127.0.0.1:18800/json/list?token=abc") {
return {
ok: true,
json: async () => [
{
id: "T1",
title: "Tab 1",
url: "https://example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/T1",
type: "page",
},
],
} as unknown as Response;
}
if (u === "http://127.0.0.1:18800/json/activate/T1?token=abc") {
return { ok: true, json: async () => ({}) } as unknown as Response;
}
if (u === "http://127.0.0.1:18800/json/close/T1?token=abc") {
return { ok: true, json: async () => ({}) } as unknown as Response;
}
throw new Error(`unexpected fetch: ${u}`);
});
global.fetch = withFetchPreconnect(fetchMock);
const state = makeState("openclaw");
state.resolved.profiles.openclaw = {
cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc",
color: "#FF4500",
};
const ctx = createBrowserRouteContext({ getState: () => state });
const openclaw = ctx.forProfile("openclaw");
await openclaw.focusTab("T1");
await openclaw.closeTab("T1");
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:18800/json/activate/T1?token=abc",
expect.any(Object),
);
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:18800/json/close/T1?token=abc",
expect.any(Object),
);
});
});

View File

@@ -1,4 +1,4 @@
import { fetchOk } from "./cdp.helpers.js";
import { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js";
import { appendCdpPath } from "./cdp.js";
import type { ResolvedBrowserProfile } from "./config.js";
import type { PwAiModule } from "./pw-ai-module.js";
@@ -27,6 +27,8 @@ export function createProfileSelectionOps({
listTabs,
openTab,
}: SelectionDeps): SelectionOps {
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => {
await ensureBrowserAvailable();
const profileState = getProfileState();
@@ -122,7 +124,7 @@ export function createProfileSelectionOps({
}
}
await fetchOk(appendCdpPath(profile.cdpUrl, `/json/activate/${resolvedTargetId}`));
await fetchOk(appendCdpPath(cdpHttpBase, `/json/activate/${resolvedTargetId}`));
const profileState = getProfileState();
profileState.lastTargetId = resolvedTargetId;
};
@@ -144,7 +146,7 @@ export function createProfileSelectionOps({
}
}
await fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${resolvedTargetId}`));
await fetchOk(appendCdpPath(cdpHttpBase, `/json/close/${resolvedTargetId}`));
};
return {

View File

@@ -1,5 +1,5 @@
import { CDP_JSON_NEW_TIMEOUT_MS } from "./cdp-timeouts.js";
import { fetchJson, fetchOk } from "./cdp.helpers.js";
import { fetchJson, fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js";
import { appendCdpPath, createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js";
import type { ResolvedBrowserProfile } from "./config.js";
import {
@@ -58,6 +58,8 @@ export function createProfileTabOps({
state,
getProfileState,
}: TabOpsDeps): ProfileTabOps {
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
const listTabs = async (): Promise<BrowserTab[]> => {
// For remote profiles, use Playwright's persistent connection to avoid ephemeral sessions
if (!profile.cdpIsLoopback) {
@@ -82,7 +84,7 @@ export function createProfileTabOps({
webSocketDebuggerUrl?: string;
type?: string;
}>
>(appendCdpPath(profile.cdpUrl, "/json/list"));
>(appendCdpPath(cdpHttpBase, "/json/list"));
return raw
.map((t) => ({
targetId: t.id ?? "",
@@ -115,7 +117,7 @@ export function createProfileTabOps({
const candidates = pageTabs.filter((tab) => tab.targetId !== keepTargetId);
const excessCount = pageTabs.length - MANAGED_BROWSER_PAGE_TAB_LIMIT;
for (const tab of candidates.slice(0, excessCount)) {
void fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${tab.targetId}`)).catch(() => {
void fetchOk(appendCdpPath(cdpHttpBase, `/json/close/${tab.targetId}`)).catch(() => {
// best-effort cleanup only
});
}
@@ -180,7 +182,7 @@ export function createProfileTabOps({
}
const encoded = encodeURIComponent(url);
const endpointUrl = new URL(appendCdpPath(profile.cdpUrl, "/json/new"));
const endpointUrl = new URL(appendCdpPath(cdpHttpBase, "/json/new"));
await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });
const endpoint = endpointUrl.search
? (() => {

View File

@@ -110,7 +110,7 @@ describe("profile CRUD endpoints", () => {
const createBadRemote = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "badremote", cdpUrl: "ws://bad" }),
body: JSON.stringify({ name: "badremote", cdpUrl: "ftp://bad" }),
});
expect(createBadRemote.status).toBe(400);
const createBadRemoteBody = (await createBadRemote.json()) as { error: string };

View File

@@ -36,17 +36,16 @@ const renderGatewayPortHealthDiagnostics = vi.fn(() => ["diag: unhealthy port"])
const renderRestartDiagnostics = vi.fn(() => ["diag: unhealthy runtime"]);
const resolveGatewayPort = vi.fn(() => 18789);
const findGatewayPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []);
const probeGateway =
vi.fn<
(opts: {
url: string;
auth?: { token?: string; password?: string };
timeoutMs: number;
}) => Promise<{
ok: boolean;
configSnapshot: unknown;
}>
>();
const probeGateway = vi.fn<
(opts: {
url: string;
auth?: { token?: string; password?: string };
timeoutMs: number;
}) => Promise<{
ok: boolean;
configSnapshot: unknown;
}>
>();
const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true);
const loadConfig = vi.fn(() => ({}));

View File

@@ -7,6 +7,7 @@ import { AcpRuntimeError } from "../acp/runtime/errors.js";
import * as embeddedModule from "../agents/pi-embedded.js";
import type { OpenClawConfig } from "../config/config.js";
import * as configModule from "../config/config.js";
import { readSessionMessages } from "../gateway/session-utils.fs.js";
import { onAgentEvent } from "../infra/agent-events.js";
import type { RuntimeEnv } from "../runtime.js";
import { agentCommand } from "./agent.js";
@@ -133,6 +134,17 @@ async function withAcpSessionEnv(fn: () => Promise<void>) {
});
}
async function withAcpSessionEnvInfo(
fn: (env: { home: string; storePath: string }) => Promise<void>,
) {
await withTempHome(async (home) => {
const storePath = path.join(home, "sessions.json");
writeAcpSessionStore(storePath);
mockConfig(home, storePath);
await fn({ home, storePath });
});
}
function createRunTurnFromTextDeltas(chunks: string[]) {
return vi.fn(async (paramsUnknown: unknown) => {
const params = paramsUnknown as {
@@ -220,6 +232,62 @@ describe("agentCommand ACP runtime routing", () => {
});
});
it("persists ACP child session history to the transcript store", async () => {
await withAcpSessionEnvInfo(async ({ storePath }) => {
const runTurn = createRunTurnFromTextDeltas(["ACP_", "OK"]);
mockAcpManager({
runTurn: (params: unknown) => runTurn(params),
});
await agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime);
const persistedStore = JSON.parse(fs.readFileSync(storePath, "utf-8")) as Record<
string,
{ sessionFile?: string }
>;
const sessionFile = persistedStore["agent:codex:acp:test"]?.sessionFile;
const messages = readSessionMessages("acp-session-1", storePath, sessionFile);
expect(messages).toHaveLength(2);
expect(messages[0]).toMatchObject({
role: "user",
content: "ping",
});
expect(messages[1]).toMatchObject({
role: "assistant",
content: [{ type: "text", text: "ACP_OK" }],
});
});
});
it("preserves exact ACP transcript text without trimming whitespace", async () => {
await withAcpSessionEnvInfo(async ({ storePath }) => {
const runTurn = createRunTurnFromTextDeltas([" ACP_OK\n"]);
mockAcpManager({
runTurn: (params: unknown) => runTurn(params),
});
await agentCommand({ message: " ping\n", sessionKey: "agent:codex:acp:test" }, runtime);
const persistedStore = JSON.parse(fs.readFileSync(storePath, "utf-8")) as Record<
string,
{ sessionFile?: string }
>;
const sessionFile = persistedStore["agent:codex:acp:test"]?.sessionFile;
const messages = readSessionMessages("acp-session-1", storePath, sessionFile);
expect(messages).toHaveLength(2);
expect(messages[0]).toMatchObject({
role: "user",
content: " ping\n",
});
expect(messages[1]).toMatchObject({
role: "assistant",
content: [{ type: "text", text: " ACP_OK\n" }],
});
});
});
it("suppresses ACP NO_REPLY lead fragments before emitting assistant text", async () => {
await withAcpSessionEnv(async () => {
const { assistantEvents, stop } = subscribeAssistantEvents();

View File

@@ -1,6 +1,9 @@
import fs from "node:fs/promises";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import { getAcpSessionManager } from "../acp/control-plane/manager.js";
import { resolveAcpAgentPolicyError, resolveAcpDispatchPolicyError } from "../acp/policy.js";
import { toAcpRuntimeError } from "../acp/runtime/errors.js";
import { resolveAcpSessionCwd } from "../acp/runtime/session-identifiers.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
const log = createSubsystemLogger("commands/agent");
@@ -33,6 +36,7 @@ import {
resolveDefaultModelForAgent,
resolveThinkingDefault,
} from "../agents/model-selection.js";
import { prepareSessionManagerForRun } from "../agents/pi-embedded-runner/session-manager-init.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
import { getSkillsSnapshotVersion } from "../agents/skills/refresh.js";
@@ -65,15 +69,11 @@ import {
} from "../config/config.js";
import {
mergeSessionEntry,
parseSessionThreadInfo,
resolveAndPersistSessionFile,
resolveAgentIdFromSessionKey,
resolveSessionFilePath,
resolveSessionFilePathOptions,
resolveSessionTranscriptPath,
type SessionEntry,
updateSessionStore,
} from "../config/sessions.js";
import { resolveSessionTranscriptFile } from "../config/sessions/transcript.js";
import {
clearAgentRunContext,
emitAgentEvent,
@@ -86,6 +86,7 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { applyVerboseOverride } from "../sessions/level-overrides.js";
import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js";
import { resolveSendPolicy } from "../sessions/send-policy.js";
import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js";
import { resolveMessageChannel } from "../utils/message-channel.js";
import { deliverAgentCommandResult } from "./agent/delivery.js";
import { resolveAgentRunContext } from "./agent/run-context.js";
@@ -230,9 +231,92 @@ function createAcpVisibleTextAccumulator() {
finalize(): string {
return visibleText.trim();
},
finalizeRaw(): string {
return visibleText;
},
};
}
const ACP_TRANSCRIPT_USAGE = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
} as const;
async function persistAcpTurnTranscript(params: {
body: string;
finalText: string;
sessionId: string;
sessionKey: string;
sessionEntry: SessionEntry | undefined;
sessionStore?: Record<string, SessionEntry>;
storePath?: string;
sessionAgentId: string;
threadId?: string | number;
sessionCwd: string;
}): Promise<SessionEntry | undefined> {
const promptText = params.body;
const replyText = params.finalText;
if (!promptText && !replyText) {
return params.sessionEntry;
}
const { sessionFile, sessionEntry } = await resolveSessionTranscriptFile({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionEntry: params.sessionEntry,
sessionStore: params.sessionStore,
storePath: params.storePath,
agentId: params.sessionAgentId,
threadId: params.threadId,
});
const hadSessionFile = await fs
.access(sessionFile)
.then(() => true)
.catch(() => false);
const sessionManager = SessionManager.open(sessionFile);
await prepareSessionManagerForRun({
sessionManager,
sessionFile,
hadSessionFile,
sessionId: params.sessionId,
cwd: params.sessionCwd,
});
if (promptText) {
sessionManager.appendMessage({
role: "user",
content: promptText,
timestamp: Date.now(),
});
}
if (replyText) {
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "text", text: replyText }],
api: "openai-responses",
provider: "openclaw",
model: "acp-runtime",
usage: ACP_TRANSCRIPT_USAGE,
stopReason: "stop",
timestamp: Date.now(),
});
}
emitSessionTranscriptUpdate(sessionFile);
return sessionEntry;
}
function runAgentAttempt(params: {
providerOverride: string;
modelOverride: string;
@@ -421,8 +505,8 @@ async function prepareAgentCommandExecution(
opts: AgentCommandOpts & { senderIsOwner: boolean },
runtime: RuntimeEnv,
) {
const message = (opts.message ?? "").trim();
if (!message) {
const message = opts.message ?? "";
if (!message.trim()) {
throw new Error("Message (--message) is required");
}
const body = prependInternalEventContext(message, opts.internalEvents);
@@ -732,8 +816,29 @@ async function agentCommandInternal(
},
});
const finalTextRaw = visibleTextAccumulator.finalizeRaw();
const finalText = visibleTextAccumulator.finalize();
try {
sessionEntry = await persistAcpTurnTranscript({
body,
finalText: finalTextRaw,
sessionId,
sessionKey,
sessionEntry,
sessionStore,
storePath,
sessionAgentId,
threadId: opts.threadId,
sessionCwd: resolveAcpSessionCwd(acpResolution.meta) ?? workspaceDir,
});
} catch (error) {
log.warn(
`ACP transcript persistence failed for ${sessionKey}: ${error instanceof Error ? error.message : String(error)}`,
);
}
const normalizedFinalPayload = normalizeReplyPayload({
text: visibleTextAccumulator.finalize(),
text: finalText,
});
const payloads = normalizedFinalPayload ? [normalizedFinalPayload] : [];
const result = {
@@ -944,29 +1049,27 @@ async function agentCommandInternal(
});
}
}
const sessionPathOpts = resolveSessionFilePathOptions({
agentId: sessionAgentId,
storePath,
});
let sessionFile = resolveSessionFilePath(sessionId, sessionEntry, sessionPathOpts);
let sessionFile: string | undefined;
if (sessionStore && sessionKey) {
const threadIdFromSessionKey = parseSessionThreadInfo(sessionKey).threadId;
const fallbackSessionFile = !sessionEntry?.sessionFile
? resolveSessionTranscriptPath(
sessionId,
sessionAgentId,
opts.threadId ?? threadIdFromSessionKey,
)
: undefined;
const resolvedSessionFile = await resolveAndPersistSessionFile({
const resolvedSessionFile = await resolveSessionTranscriptFile({
sessionId,
sessionKey,
sessionStore,
storePath,
sessionEntry,
agentId: sessionPathOpts?.agentId,
sessionsDir: sessionPathOpts?.sessionsDir,
fallbackSessionFile,
agentId: sessionAgentId,
threadId: opts.threadId,
});
sessionFile = resolvedSessionFile.sessionFile;
sessionEntry = resolvedSessionFile.sessionEntry;
}
if (!sessionFile) {
const resolvedSessionFile = await resolveSessionTranscriptFile({
sessionId,
sessionKey: sessionKey ?? sessionId,
sessionEntry,
agentId: sessionAgentId,
threadId: opts.threadId,
});
sessionFile = resolvedSessionFile.sessionFile;
sessionEntry = resolvedSessionFile.sessionEntry;

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import {
formatConfigPath,
resolveConfigPathTarget,
stripUnknownConfigKeys,
} from "./doctor-config-analysis.js";
describe("doctor config analysis helpers", () => {
it("formats config paths predictably", () => {
expect(formatConfigPath([])).toBe("<root>");
expect(formatConfigPath(["channels", "slack", "accounts", 0, "token"])).toBe(
"channels.slack.accounts[0].token",
);
});
it("resolves nested config targets without throwing", () => {
const target = resolveConfigPathTarget(
{ channels: { slack: { accounts: [{ token: "x" }] } } },
["channels", "slack", "accounts", 0],
);
expect(target).toEqual({ token: "x" });
expect(resolveConfigPathTarget({ channels: null }, ["channels", "slack"])).toBeNull();
});
it("strips unknown config keys while keeping known values", () => {
const result = stripUnknownConfigKeys({
hooks: {},
unexpected: true,
} as never);
expect(result.removed).toContain("unexpected");
expect((result.config as Record<string, unknown>).unexpected).toBeUndefined();
expect((result.config as Record<string, unknown>).hooks).toEqual({});
});
});

View File

@@ -0,0 +1,152 @@
import path from "node:path";
import type { ZodIssue } from "zod";
import type { OpenClawConfig } from "../config/config.js";
import { CONFIG_PATH } from "../config/config.js";
import { OpenClawSchema } from "../config/zod-schema.js";
import { note } from "../terminal/note.js";
import { isRecord } from "../utils.js";
type UnrecognizedKeysIssue = ZodIssue & {
code: "unrecognized_keys";
keys: PropertyKey[];
};
function normalizeIssuePath(path: PropertyKey[]): Array<string | number> {
return path.filter((part): part is string | number => typeof part !== "symbol");
}
function isUnrecognizedKeysIssue(issue: ZodIssue): issue is UnrecognizedKeysIssue {
return issue.code === "unrecognized_keys";
}
export function formatConfigPath(parts: Array<string | number>): string {
if (parts.length === 0) {
return "<root>";
}
let out = "";
for (const part of parts) {
if (typeof part === "number") {
out += `[${part}]`;
continue;
}
out = out ? `${out}.${part}` : part;
}
return out || "<root>";
}
export function resolveConfigPathTarget(root: unknown, path: Array<string | number>): unknown {
let current: unknown = root;
for (const part of path) {
if (typeof part === "number") {
if (!Array.isArray(current)) {
return null;
}
if (part < 0 || part >= current.length) {
return null;
}
current = current[part];
continue;
}
if (!current || typeof current !== "object" || Array.isArray(current)) {
return null;
}
const record = current as Record<string, unknown>;
if (!(part in record)) {
return null;
}
current = record[part];
}
return current;
}
export function stripUnknownConfigKeys(config: OpenClawConfig): {
config: OpenClawConfig;
removed: string[];
} {
const parsed = OpenClawSchema.safeParse(config);
if (parsed.success) {
return { config, removed: [] };
}
const next = structuredClone(config);
const removed: string[] = [];
for (const issue of parsed.error.issues) {
if (!isUnrecognizedKeysIssue(issue)) {
continue;
}
const issuePath = normalizeIssuePath(issue.path);
const target = resolveConfigPathTarget(next, issuePath);
if (!target || typeof target !== "object" || Array.isArray(target)) {
continue;
}
const record = target as Record<string, unknown>;
for (const key of issue.keys) {
if (typeof key !== "string" || !(key in record)) {
continue;
}
delete record[key];
removed.push(formatConfigPath([...issuePath, key]));
}
}
return { config: next, removed };
}
export function noteOpencodeProviderOverrides(cfg: OpenClawConfig): void {
const providers = cfg.models?.providers;
if (!providers) {
return;
}
const overrides: string[] = [];
if (providers.opencode) {
overrides.push("opencode");
}
if (providers["opencode-zen"]) {
overrides.push("opencode-zen");
}
if (overrides.length === 0) {
return;
}
const lines = overrides.flatMap((id) => {
const providerEntry = providers[id];
const api =
isRecord(providerEntry) && typeof providerEntry.api === "string"
? providerEntry.api
: undefined;
return [
`- models.providers.${id} is set; this overrides the built-in OpenCode Zen catalog.`,
api ? `- models.providers.${id}.api=${api}` : null,
].filter((line): line is string => Boolean(line));
});
lines.push(
"- Remove these entries to restore per-model API routing + costs (then re-run onboarding if needed).",
);
note(lines.join("\n"), "OpenCode Zen");
}
export function noteIncludeConfinementWarning(snapshot: {
path?: string | null;
issues?: Array<{ message: string }>;
}): void {
const issues = snapshot.issues ?? [];
const includeIssue = issues.find(
(issue) =>
issue.message.includes("Include path escapes config directory") ||
issue.message.includes("Include path resolves outside config directory"),
);
if (!includeIssue) {
return;
}
const configRoot = path.dirname(snapshot.path ?? CONFIG_PATH);
note(
[
`- $include paths must stay under: ${configRoot}`,
'- Move shared include files under that directory and update to relative paths like "./shared/common.json".',
`- Error: ${includeIssue.message}`,
].join("\n"),
"Doctor warnings",
);
}

View File

@@ -1,6 +1,5 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { ZodIssue } from "zod";
import { normalizeChatChannelId } from "../channels/registry.js";
import {
isNumericTelegramUserId,
@@ -17,7 +16,6 @@ import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-
import { formatConfigIssueLines } from "../config/issue-format.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { parseToolsBySenderTypedKey } from "../config/types.tools.js";
import { OpenClawSchema } from "../config/zod-schema.js";
import { resolveCommandResolutionFromArgv } from "../infra/exec-command-resolution.js";
import {
listInterpreterLikeSafeBins,
@@ -50,161 +48,18 @@ import {
import { inspectTelegramAccount } from "../telegram/account-inspect.js";
import { listTelegramAccountIds, resolveTelegramAccount } from "../telegram/accounts.js";
import { note } from "../terminal/note.js";
import { isRecord, resolveHomeDir } from "../utils.js";
import { resolveHomeDir } from "../utils.js";
import {
formatConfigPath,
noteIncludeConfinementWarning,
noteOpencodeProviderOverrides,
resolveConfigPathTarget,
stripUnknownConfigKeys,
} from "./doctor-config-analysis.js";
import { normalizeCompatibilityConfigValues } from "./doctor-legacy-config.js";
import type { DoctorOptions } from "./doctor-prompter.js";
import { autoMigrateLegacyStateDir } from "./doctor-state-migrations.js";
type UnrecognizedKeysIssue = ZodIssue & {
code: "unrecognized_keys";
keys: PropertyKey[];
};
function normalizeIssuePath(path: PropertyKey[]): Array<string | number> {
return path.filter((part): part is string | number => typeof part !== "symbol");
}
function isUnrecognizedKeysIssue(issue: ZodIssue): issue is UnrecognizedKeysIssue {
return issue.code === "unrecognized_keys";
}
function formatPath(parts: Array<string | number>): string {
if (parts.length === 0) {
return "<root>";
}
let out = "";
for (const part of parts) {
if (typeof part === "number") {
out += `[${part}]`;
continue;
}
out = out ? `${out}.${part}` : part;
}
return out || "<root>";
}
function resolvePathTarget(root: unknown, path: Array<string | number>): unknown {
let current: unknown = root;
for (const part of path) {
if (typeof part === "number") {
if (!Array.isArray(current)) {
return null;
}
if (part < 0 || part >= current.length) {
return null;
}
current = current[part];
continue;
}
if (!current || typeof current !== "object" || Array.isArray(current)) {
return null;
}
const record = current as Record<string, unknown>;
if (!(part in record)) {
return null;
}
current = record[part];
}
return current;
}
function stripUnknownConfigKeys(config: OpenClawConfig): {
config: OpenClawConfig;
removed: string[];
} {
const parsed = OpenClawSchema.safeParse(config);
if (parsed.success) {
return { config, removed: [] };
}
const next = structuredClone(config);
const removed: string[] = [];
for (const issue of parsed.error.issues) {
if (!isUnrecognizedKeysIssue(issue)) {
continue;
}
const path = normalizeIssuePath(issue.path);
const target = resolvePathTarget(next, path);
if (!target || typeof target !== "object" || Array.isArray(target)) {
continue;
}
const record = target as Record<string, unknown>;
for (const key of issue.keys) {
if (typeof key !== "string") {
continue;
}
if (!(key in record)) {
continue;
}
delete record[key];
removed.push(formatPath([...path, key]));
}
}
return { config: next, removed };
}
function noteOpencodeProviderOverrides(cfg: OpenClawConfig) {
const providers = cfg.models?.providers;
if (!providers) {
return;
}
// 2026-01-10: warn when OpenCode Zen overrides mask built-in routing/costs (8a194b4abc360c6098f157956bb9322576b44d51, 2d105d16f8a099276114173836d46b46cdfbdbae).
const overrides: string[] = [];
if (providers.opencode) {
overrides.push("opencode");
}
if (providers["opencode-zen"]) {
overrides.push("opencode-zen");
}
if (overrides.length === 0) {
return;
}
const lines = overrides.flatMap((id) => {
const providerEntry = providers[id];
const api =
isRecord(providerEntry) && typeof providerEntry.api === "string"
? providerEntry.api
: undefined;
return [
`- models.providers.${id} is set; this overrides the built-in OpenCode Zen catalog.`,
api ? `- models.providers.${id}.api=${api}` : null,
].filter((line): line is string => Boolean(line));
});
lines.push(
"- Remove these entries to restore per-model API routing + costs (then re-run onboarding if needed).",
);
note(lines.join("\n"), "OpenCode Zen");
}
function noteIncludeConfinementWarning(snapshot: {
path?: string | null;
issues?: Array<{ message: string }>;
}): void {
const issues = snapshot.issues ?? [];
const includeIssue = issues.find(
(issue) =>
issue.message.includes("Include path escapes config directory") ||
issue.message.includes("Include path resolves outside config directory"),
);
if (!includeIssue) {
return;
}
const configRoot = path.dirname(snapshot.path ?? CONFIG_PATH);
note(
[
`- $include paths must stay under: ${configRoot}`,
'- Move shared include files under that directory and update to relative paths like "./shared/common.json".',
`- Error: ${includeIssue.message}`,
].join("\n"),
"Doctor warnings",
);
}
type TelegramAllowFromUsernameHit = { path: string; entry: string };
type TelegramAllowFromListRef = {
@@ -1659,7 +1514,7 @@ function collectLegacyToolsBySenderKeyHits(
const toolsBySender = asObjectRecord(record.toolsBySender);
if (toolsBySender) {
const path = [...pathParts, "toolsBySender"];
const pathLabel = formatPath(path);
const pathLabel = formatConfigPath(path);
for (const rawKey of Object.keys(toolsBySender)) {
const trimmed = rawKey.trim();
if (!trimmed || trimmed === "*" || parseToolsBySenderTypedKey(trimmed)) {
@@ -1702,7 +1557,7 @@ function maybeRepairLegacyToolsBySenderKeys(cfg: OpenClawConfig): {
let changed = false;
for (const hit of hits) {
const toolsBySender = asObjectRecord(resolvePathTarget(next, hit.toolsBySenderPath));
const toolsBySender = asObjectRecord(resolveConfigPathTarget(next, hit.toolsBySenderPath));
if (!toolsBySender || !(hit.key in toolsBySender)) {
continue;
}

View File

@@ -324,7 +324,19 @@ describe("models list/status", () => {
await expect(loadModelRegistry({})).rejects.toThrow("model discovery unavailable");
});
it("loadModelRegistry persists using source config snapshot when provided", async () => {
it("loadModelRegistry does not persist models.json as a side effect", async () => {
modelRegistryState.models = [OPENAI_MODEL];
modelRegistryState.available = [OPENAI_MODEL];
const resolvedConfig = {
models: { providers: { openai: { apiKey: "sk-resolved-runtime-value" } } }, // pragma: allowlist secret
};
await loadModelRegistry(resolvedConfig as never);
expect(ensureOpenClawModelsJson).not.toHaveBeenCalled();
});
it("modelsListCommand persists using the write snapshot config when provided", async () => {
modelRegistryState.models = [OPENAI_MODEL];
modelRegistryState.available = [OPENAI_MODEL];
const sourceConfig = {
@@ -333,21 +345,14 @@ describe("models list/status", () => {
const resolvedConfig = {
models: { providers: { openai: { apiKey: "sk-resolved-runtime-value" } } }, // pragma: allowlist secret
};
readConfigFileSnapshotForWrite.mockResolvedValue({
snapshot: { valid: true, resolved: resolvedConfig, source: sourceConfig },
writeOptions: {},
});
setDefaultModel("openai/gpt-4.1-mini");
const runtime = makeRuntime();
await loadModelRegistry(resolvedConfig as never, { sourceConfig: sourceConfig as never });
expect(ensureOpenClawModelsJson).toHaveBeenCalledTimes(1);
expect(ensureOpenClawModelsJson).toHaveBeenCalledWith(sourceConfig);
});
it("loadModelRegistry uses resolved config when no source snapshot is provided", async () => {
modelRegistryState.models = [OPENAI_MODEL];
modelRegistryState.available = [OPENAI_MODEL];
const resolvedConfig = {
models: { providers: { openai: { apiKey: "sk-resolved-runtime-value" } } }, // pragma: allowlist secret
};
await loadModelRegistry(resolvedConfig as never);
await modelsListCommand({ all: true, json: true }, runtime);
expect(ensureOpenClawModelsJson).toHaveBeenCalledTimes(1);
expect(ensureOpenClawModelsJson).toHaveBeenCalledWith(resolvedConfig);

View File

@@ -23,6 +23,7 @@ export async function modelsListCommand(
) {
ensureFlagCompatibility(opts);
const { ensureAuthProfileStore } = await import("../../agents/auth-profiles.js");
const { ensureOpenClawModelsJson } = await import("../../agents/models-config.js");
const { sourceConfig, resolvedConfig: cfg } = await loadModelsConfigWithSource({
commandName: "models list",
runtime,
@@ -42,6 +43,9 @@ export async function modelsListCommand(
let availableKeys: Set<string> | undefined;
let availabilityErrorMessage: string | undefined;
try {
// Keep command behavior explicit: sync models.json from the source config
// before building the read-only model registry view.
await ensureOpenClawModelsJson(sourceConfig ?? cfg);
const loaded = await loadModelRegistry(cfg, { sourceConfig });
modelRegistry = loaded.registry;
models = loaded.models;

View File

@@ -8,7 +8,6 @@ import {
resolveAwsSdkEnvVarName,
resolveEnvApiKey,
} from "../../agents/model-auth.js";
import { ensureOpenClawModelsJson } from "../../agents/models-config.js";
import { discoverAuthStorage, discoverModels } from "../../agents/pi-model-discovery.js";
import type { OpenClawConfig } from "../../config/config.js";
import {
@@ -95,12 +94,9 @@ function loadAvailableModels(registry: ModelRegistry): Model<Api>[] {
}
export async function loadModelRegistry(
cfg: OpenClawConfig,
opts?: { sourceConfig?: OpenClawConfig },
_cfg: OpenClawConfig,
_opts?: { sourceConfig?: OpenClawConfig },
) {
// Persistence must be based on source config (pre-resolution) so SecretRef-managed
// credentials remain markers in models.json for command paths too.
await ensureOpenClawModelsJson(opts?.sourceConfig ?? cfg);
const agentDir = resolveOpenClawAgentDir();
const authStorage = discoverAuthStorage(agentDir);
const registry = discoverModels(authStorage, agentDir);

View File

@@ -0,0 +1,60 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { setupCommand } from "./setup.js";
describe("setupCommand", () => {
it("writes gateway.mode=local on first run", async () => {
await withTempHome(async (home) => {
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await setupCommand(undefined, runtime);
const configPath = path.join(home, ".openclaw", "openclaw.json");
const raw = await fs.readFile(configPath, "utf-8");
expect(raw).toContain('"mode": "local"');
expect(raw).toContain('"workspace"');
});
});
it("adds gateway.mode=local to an existing config without overwriting workspace", async () => {
await withTempHome(async (home) => {
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const configDir = path.join(home, ".openclaw");
const configPath = path.join(configDir, "openclaw.json");
const workspace = path.join(home, "custom-workspace");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({
agents: {
defaults: {
workspace,
},
},
}),
);
await setupCommand(undefined, runtime);
const raw = JSON.parse(await fs.readFile(configPath, "utf-8")) as {
agents?: { defaults?: { workspace?: string } };
gateway?: { mode?: string };
};
expect(raw.agents?.defaults?.workspace).toBe(workspace);
expect(raw.gateway?.mode).toBe("local");
});
});
});

View File

@@ -50,14 +50,30 @@ export async function setupCommand(
workspace,
},
},
gateway: {
...cfg.gateway,
mode: cfg.gateway?.mode ?? "local",
},
};
if (!existingRaw.exists || defaults.workspace !== workspace) {
if (
!existingRaw.exists ||
defaults.workspace !== workspace ||
cfg.gateway?.mode !== next.gateway?.mode
) {
await writeConfigFile(next);
if (!existingRaw.exists) {
runtime.log(`Wrote ${formatConfigPath(configPath)}`);
} else {
logConfigUpdated(runtime, { path: configPath, suffix: "(set agents.defaults.workspace)" });
const updates: string[] = [];
if (defaults.workspace !== workspace) {
updates.push("set agents.defaults.workspace");
}
if (cfg.gateway?.mode !== next.gateway?.mode) {
updates.push("set gateway.mode");
}
const suffix = updates.length > 0 ? `(${updates.join(", ")})` : undefined;
logConfigUpdated(runtime, { path: configPath, suffix });
}
} else {
runtime.log(`Config OK: ${formatConfigPath(configPath)}`);

View File

@@ -3,7 +3,11 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import { loadDotEnv } from "../infra/dotenv.js";
import { resolveConfigEnvVars } from "./env-substitution.js";
import { applyConfigEnvVars, collectConfigRuntimeEnvVars } from "./env-vars.js";
import {
applyConfigEnvVars,
collectConfigRuntimeEnvVars,
createConfigRuntimeEnv,
} from "./env-vars.js";
import { withEnvOverride, withTempHome } from "./test-helpers.js";
import type { OpenClawConfig } from "./types.js";
@@ -29,6 +33,16 @@ describe("config env vars", () => {
});
});
it("can build a merged runtime env without mutating process.env", async () => {
await withEnvOverride({ OPENROUTER_API_KEY: undefined }, async () => {
const merged = createConfigRuntimeEnv({
env: { vars: { OPENROUTER_API_KEY: "config-key" } },
} as OpenClawConfig);
expect(merged.OPENROUTER_API_KEY).toBe("config-key");
expect(process.env.OPENROUTER_API_KEY).toBeUndefined();
});
});
it("blocks dangerous startup env vars from config env", async () => {
await withEnvOverride(
{

View File

@@ -67,6 +67,15 @@ export function collectConfigEnvVars(cfg?: OpenClawConfig): Record<string, strin
return collectConfigRuntimeEnvVars(cfg);
}
export function createConfigRuntimeEnv(
cfg: OpenClawConfig,
baseEnv: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv {
const env = { ...baseEnv };
applyConfigEnvVars(cfg, env);
return env;
}
export function applyConfigEnvVars(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,

View File

@@ -2,7 +2,13 @@ import fs from "node:fs";
import path from "node:path";
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import { resolveDefaultSessionStorePath } from "./paths.js";
import { parseSessionThreadInfo } from "./delivery-info.js";
import {
resolveDefaultSessionStorePath,
resolveSessionFilePath,
resolveSessionFilePathOptions,
resolveSessionTranscriptPath,
} from "./paths.js";
import { resolveAndPersistSessionFile } from "./session-file.js";
import { loadSessionStore } from "./store.js";
import type { SessionEntry } from "./types.js";
@@ -79,6 +85,51 @@ async function ensureSessionHeader(params: {
});
}
export async function resolveSessionTranscriptFile(params: {
sessionId: string;
sessionKey: string;
sessionEntry: SessionEntry | undefined;
sessionStore?: Record<string, SessionEntry>;
storePath?: string;
agentId: string;
threadId?: string | number;
}): Promise<{ sessionFile: string; sessionEntry: SessionEntry | undefined }> {
const sessionPathOpts = resolveSessionFilePathOptions({
agentId: params.agentId,
storePath: params.storePath,
});
let sessionFile = resolveSessionFilePath(params.sessionId, params.sessionEntry, sessionPathOpts);
let sessionEntry = params.sessionEntry;
if (params.sessionStore && params.storePath) {
const threadIdFromSessionKey = parseSessionThreadInfo(params.sessionKey).threadId;
const fallbackSessionFile = !sessionEntry?.sessionFile
? resolveSessionTranscriptPath(
params.sessionId,
params.agentId,
params.threadId ?? threadIdFromSessionKey,
)
: undefined;
const resolvedSessionFile = await resolveAndPersistSessionFile({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionStore: params.sessionStore,
storePath: params.storePath,
sessionEntry,
agentId: sessionPathOpts?.agentId,
sessionsDir: sessionPathOpts?.sessionsDir,
fallbackSessionFile,
});
sessionFile = resolvedSessionFile.sessionFile;
sessionEntry = resolvedSessionFile.sessionEntry;
}
return {
sessionFile,
sessionEntry,
};
}
export async function appendAssistantMessageToSessionTranscript(params: {
agentId?: string;
sessionKey: string;

View File

@@ -26,6 +26,7 @@ export type ModelCompatConfig = {
requiresAssistantAfterToolResult?: boolean;
requiresThinkingAsText?: boolean;
requiresMistralToolIds?: boolean;
requiresOpenAiAnthropicToolPayload?: boolean;
};
export type ModelProviderAuthMode = "api-key" | "aws-sdk" | "oauth" | "token";

View File

@@ -67,7 +67,7 @@ class MockContextEngine implements ContextEngine {
tokenBudget?: number;
compactionTarget?: "budget" | "threshold";
customInstructions?: string;
legacyParams?: Record<string, unknown>;
runtimeContext?: Record<string, unknown>;
}): Promise<CompactResult> {
return {
ok: true,

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