mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-17 11:38:44 +08:00
Compare commits
61 Commits
fix/secret
...
docs/add-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55300ea850 | ||
|
|
8e5f702adf | ||
|
|
56d2662f9d | ||
|
|
e2ecd0a321 | ||
|
|
7fce53976e | ||
|
|
1cc021251e | ||
|
|
a8ad7e42af | ||
|
|
42320281c6 | ||
|
|
f60168b735 | ||
|
|
0a5701f468 | ||
|
|
07f65838ed | ||
|
|
4d326271f0 | ||
|
|
eb4ff4464e | ||
|
|
cbcf9d0811 | ||
|
|
83a854bfa0 | ||
|
|
3ada30e670 | ||
|
|
c5095153b0 | ||
|
|
68775745d2 | ||
|
|
f399a818ef | ||
|
|
6bd5735519 | ||
|
|
11be305609 | ||
|
|
f6cb77134c | ||
|
|
25d0aa7296 | ||
|
|
dd7470730d | ||
|
|
c70151e873 | ||
|
|
a007bed375 | ||
|
|
fa580e33c1 | ||
|
|
371c53b282 | ||
|
|
cee2f3e8b4 | ||
|
|
2ed644f5d3 | ||
|
|
404b1527e6 | ||
|
|
72ebaf97c3 | ||
|
|
8ab762c005 | ||
|
|
d307a7ca1a | ||
|
|
52bc809143 | ||
|
|
6094035054 | ||
|
|
f493b03202 | ||
|
|
e53d840fed | ||
|
|
f66bd105a4 | ||
|
|
ef2541ceb3 | ||
|
|
8a18e2598f | ||
|
|
749eb4efea | ||
|
|
64d4d9aabb | ||
|
|
e5c06dd64a | ||
|
|
efcca3d2ea | ||
|
|
0b452a5665 | ||
|
|
4c71176c9f | ||
|
|
c5bba6628e | ||
|
|
3b68d3fded | ||
|
|
7856f5730c | ||
|
|
aebfce7a36 | ||
|
|
e19b3679d1 | ||
|
|
d23d36a2f9 | ||
|
|
2ae58542a0 | ||
|
|
55465d86d9 | ||
|
|
615466bdf4 | ||
|
|
6f4de3cc23 | ||
|
|
f19761cefa | ||
|
|
5387faa718 | ||
|
|
bdf9739e59 | ||
|
|
2970d72554 |
@@ -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"
|
||||
|
||||
6
.github/workflows/auto-response.yml
vendored
6
.github/workflows/auto-response.yml
vendored
@@ -261,6 +261,8 @@ jobs:
|
||||
};
|
||||
|
||||
const triggerLabel = "trigger-response";
|
||||
const activePrLimitLabel = "r: too-many-prs";
|
||||
const activePrLimitOverrideLabel = "r: too-many-prs-override";
|
||||
const target = context.payload.issue ?? context.payload.pull_request;
|
||||
if (!target) {
|
||||
return;
|
||||
@@ -448,6 +450,10 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
if (pullRequest && labelSet.has(activePrLimitOverrideLabel)) {
|
||||
labelSet.delete(activePrLimitLabel);
|
||||
}
|
||||
|
||||
const rule = rules.find((item) => labelSet.has(item.label));
|
||||
if (!rule) {
|
||||
return;
|
||||
|
||||
46
.github/workflows/ci.yml
vendored
46
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
28
.github/workflows/labeler.yml
vendored
28
.github/workflows/labeler.yml
vendored
@@ -213,6 +213,7 @@ jobs:
|
||||
}
|
||||
|
||||
const activePrLimitLabel = "r: too-many-prs";
|
||||
const activePrLimitOverrideLabel = "r: too-many-prs-override";
|
||||
const activePrLimit = 10;
|
||||
const labelColor = "B60205";
|
||||
const labelDescription = `Author has more than ${activePrLimit} active PRs in this repo`;
|
||||
@@ -221,12 +222,37 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const labelNames = new Set(
|
||||
(pullRequest.labels ?? [])
|
||||
currentLabels
|
||||
.map((label) => (typeof label === "string" ? label : label?.name))
|
||||
.filter((name) => typeof name === "string"),
|
||||
);
|
||||
|
||||
if (labelNames.has(activePrLimitOverrideLabel)) {
|
||||
if (labelNames.has(activePrLimitLabel)) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
name: activePrLimitLabel,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const ensureLabelExists = async () => {
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
|
||||
@@ -66,7 +66,7 @@ repos:
|
||||
- --exclude-lines
|
||||
- 'env: \{ MISTRAL_API_K[E]Y: "sk-\.\.\." \},'
|
||||
- --exclude-lines
|
||||
- '"ap[i]Key": "xxxxx",'
|
||||
- '"ap[i]Key": "xxxxx"(,)?'
|
||||
- --exclude-lines
|
||||
- 'ap[i]Key: "A[I]za\.\.\.",'
|
||||
# Shell script linting
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
"export CUSTOM_API_K[E]Y=\"your-key\"",
|
||||
"grep -q 'N[O]DE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache' ~/.bashrc \\|\\| cat >> ~/.bashrc <<'EOF'",
|
||||
"env: \\{ MISTRAL_API_K[E]Y: \"sk-\\.\\.\\.\" \\},",
|
||||
"\"ap[i]Key\": \"xxxxx\",",
|
||||
"\"ap[i]Key\": \"xxxxx\"(,)?",
|
||||
"ap[i]Key: \"A[I]za\\.\\.\\.\","
|
||||
]
|
||||
},
|
||||
@@ -9619,14 +9619,14 @@
|
||||
"filename": "docs/channels/feishu.md",
|
||||
"hashed_secret": "b60d121b438a380c343d5ec3c2037564b82ffef3",
|
||||
"is_verified": false,
|
||||
"line_number": 189
|
||||
"line_number": 187
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "docs/channels/feishu.md",
|
||||
"hashed_secret": "186154712b2d5f6791d85b9a0987b98fa231779c",
|
||||
"is_verified": false,
|
||||
"line_number": 501
|
||||
"line_number": 499
|
||||
}
|
||||
],
|
||||
"docs/channels/irc.md": [
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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?) {
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class TalkModeVoiceResolverTest {
|
||||
@Test
|
||||
fun resolvesVoiceAliasCaseInsensitively() {
|
||||
val resolved =
|
||||
TalkModeVoiceResolver.resolveVoiceAlias(
|
||||
" Clawd ",
|
||||
mapOf("clawd" to "voice-123"),
|
||||
)
|
||||
|
||||
assertEquals("voice-123", resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsDirectVoiceIds() {
|
||||
val resolved = TalkModeVoiceResolver.resolveVoiceAlias("21m00Tcm4TlvDq8ikWAM", emptyMap())
|
||||
|
||||
assertEquals("21m00Tcm4TlvDq8ikWAM", resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsUnknownAliases() {
|
||||
val resolved = TalkModeVoiceResolver.resolveVoiceAlias("nickname", emptyMap())
|
||||
|
||||
assertNull(resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reusesCachedFallbackVoiceBeforeFetchingCatalog() =
|
||||
runBlocking {
|
||||
var fetchCount = 0
|
||||
|
||||
val resolved =
|
||||
TalkModeVoiceResolver.resolveVoiceId(
|
||||
preferred = null,
|
||||
fallbackVoiceId = "cached-voice",
|
||||
defaultVoiceId = null,
|
||||
currentVoiceId = null,
|
||||
voiceOverrideActive = false,
|
||||
listVoices = {
|
||||
fetchCount += 1
|
||||
emptyList()
|
||||
},
|
||||
)
|
||||
|
||||
assertEquals("cached-voice", resolved.voiceId)
|
||||
assertEquals(0, fetchCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seedsDefaultVoiceFromCatalogWhenNeeded() =
|
||||
runBlocking {
|
||||
val resolved =
|
||||
TalkModeVoiceResolver.resolveVoiceId(
|
||||
preferred = null,
|
||||
fallbackVoiceId = null,
|
||||
defaultVoiceId = null,
|
||||
currentVoiceId = null,
|
||||
voiceOverrideActive = false,
|
||||
listVoices = { listOf(ElevenLabsVoice("voice-1", "First")) },
|
||||
)
|
||||
|
||||
assertEquals("voice-1", resolved.voiceId)
|
||||
assertEquals("voice-1", resolved.fallbackVoiceId)
|
||||
assertEquals("voice-1", resolved.defaultVoiceId)
|
||||
assertEquals("voice-1", resolved.currentVoiceId)
|
||||
assertEquals("First", resolved.selectedVoiceName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preservesCurrentVoiceWhenOverrideIsActive() =
|
||||
runBlocking {
|
||||
val resolved =
|
||||
TalkModeVoiceResolver.resolveVoiceId(
|
||||
preferred = null,
|
||||
fallbackVoiceId = null,
|
||||
defaultVoiceId = null,
|
||||
currentVoiceId = null,
|
||||
voiceOverrideActive = true,
|
||||
listVoices = { listOf(ElevenLabsVoice("voice-1", "First")) },
|
||||
)
|
||||
|
||||
assertEquals("voice-1", resolved.voiceId)
|
||||
assertNull(resolved.currentVoiceId)
|
||||
}
|
||||
}
|
||||
@@ -129,8 +129,7 @@ final class NodeAppModel {
|
||||
private var backgroundReconnectSuppressed = false
|
||||
private var backgroundReconnectLeaseUntil: Date?
|
||||
private var lastSignificantLocationWakeAt: Date?
|
||||
private var queuedWatchReplies: [WatchQuickReplyEvent] = []
|
||||
private var seenWatchReplyIds = Set<String>()
|
||||
@ObservationIgnored private let watchReplyCoordinator = WatchReplyCoordinator()
|
||||
|
||||
private var gatewayConnected = false
|
||||
private var operatorConnected = false
|
||||
@@ -2199,37 +2198,22 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
private func handleWatchQuickReply(_ event: WatchQuickReplyEvent) async {
|
||||
let replyId = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let actionId = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if replyId.isEmpty || actionId.isEmpty {
|
||||
switch self.watchReplyCoordinator.ingest(event, isGatewayConnected: await self.isGatewayConnected()) {
|
||||
case .dropMissingFields:
|
||||
self.watchReplyLogger.info("watch reply dropped: missing replyId/actionId")
|
||||
return
|
||||
}
|
||||
|
||||
if self.seenWatchReplyIds.contains(replyId) {
|
||||
case .deduped(let replyId):
|
||||
self.watchReplyLogger.debug(
|
||||
"watch reply deduped replyId=\(replyId, privacy: .public)")
|
||||
return
|
||||
}
|
||||
self.seenWatchReplyIds.insert(replyId)
|
||||
|
||||
if await !self.isGatewayConnected() {
|
||||
self.queuedWatchReplies.append(event)
|
||||
case .queue(let replyId, let actionId):
|
||||
self.watchReplyLogger.info(
|
||||
"watch reply queued replyId=\(replyId, privacy: .public) action=\(actionId, privacy: .public)")
|
||||
return
|
||||
case .forward:
|
||||
await self.forwardWatchReplyToAgent(event)
|
||||
}
|
||||
|
||||
await self.forwardWatchReplyToAgent(event)
|
||||
}
|
||||
|
||||
private func flushQueuedWatchRepliesIfConnected() async {
|
||||
guard await self.isGatewayConnected() else { return }
|
||||
guard !self.queuedWatchReplies.isEmpty else { return }
|
||||
|
||||
let pending = self.queuedWatchReplies
|
||||
self.queuedWatchReplies.removeAll()
|
||||
for event in pending {
|
||||
for event in self.watchReplyCoordinator.drainIfConnected(await self.isGatewayConnected()) {
|
||||
await self.forwardWatchReplyToAgent(event)
|
||||
}
|
||||
}
|
||||
@@ -2259,7 +2243,7 @@ extension NodeAppModel {
|
||||
"watch reply forwarding failed replyId=\(event.replyId) "
|
||||
+ "error=\(error.localizedDescription)"
|
||||
self.watchReplyLogger.error("\(failedMessage, privacy: .public)")
|
||||
self.queuedWatchReplies.insert(event, at: 0)
|
||||
self.watchReplyCoordinator.requeueFront(event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2852,7 +2836,7 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
func _test_queuedWatchReplyCount() -> Int {
|
||||
self.queuedWatchReplies.count
|
||||
self.watchReplyCoordinator.queuedCount
|
||||
}
|
||||
|
||||
func _test_setGatewayConnected(_ connected: Bool) {
|
||||
|
||||
46
apps/ios/Sources/Model/WatchReplyCoordinator.swift
Normal file
46
apps/ios/Sources/Model/WatchReplyCoordinator.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class WatchReplyCoordinator {
|
||||
enum Decision {
|
||||
case dropMissingFields
|
||||
case deduped(replyId: String)
|
||||
case queue(replyId: String, actionId: String)
|
||||
case forward
|
||||
}
|
||||
|
||||
private var queuedReplies: [WatchQuickReplyEvent] = []
|
||||
private var seenReplyIds = Set<String>()
|
||||
|
||||
func ingest(_ event: WatchQuickReplyEvent, isGatewayConnected: Bool) -> Decision {
|
||||
let replyId = event.replyId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let actionId = event.actionId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if replyId.isEmpty || actionId.isEmpty {
|
||||
return .dropMissingFields
|
||||
}
|
||||
if self.seenReplyIds.contains(replyId) {
|
||||
return .deduped(replyId: replyId)
|
||||
}
|
||||
self.seenReplyIds.insert(replyId)
|
||||
if !isGatewayConnected {
|
||||
self.queuedReplies.append(event)
|
||||
return .queue(replyId: replyId, actionId: actionId)
|
||||
}
|
||||
return .forward
|
||||
}
|
||||
|
||||
func drainIfConnected(_ isGatewayConnected: Bool) -> [WatchQuickReplyEvent] {
|
||||
guard isGatewayConnected, !self.queuedReplies.isEmpty else { return [] }
|
||||
let pending = self.queuedReplies
|
||||
self.queuedReplies.removeAll()
|
||||
return pending
|
||||
}
|
||||
|
||||
func requeueFront(_ event: WatchQuickReplyEvent) {
|
||||
self.queuedReplies.insert(event, at: 0)
|
||||
}
|
||||
|
||||
var queuedCount: Int {
|
||||
self.queuedReplies.count
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -79,11 +79,16 @@ See [Memory](/concepts/memory).
|
||||
|
||||
`web_search` uses API keys and may incur usage charges depending on your provider:
|
||||
|
||||
- **Perplexity Search API**: `PERPLEXITY_API_KEY`
|
||||
- **Brave Search API**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
|
||||
- **Gemini (Google Search)**: `GEMINI_API_KEY`
|
||||
- **Grok (xAI)**: `XAI_API_KEY`
|
||||
- **Kimi (Moonshot)**: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
|
||||
- **Perplexity Search API**: `PERPLEXITY_API_KEY`
|
||||
|
||||
**Brave Search free credit:** Each Brave plan includes $5/month in renewing
|
||||
free credit. The Search plan costs $5 per 1,000 requests, so the credit covers
|
||||
1,000 requests/month at no charge. Set your usage limit in the Brave dashboard
|
||||
to avoid unexpected charges.
|
||||
|
||||
See [Web tools](/tools/web).
|
||||
|
||||
|
||||
@@ -196,6 +196,53 @@ Notes:
|
||||
- Replace `<BROWSERLESS_API_KEY>` with your real Browserless token.
|
||||
- Choose the region endpoint that matches your Browserless account (see their docs).
|
||||
|
||||
## Direct WebSocket CDP providers
|
||||
|
||||
Some hosted browser services expose a **direct WebSocket** endpoint rather than
|
||||
the standard HTTP-based CDP discovery (`/json/version`). OpenClaw supports both:
|
||||
|
||||
- **HTTP(S) endpoints** (e.g. Browserless) — OpenClaw calls `/json/version` to
|
||||
discover the WebSocket debugger URL, then connects.
|
||||
- **WebSocket endpoints** (`ws://` / `wss://`) — OpenClaw connects directly,
|
||||
skipping `/json/version`. Use this for services like
|
||||
[Browserbase](https://www.browserbase.com) or any provider that hands you a
|
||||
WebSocket URL.
|
||||
|
||||
### Browserbase
|
||||
|
||||
[Browserbase](https://www.browserbase.com) is a cloud platform for running
|
||||
headless browsers with built-in CAPTCHA solving, stealth mode, and residential
|
||||
proxies.
|
||||
|
||||
```json5
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
defaultProfile: "browserbase",
|
||||
remoteCdpTimeoutMs: 3000,
|
||||
remoteCdpHandshakeTimeoutMs: 5000,
|
||||
profiles: {
|
||||
browserbase: {
|
||||
cdpUrl: "wss://connect.browserbase.com?apiKey=<BROWSERBASE_API_KEY>",
|
||||
color: "#F97316",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- [Sign up](https://www.browserbase.com/sign-up) and copy your **API Key**
|
||||
from the [Overview dashboard](https://www.browserbase.com/overview).
|
||||
- Replace `<BROWSERBASE_API_KEY>` with your real Browserbase API key.
|
||||
- Browserbase auto-creates a browser session on WebSocket connect, so no
|
||||
manual session creation step is needed.
|
||||
- The free tier allows one concurrent session and one browser hour per month.
|
||||
See [pricing](https://www.browserbase.com/pricing) for paid plan limits.
|
||||
- See the [Browserbase docs](https://docs.browserbase.com) for full API
|
||||
reference, SDK guides, and integration examples.
|
||||
|
||||
## Security
|
||||
|
||||
Key ideas:
|
||||
@@ -207,7 +254,7 @@ Key ideas:
|
||||
|
||||
Remote CDP tips:
|
||||
|
||||
- Prefer HTTPS endpoints and short-lived tokens where possible.
|
||||
- Prefer encrypted endpoints (HTTPS or WSS) and short-lived tokens where possible.
|
||||
- Avoid embedding long-lived tokens directly in config files.
|
||||
|
||||
## Profiles (multi-browser)
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
"npmSpec": "@openclaw/googlechat",
|
||||
"localPath": "extensions/googlechat",
|
||||
"defaultChoice": "npm"
|
||||
},
|
||||
"releaseChecks": {
|
||||
"rootDependencyMirrorAllowlist": [
|
||||
"google-auth-library"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,13 @@
|
||||
"npmSpec": "@openclaw/matrix",
|
||||
"localPath": "extensions/matrix",
|
||||
"defaultChoice": "npm"
|
||||
},
|
||||
"releaseChecks": {
|
||||
"rootDependencyMirrorAllowlist": [
|
||||
"@matrix-org/matrix-sdk-crypto-nodejs",
|
||||
"@vector-im/matrix-bot-sdk",
|
||||
"music-metadata"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
"npmSpec": "@openclaw/msteams",
|
||||
"localPath": "extensions/msteams",
|
||||
"defaultChoice": "npm"
|
||||
},
|
||||
"releaseChecks": {
|
||||
"rootDependencyMirrorAllowlist": [
|
||||
"@microsoft/agents-hosting"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
"npmSpec": "@openclaw/nostr",
|
||||
"localPath": "extensions/nostr",
|
||||
"defaultChoice": "npm"
|
||||
},
|
||||
"releaseChecks": {
|
||||
"rootDependencyMirrorAllowlist": [
|
||||
"nostr-tools"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,13 @@
|
||||
"npmSpec": "@openclaw/tlon",
|
||||
"localPath": "extensions/tlon",
|
||||
"defaultChoice": "npm"
|
||||
},
|
||||
"releaseChecks": {
|
||||
"rootDependencyMirrorAllowlist": [
|
||||
"@tloncorp/api",
|
||||
"@tloncorp/tlon-skill",
|
||||
"@urbit/aura"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,11 @@
|
||||
"npmSpec": "@openclaw/zalouser",
|
||||
"localPath": "extensions/zalouser",
|
||||
"defaultChoice": "npm"
|
||||
},
|
||||
"releaseChecks": {
|
||||
"rootDependencyMirrorAllowlist": [
|
||||
"zca-js"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
71
scripts/lib/bundled-extension-manifest.ts
Normal file
71
scripts/lib/bundled-extension-manifest.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
export type ExtensionPackageJson = {
|
||||
name?: string;
|
||||
version?: string;
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
openclaw?: {
|
||||
install?: {
|
||||
npmSpec?: string;
|
||||
};
|
||||
releaseChecks?: {
|
||||
rootDependencyMirrorAllowlist?: string[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type BundledExtension = { id: string; packageJson: ExtensionPackageJson };
|
||||
export type BundledExtensionMetadata = BundledExtension & {
|
||||
npmSpec?: string;
|
||||
rootDependencyMirrorAllowlist: string[];
|
||||
};
|
||||
|
||||
export function normalizeBundledExtensionMetadata(
|
||||
extensions: BundledExtension[],
|
||||
): BundledExtensionMetadata[] {
|
||||
return extensions.map((extension) => ({
|
||||
...extension,
|
||||
npmSpec:
|
||||
typeof extension.packageJson.openclaw?.install?.npmSpec === "string"
|
||||
? extension.packageJson.openclaw.install.npmSpec.trim()
|
||||
: undefined,
|
||||
rootDependencyMirrorAllowlist:
|
||||
extension.packageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist?.filter(
|
||||
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
|
||||
) ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
export function collectBundledExtensionManifestErrors(extensions: BundledExtension[]): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const extension of extensions) {
|
||||
const install = extension.packageJson.openclaw?.install;
|
||||
if (
|
||||
install &&
|
||||
(!install.npmSpec || typeof install.npmSpec !== "string" || !install.npmSpec.trim())
|
||||
) {
|
||||
errors.push(
|
||||
`bundled extension '${extension.id}' manifest invalid | openclaw.install.npmSpec must be a non-empty string`,
|
||||
);
|
||||
}
|
||||
|
||||
const allowlist = extension.packageJson.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist;
|
||||
if (allowlist === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(allowlist)) {
|
||||
errors.push(
|
||||
`bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must be an array of non-empty strings`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const invalidEntries = allowlist.filter((entry) => typeof entry !== "string" || !entry.trim());
|
||||
if (invalidEntries.length > 0) {
|
||||
errors.push(
|
||||
`bundled extension '${extension.id}' manifest invalid | openclaw.releaseChecks.rootDependencyMirrorAllowlist must contain only non-empty strings`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -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:");
|
||||
|
||||
@@ -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
|
||||
? [
|
||||
|
||||
@@ -49,7 +49,9 @@ import {
|
||||
normalizeAcpErrorCode,
|
||||
normalizeActorKey,
|
||||
normalizeSessionKey,
|
||||
requireReadySessionMeta,
|
||||
resolveAcpAgentFromSessionKey,
|
||||
resolveAcpSessionResolutionError,
|
||||
resolveMissingMetaError,
|
||||
resolveRuntimeIdleTtlMs,
|
||||
} from "./manager.utils.js";
|
||||
@@ -332,15 +334,7 @@ export class AcpSessionManager {
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
});
|
||||
if (resolution.kind === "none") {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`Session is not ACP-enabled: ${sessionKey}`,
|
||||
);
|
||||
}
|
||||
if (resolution.kind === "stale") {
|
||||
throw resolution.error;
|
||||
}
|
||||
const resolvedMeta = requireReadySessionMeta(resolution);
|
||||
const {
|
||||
runtime,
|
||||
handle: ensuredHandle,
|
||||
@@ -348,7 +342,7 @@ export class AcpSessionManager {
|
||||
} = await this.ensureRuntimeHandle({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
meta: resolution.meta,
|
||||
meta: resolvedMeta,
|
||||
});
|
||||
let handle = ensuredHandle;
|
||||
let meta = ensuredMeta;
|
||||
@@ -414,19 +408,11 @@ export class AcpSessionManager {
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
});
|
||||
if (resolution.kind === "none") {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`Session is not ACP-enabled: ${sessionKey}`,
|
||||
);
|
||||
}
|
||||
if (resolution.kind === "stale") {
|
||||
throw resolution.error;
|
||||
}
|
||||
const resolvedMeta = requireReadySessionMeta(resolution);
|
||||
const { runtime, handle, meta } = await this.ensureRuntimeHandle({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
meta: resolution.meta,
|
||||
meta: resolvedMeta,
|
||||
});
|
||||
const capabilities = await this.resolveRuntimeCapabilities({ runtime, handle });
|
||||
if (!capabilities.controls.includes("session/set_mode") || !runtime.setMode) {
|
||||
@@ -479,19 +465,11 @@ export class AcpSessionManager {
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
});
|
||||
if (resolution.kind === "none") {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`Session is not ACP-enabled: ${sessionKey}`,
|
||||
);
|
||||
}
|
||||
if (resolution.kind === "stale") {
|
||||
throw resolution.error;
|
||||
}
|
||||
const resolvedMeta = requireReadySessionMeta(resolution);
|
||||
const { runtime, handle, meta } = await this.ensureRuntimeHandle({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
meta: resolution.meta,
|
||||
meta: resolvedMeta,
|
||||
});
|
||||
const inferredPatch = inferRuntimeOptionPatchFromConfigOption(key, value);
|
||||
const capabilities = await this.resolveRuntimeCapabilities({ runtime, handle });
|
||||
@@ -558,17 +536,9 @@ export class AcpSessionManager {
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
});
|
||||
if (resolution.kind === "none") {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`Session is not ACP-enabled: ${sessionKey}`,
|
||||
);
|
||||
}
|
||||
if (resolution.kind === "stale") {
|
||||
throw resolution.error;
|
||||
}
|
||||
const resolvedMeta = requireReadySessionMeta(resolution);
|
||||
const nextOptions = mergeRuntimeOptions({
|
||||
current: resolveRuntimeOptionsFromMeta(resolution.meta),
|
||||
current: resolveRuntimeOptionsFromMeta(resolvedMeta),
|
||||
patch: validatedPatch,
|
||||
});
|
||||
await this.persistRuntimeOptions({
|
||||
@@ -594,19 +564,11 @@ export class AcpSessionManager {
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
});
|
||||
if (resolution.kind === "none") {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`Session is not ACP-enabled: ${sessionKey}`,
|
||||
);
|
||||
}
|
||||
if (resolution.kind === "stale") {
|
||||
throw resolution.error;
|
||||
}
|
||||
const resolvedMeta = requireReadySessionMeta(resolution);
|
||||
const { runtime, handle } = await this.ensureRuntimeHandle({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
meta: resolution.meta,
|
||||
meta: resolvedMeta,
|
||||
});
|
||||
await withAcpRuntimeErrorBoundary({
|
||||
run: async () =>
|
||||
@@ -638,15 +600,7 @@ export class AcpSessionManager {
|
||||
cfg: input.cfg,
|
||||
sessionKey,
|
||||
});
|
||||
if (resolution.kind === "none") {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`Session is not ACP-enabled: ${sessionKey}`,
|
||||
);
|
||||
}
|
||||
if (resolution.kind === "stale") {
|
||||
throw resolution.error;
|
||||
}
|
||||
const resolvedMeta = requireReadySessionMeta(resolution);
|
||||
|
||||
const {
|
||||
runtime,
|
||||
@@ -655,7 +609,7 @@ export class AcpSessionManager {
|
||||
} = await this.ensureRuntimeHandle({
|
||||
cfg: input.cfg,
|
||||
sessionKey,
|
||||
meta: resolution.meta,
|
||||
meta: resolvedMeta,
|
||||
});
|
||||
let handle = ensuredHandle;
|
||||
const meta = ensuredMeta;
|
||||
@@ -810,19 +764,11 @@ export class AcpSessionManager {
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
});
|
||||
if (resolution.kind === "none") {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`Session is not ACP-enabled: ${sessionKey}`,
|
||||
);
|
||||
}
|
||||
if (resolution.kind === "stale") {
|
||||
throw resolution.error;
|
||||
}
|
||||
const resolvedMeta = requireReadySessionMeta(resolution);
|
||||
const { runtime, handle } = await this.ensureRuntimeHandle({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
meta: resolution.meta,
|
||||
meta: resolvedMeta,
|
||||
});
|
||||
try {
|
||||
await withAcpRuntimeErrorBoundary({
|
||||
@@ -868,27 +814,17 @@ export class AcpSessionManager {
|
||||
cfg: input.cfg,
|
||||
sessionKey,
|
||||
});
|
||||
if (resolution.kind === "none") {
|
||||
const resolutionError = resolveAcpSessionResolutionError(resolution);
|
||||
if (resolutionError) {
|
||||
if (input.requireAcpSession ?? true) {
|
||||
throw new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`Session is not ACP-enabled: ${sessionKey}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
runtimeClosed: false,
|
||||
metaCleared: false,
|
||||
};
|
||||
}
|
||||
if (resolution.kind === "stale") {
|
||||
if (input.requireAcpSession ?? true) {
|
||||
throw resolution.error;
|
||||
throw resolutionError;
|
||||
}
|
||||
return {
|
||||
runtimeClosed: false,
|
||||
metaCleared: false,
|
||||
};
|
||||
}
|
||||
const meta = requireReadySessionMeta(resolution);
|
||||
|
||||
let runtimeClosed = false;
|
||||
let runtimeNotice: string | undefined;
|
||||
@@ -896,7 +832,7 @@ export class AcpSessionManager {
|
||||
const { runtime, handle } = await this.ensureRuntimeHandle({
|
||||
cfg: input.cfg,
|
||||
sessionKey,
|
||||
meta: resolution.meta,
|
||||
meta,
|
||||
});
|
||||
await withAcpRuntimeErrorBoundary({
|
||||
run: async () =>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { SessionAcpMeta } from "../../config/sessions/types.js";
|
||||
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import { ACP_ERROR_CODES, AcpRuntimeError } from "../runtime/errors.js";
|
||||
import type { AcpSessionResolution } from "./manager.types.js";
|
||||
|
||||
export function resolveAcpAgentFromSessionKey(sessionKey: string, fallback = "main"): string {
|
||||
const parsed = parseAgentSessionKey(sessionKey);
|
||||
@@ -15,6 +16,28 @@ export function resolveMissingMetaError(sessionKey: string): AcpRuntimeError {
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveAcpSessionResolutionError(
|
||||
resolution: AcpSessionResolution,
|
||||
): AcpRuntimeError | null {
|
||||
if (resolution.kind === "ready") {
|
||||
return null;
|
||||
}
|
||||
if (resolution.kind === "stale") {
|
||||
return resolution.error;
|
||||
}
|
||||
return new AcpRuntimeError(
|
||||
"ACP_SESSION_INIT_FAILED",
|
||||
`Session is not ACP-enabled: ${resolution.sessionKey}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function requireReadySessionMeta(resolution: AcpSessionResolution): SessionAcpMeta {
|
||||
if (resolution.kind === "ready") {
|
||||
return resolution.meta;
|
||||
}
|
||||
throw resolveAcpSessionResolutionError(resolution);
|
||||
}
|
||||
|
||||
export function normalizeSessionKey(sessionKey: string): string {
|
||||
return sessionKey.trim();
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ const hoisted = vi.hoisted(() => {
|
||||
const initializeSessionMock = vi.fn();
|
||||
const startAcpSpawnParentStreamRelayMock = vi.fn();
|
||||
const resolveAcpSpawnStreamLogPathMock = vi.fn();
|
||||
const loadSessionStoreMock = vi.fn();
|
||||
const resolveStorePathMock = vi.fn();
|
||||
const resolveSessionTranscriptFileMock = vi.fn();
|
||||
const state = {
|
||||
cfg: createDefaultSpawnConfig(),
|
||||
};
|
||||
@@ -49,6 +52,9 @@ const hoisted = vi.hoisted(() => {
|
||||
initializeSessionMock,
|
||||
startAcpSpawnParentStreamRelayMock,
|
||||
resolveAcpSpawnStreamLogPathMock,
|
||||
loadSessionStoreMock,
|
||||
resolveStorePathMock,
|
||||
resolveSessionTranscriptFileMock,
|
||||
state,
|
||||
};
|
||||
});
|
||||
@@ -86,6 +92,24 @@ vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: (opts: unknown) => hoisted.callGatewayMock(opts),
|
||||
}));
|
||||
|
||||
vi.mock("../config/sessions.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/sessions.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadSessionStore: (storePath: string) => hoisted.loadSessionStoreMock(storePath),
|
||||
resolveStorePath: (store: unknown, opts: unknown) => hoisted.resolveStorePathMock(store, opts),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../config/sessions/transcript.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/sessions/transcript.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveSessionTranscriptFile: (params: unknown) =>
|
||||
hoisted.resolveSessionTranscriptFileMock(params),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../acp/control-plane/manager.js", () => {
|
||||
return {
|
||||
getAcpSessionManager: () => ({
|
||||
@@ -263,6 +287,34 @@ describe("spawnAcpDirect", () => {
|
||||
hoisted.resolveAcpSpawnStreamLogPathMock
|
||||
.mockReset()
|
||||
.mockReturnValue("/tmp/sess-main.acp-stream.jsonl");
|
||||
hoisted.resolveStorePathMock.mockReset().mockReturnValue("/tmp/codex-sessions.json");
|
||||
hoisted.loadSessionStoreMock.mockReset().mockImplementation(() => {
|
||||
const store: Record<string, { sessionId: string; updatedAt: number }> = {};
|
||||
return new Proxy(store, {
|
||||
get(_target, prop) {
|
||||
if (typeof prop === "string" && prop.startsWith("agent:codex:acp:")) {
|
||||
return { sessionId: "sess-123", updatedAt: Date.now() };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
});
|
||||
hoisted.resolveSessionTranscriptFileMock
|
||||
.mockReset()
|
||||
.mockImplementation(async (params: unknown) => {
|
||||
const typed = params as { threadId?: string };
|
||||
const sessionFile = typed.threadId
|
||||
? `/tmp/agents/codex/sessions/sess-123-topic-${typed.threadId}.jsonl`
|
||||
: "/tmp/agents/codex/sessions/sess-123.jsonl";
|
||||
return {
|
||||
sessionFile,
|
||||
sessionEntry: {
|
||||
sessionId: "sess-123",
|
||||
updatedAt: Date.now(),
|
||||
sessionFile,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
it("spawns ACP session, binds a new thread, and dispatches initial task", async () => {
|
||||
@@ -286,6 +338,13 @@ describe("spawnAcpDirect", () => {
|
||||
expect(result.childSessionKey).toMatch(/^agent:codex:acp:/);
|
||||
expect(result.runId).toBe("run-1");
|
||||
expect(result.mode).toBe("session");
|
||||
const patchCalls = hoisted.callGatewayMock.mock.calls
|
||||
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
|
||||
.filter((request) => request.method === "sessions.patch");
|
||||
expect(patchCalls[0]?.params).toMatchObject({
|
||||
key: result.childSessionKey,
|
||||
spawnedBy: "agent:main:main",
|
||||
});
|
||||
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
targetKind: "session",
|
||||
@@ -308,6 +367,12 @@ describe("spawnAcpDirect", () => {
|
||||
mode: "persistent",
|
||||
}),
|
||||
);
|
||||
const transcriptCalls = hoisted.resolveSessionTranscriptFileMock.mock.calls.map(
|
||||
(call: unknown[]) => call[0] as { threadId?: string },
|
||||
);
|
||||
expect(transcriptCalls).toHaveLength(2);
|
||||
expect(transcriptCalls[0]?.threadId).toBeUndefined();
|
||||
expect(transcriptCalls[1]?.threadId).toBe("child-thread");
|
||||
});
|
||||
|
||||
it("does not inline delivery for fresh oneshot ACP runs", async () => {
|
||||
@@ -328,6 +393,13 @@ describe("spawnAcpDirect", () => {
|
||||
|
||||
expect(result.status).toBe("accepted");
|
||||
expect(result.mode).toBe("run");
|
||||
expect(hoisted.resolveSessionTranscriptFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "sess-123",
|
||||
storePath: "/tmp/codex-sessions.json",
|
||||
agentId: "codex",
|
||||
}),
|
||||
);
|
||||
const agentCall = hoisted.callGatewayMock.mock.calls
|
||||
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
|
||||
.find((request) => request.method === "agent");
|
||||
@@ -337,6 +409,32 @@ describe("spawnAcpDirect", () => {
|
||||
expect(agentCall?.params?.threadId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps ACP spawn running when session-file persistence fails", async () => {
|
||||
hoisted.resolveSessionTranscriptFileMock.mockRejectedValueOnce(new Error("disk full"));
|
||||
|
||||
const result = await spawnAcpDirect(
|
||||
{
|
||||
task: "Investigate flaky tests",
|
||||
agentId: "codex",
|
||||
mode: "run",
|
||||
},
|
||||
{
|
||||
agentSessionKey: "agent:main:main",
|
||||
agentChannel: "telegram",
|
||||
agentAccountId: "default",
|
||||
agentTo: "telegram:6098642967",
|
||||
agentThreadId: "1",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe("accepted");
|
||||
expect(result.childSessionKey).toMatch(/^agent:codex:acp:/);
|
||||
const agentCall = hoisted.callGatewayMock.mock.calls
|
||||
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
|
||||
.find((request) => request.method === "agent");
|
||||
expect(agentCall?.params?.sessionKey).toBe(result.childSessionKey);
|
||||
});
|
||||
|
||||
it("includes cwd in ACP thread intro banner when provided at spawn time", async () => {
|
||||
const result = await spawnAcpDirect(
|
||||
{
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
} from "../channels/thread-bindings-policy.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loadSessionStore, resolveStorePath, type SessionEntry } from "../config/sessions.js";
|
||||
import { resolveSessionTranscriptFile } from "../config/sessions/transcript.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js";
|
||||
import {
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
isSessionBindingError,
|
||||
type SessionBindingRecord,
|
||||
} from "../infra/outbound/session-binding-service.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
|
||||
import {
|
||||
@@ -38,6 +41,9 @@ import {
|
||||
startAcpSpawnParentStreamRelay,
|
||||
} from "./acp-spawn-parent-stream.js";
|
||||
import { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js";
|
||||
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./tools/sessions-helpers.js";
|
||||
|
||||
const log = createSubsystemLogger("agents/acp-spawn");
|
||||
|
||||
export const ACP_SPAWN_MODES = ["run", "session"] as const;
|
||||
export type SpawnAcpMode = (typeof ACP_SPAWN_MODES)[number];
|
||||
@@ -162,6 +168,50 @@ function summarizeError(err: unknown): string {
|
||||
return "error";
|
||||
}
|
||||
|
||||
function resolveRequesterInternalSessionKey(params: {
|
||||
cfg: OpenClawConfig;
|
||||
requesterSessionKey?: string;
|
||||
}): string {
|
||||
const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
|
||||
const requesterSessionKey = params.requesterSessionKey?.trim();
|
||||
return requesterSessionKey
|
||||
? resolveInternalSessionKey({
|
||||
key: requesterSessionKey,
|
||||
alias,
|
||||
mainKey,
|
||||
})
|
||||
: alias;
|
||||
}
|
||||
|
||||
async function persistAcpSpawnSessionFileBestEffort(params: {
|
||||
sessionId: string;
|
||||
sessionKey: string;
|
||||
sessionEntry: SessionEntry | undefined;
|
||||
sessionStore: Record<string, SessionEntry>;
|
||||
storePath: string;
|
||||
agentId: string;
|
||||
threadId?: string | number;
|
||||
stage: "spawn" | "thread-bind";
|
||||
}): Promise<SessionEntry | undefined> {
|
||||
try {
|
||||
const resolvedSessionFile = await resolveSessionTranscriptFile({
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionEntry: params.sessionEntry,
|
||||
sessionStore: params.sessionStore,
|
||||
storePath: params.storePath,
|
||||
agentId: params.agentId,
|
||||
threadId: params.threadId,
|
||||
});
|
||||
return resolvedSessionFile.sessionEntry;
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`ACP session-file persistence failed during ${params.stage} for ${params.sessionKey}: ${summarizeError(error)}`,
|
||||
);
|
||||
return params.sessionEntry;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConversationIdForThreadBinding(params: {
|
||||
to?: string;
|
||||
threadId?: string | number;
|
||||
@@ -257,6 +307,10 @@ export async function spawnAcpDirect(
|
||||
ctx: SpawnAcpContext,
|
||||
): Promise<SpawnAcpResult> {
|
||||
const cfg = loadConfig();
|
||||
const requesterInternalKey = resolveRequesterInternalSessionKey({
|
||||
cfg,
|
||||
requesterSessionKey: ctx.agentSessionKey,
|
||||
});
|
||||
if (!isAcpEnabledByPolicy(cfg)) {
|
||||
return {
|
||||
status: "forbidden",
|
||||
@@ -346,11 +400,27 @@ export async function spawnAcpDirect(
|
||||
method: "sessions.patch",
|
||||
params: {
|
||||
key: sessionKey,
|
||||
spawnedBy: requesterInternalKey,
|
||||
...(params.label ? { label: params.label } : {}),
|
||||
},
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
sessionCreated = true;
|
||||
const storePath = resolveStorePath(cfg.session?.store, { agentId: targetAgentId });
|
||||
const sessionStore = loadSessionStore(storePath);
|
||||
let sessionEntry: SessionEntry | undefined = sessionStore[sessionKey];
|
||||
const sessionId = sessionEntry?.sessionId;
|
||||
if (sessionId) {
|
||||
sessionEntry = await persistAcpSpawnSessionFileBestEffort({
|
||||
sessionId,
|
||||
sessionKey,
|
||||
sessionStore,
|
||||
storePath,
|
||||
sessionEntry,
|
||||
agentId: targetAgentId,
|
||||
stage: "spawn",
|
||||
});
|
||||
}
|
||||
const initialized = await acpManager.initializeSession({
|
||||
cfg,
|
||||
sessionKey,
|
||||
@@ -408,6 +478,21 @@ export async function spawnAcpDirect(
|
||||
`Failed to create and bind a ${preparedBinding.channel} thread for this ACP session.`,
|
||||
);
|
||||
}
|
||||
if (sessionId) {
|
||||
const boundThreadId = String(binding.conversation.conversationId).trim() || undefined;
|
||||
if (boundThreadId) {
|
||||
sessionEntry = await persistAcpSpawnSessionFileBestEffort({
|
||||
sessionId,
|
||||
sessionKey,
|
||||
sessionStore,
|
||||
storePath,
|
||||
sessionEntry,
|
||||
agentId: targetAgentId,
|
||||
threadId: boundThreadId,
|
||||
stage: "thread-bind",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
await cleanupFailedAcpSpawn({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
CUSTOM_PROXY_MODELS_CONFIG,
|
||||
installModelsConfigTestHooks,
|
||||
unsetEnv,
|
||||
withModelsTempHome as withTempHome,
|
||||
@@ -14,33 +14,55 @@ installModelsConfigTestHooks();
|
||||
const TEST_ENV_VAR = "OPENCLAW_MODELS_CONFIG_TEST_ENV";
|
||||
|
||||
describe("models-config", () => {
|
||||
it("applies config env.vars entries while ensuring models.json", async () => {
|
||||
it("uses config env.vars entries for implicit provider discovery without mutating process.env", async () => {
|
||||
await withTempHome(async () => {
|
||||
await withTempEnv([TEST_ENV_VAR], async () => {
|
||||
unsetEnv([TEST_ENV_VAR]);
|
||||
await withTempEnv(["OPENROUTER_API_KEY", TEST_ENV_VAR], async () => {
|
||||
unsetEnv(["OPENROUTER_API_KEY", TEST_ENV_VAR]);
|
||||
const cfg: OpenClawConfig = {
|
||||
...CUSTOM_PROXY_MODELS_CONFIG,
|
||||
env: { vars: { [TEST_ENV_VAR]: "from-config" } },
|
||||
models: { providers: {} },
|
||||
env: {
|
||||
vars: {
|
||||
OPENROUTER_API_KEY: "from-config",
|
||||
[TEST_ENV_VAR]: "from-config",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await ensureOpenClawModelsJson(cfg);
|
||||
const { agentDir } = await ensureOpenClawModelsJson(cfg);
|
||||
|
||||
expect(process.env[TEST_ENV_VAR]).toBe("from-config");
|
||||
expect(process.env.OPENROUTER_API_KEY).toBeUndefined();
|
||||
expect(process.env[TEST_ENV_VAR]).toBeUndefined();
|
||||
|
||||
const modelsJson = JSON.parse(await fs.readFile(`${agentDir}/models.json`, "utf8")) as {
|
||||
providers?: { openrouter?: { apiKey?: string } };
|
||||
};
|
||||
expect(modelsJson.providers?.openrouter?.apiKey).toBe("OPENROUTER_API_KEY");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("does not overwrite already-set host env vars", async () => {
|
||||
it("does not overwrite already-set host env vars while ensuring models.json", async () => {
|
||||
await withTempHome(async () => {
|
||||
await withTempEnv([TEST_ENV_VAR], async () => {
|
||||
await withTempEnv(["OPENROUTER_API_KEY", TEST_ENV_VAR], async () => {
|
||||
process.env.OPENROUTER_API_KEY = "from-host";
|
||||
process.env[TEST_ENV_VAR] = "from-host";
|
||||
const cfg: OpenClawConfig = {
|
||||
...CUSTOM_PROXY_MODELS_CONFIG,
|
||||
env: { vars: { [TEST_ENV_VAR]: "from-config" } },
|
||||
models: { providers: {} },
|
||||
env: {
|
||||
vars: {
|
||||
OPENROUTER_API_KEY: "from-config",
|
||||
[TEST_ENV_VAR]: "from-config",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await ensureOpenClawModelsJson(cfg);
|
||||
const { agentDir } = await ensureOpenClawModelsJson(cfg);
|
||||
|
||||
const modelsJson = JSON.parse(await fs.readFile(`${agentDir}/models.json`, "utf8")) as {
|
||||
providers?: { openrouter?: { apiKey?: string } };
|
||||
};
|
||||
expect(modelsJson.providers?.openrouter?.apiKey).toBe("OPENROUTER_API_KEY");
|
||||
expect(process.env.OPENROUTER_API_KEY).toBe("from-host");
|
||||
expect(process.env[TEST_ENV_VAR]).toBe("from-host");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, vi } from "vitest";
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
|
||||
import { resolveImplicitProviders } from "./models-config.providers.js";
|
||||
|
||||
export async function withModelsTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
return withTempHomeBase(fn, { prefix: "openclaw-models-" });
|
||||
@@ -106,6 +107,8 @@ export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [
|
||||
"TOGETHER_API_KEY",
|
||||
"VOLCANO_ENGINE_API_KEY",
|
||||
"BYTEPLUS_API_KEY",
|
||||
"KILOCODE_API_KEY",
|
||||
"KIMI_API_KEY",
|
||||
"KIMICODE_API_KEY",
|
||||
"GEMINI_API_KEY",
|
||||
"VENICE_API_KEY",
|
||||
@@ -123,6 +126,29 @@ export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [
|
||||
"AWS_SHARED_CREDENTIALS_FILE",
|
||||
];
|
||||
|
||||
export function snapshotImplicitProviderEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const source = env ?? process.env;
|
||||
const snapshot: NodeJS.ProcessEnv = {};
|
||||
|
||||
for (const envVar of MODELS_CONFIG_IMPLICIT_ENV_VARS) {
|
||||
const value = source[envVar];
|
||||
if (value !== undefined) {
|
||||
snapshot[envVar] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export async function resolveImplicitProvidersForTest(
|
||||
params: Parameters<typeof resolveImplicitProviders>[0],
|
||||
) {
|
||||
return await resolveImplicitProviders({
|
||||
...params,
|
||||
env: snapshotImplicitProviderEnv(params.env),
|
||||
});
|
||||
}
|
||||
|
||||
export const CUSTOM_PROXY_MODELS_CONFIG: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
NON_ENV_SECRETREF_MARKER,
|
||||
QWEN_OAUTH_MARKER,
|
||||
} from "./model-auth-markers.js";
|
||||
import { resolveImplicitProviders } from "./models-config.providers.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
|
||||
describe("models-config provider auth provenance", () => {
|
||||
it("persists env keyRef and tokenRef auth profiles as env var markers", async () => {
|
||||
@@ -41,7 +41,7 @@ describe("models-config provider auth provenance", () => {
|
||||
"utf8",
|
||||
);
|
||||
try {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} });
|
||||
expect(providers?.volcengine?.apiKey).toBe("VOLCANO_ENGINE_API_KEY");
|
||||
expect(providers?.["volcengine-plan"]?.apiKey).toBe("VOLCANO_ENGINE_API_KEY");
|
||||
expect(providers?.together?.apiKey).toBe("TOGETHER_API_KEY");
|
||||
@@ -78,7 +78,7 @@ describe("models-config provider auth provenance", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} });
|
||||
expect(providers?.byteplus?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
|
||||
expect(providers?.["byteplus-plan"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
|
||||
expect(providers?.together?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
|
||||
@@ -114,7 +114,7 @@ describe("models-config provider auth provenance", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} });
|
||||
expect(providers?.["minimax-portal"]?.apiKey).toBe(MINIMAX_OAUTH_MARKER);
|
||||
expect(providers?.["qwen-portal"]?.apiKey).toBe(QWEN_OAUTH_MARKER);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
|
||||
import { resolveImplicitProviders } from "./models-config.providers.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
|
||||
describe("cloudflare-ai-gateway profile provenance", () => {
|
||||
it("prefers env keyRef marker over runtime plaintext for persistence", async () => {
|
||||
@@ -37,7 +37,7 @@ describe("cloudflare-ai-gateway profile provenance", () => {
|
||||
"utf8",
|
||||
);
|
||||
try {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.["cloudflare-ai-gateway"]?.apiKey).toBe("CLOUDFLARE_AI_GATEWAY_API_KEY");
|
||||
} finally {
|
||||
envSnapshot.restore();
|
||||
@@ -70,7 +70,7 @@ describe("cloudflare-ai-gateway profile provenance", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.["cloudflare-ai-gateway"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
|
||||
import { resolveImplicitProviders } from "./models-config.providers.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
|
||||
describe("provider discovery auth marker guardrails", () => {
|
||||
let originalVitest: string | undefined;
|
||||
@@ -63,7 +63,7 @@ describe("provider discovery auth marker guardrails", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const providers = await resolveImplicitProviders({ agentDir, 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");
|
||||
|
||||
@@ -3,7 +3,8 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { buildKilocodeProvider, resolveImplicitProviders } from "./models-config.providers.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
import { buildKilocodeProvider } from "./models-config.providers.js";
|
||||
|
||||
const KILOCODE_MODEL_IDS = ["kilo/auto"];
|
||||
|
||||
@@ -14,7 +15,7 @@ describe("Kilo Gateway implicit provider", () => {
|
||||
process.env.KILOCODE_API_KEY = "test-key"; // pragma: allowlist secret
|
||||
|
||||
try {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.kilocode).toBeDefined();
|
||||
expect(providers?.kilocode?.models?.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
@@ -28,7 +29,7 @@ describe("Kilo Gateway implicit provider", () => {
|
||||
delete process.env.KILOCODE_API_KEY;
|
||||
|
||||
try {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.kilocode).toBeUndefined();
|
||||
} finally {
|
||||
envSnapshot.restore();
|
||||
|
||||
@@ -3,7 +3,8 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { buildKimiCodingProvider, resolveImplicitProviders } from "./models-config.providers.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
import { buildKimiCodingProvider } from "./models-config.providers.js";
|
||||
|
||||
describe("kimi-coding implicit provider (#22409)", () => {
|
||||
it("should include kimi-coding when KIMI_API_KEY is configured", async () => {
|
||||
@@ -12,7 +13,7 @@ describe("kimi-coding implicit provider (#22409)", () => {
|
||||
process.env.KIMI_API_KEY = "test-key"; // pragma: allowlist secret
|
||||
|
||||
try {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.["kimi-coding"]).toBeDefined();
|
||||
expect(providers?.["kimi-coding"]?.api).toBe("anthropic-messages");
|
||||
expect(providers?.["kimi-coding"]?.baseUrl).toBe("https://api.kimi.com/coding/");
|
||||
@@ -36,7 +37,7 @@ describe("kimi-coding implicit provider (#22409)", () => {
|
||||
delete process.env.KIMI_API_KEY;
|
||||
|
||||
try {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.["kimi-coding"]).toBeUndefined();
|
||||
} finally {
|
||||
envSnapshot.restore();
|
||||
|
||||
175
src/agents/models-config.providers.matrix.test.ts
Normal file
175
src/agents/models-config.providers.matrix.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
MINIMAX_OAUTH_MARKER,
|
||||
NON_ENV_SECRETREF_MARKER,
|
||||
OLLAMA_LOCAL_AUTH_MARKER,
|
||||
} from "./model-auth-markers.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
|
||||
type ProvidersMap = Awaited<ReturnType<typeof resolveImplicitProvidersForTest>>;
|
||||
type ExplicitProviders = NonNullable<NonNullable<OpenClawConfig["models"]>["providers"]>;
|
||||
type MatrixCase = {
|
||||
name: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
authProfiles?: Record<string, unknown>;
|
||||
explicitProviders?: ExplicitProviders;
|
||||
assertProviders: (providers: ProvidersMap) => void;
|
||||
};
|
||||
|
||||
async function writeAuthProfiles(
|
||||
agentDir: string,
|
||||
profiles: Record<string, unknown> | undefined,
|
||||
): Promise<void> {
|
||||
if (!profiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
await writeFile(
|
||||
join(agentDir, "auth-profiles.json"),
|
||||
JSON.stringify({ version: 1, profiles }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
const MATRIX_CASES: MatrixCase[] = [
|
||||
{
|
||||
name: "env api key injects a simple provider",
|
||||
env: { NVIDIA_API_KEY: "test-nvidia-key" },
|
||||
assertProviders(providers) {
|
||||
expect(providers?.nvidia?.apiKey).toBe("NVIDIA_API_KEY");
|
||||
expect(providers?.nvidia?.baseUrl).toBe("https://integrate.api.nvidia.com/v1");
|
||||
expect(providers?.nvidia?.models?.length).toBeGreaterThan(0);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "env api key injects paired plan providers",
|
||||
env: { VOLCANO_ENGINE_API_KEY: "test-volcengine-key" },
|
||||
assertProviders(providers) {
|
||||
expect(providers?.volcengine?.apiKey).toBe("VOLCANO_ENGINE_API_KEY");
|
||||
expect(providers?.["volcengine-plan"]?.apiKey).toBe("VOLCANO_ENGINE_API_KEY");
|
||||
expect(providers?.["volcengine-plan"]?.api).toBe("openai-completions");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "env-backed auth profiles persist env markers",
|
||||
env: {},
|
||||
authProfiles: {
|
||||
"together:default": {
|
||||
type: "token",
|
||||
provider: "together",
|
||||
tokenRef: { source: "env", provider: "default", id: "TOGETHER_API_KEY" },
|
||||
},
|
||||
},
|
||||
assertProviders(providers) {
|
||||
expect(providers?.together?.apiKey).toBe("TOGETHER_API_KEY");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non-env secret refs preserve compatibility markers",
|
||||
env: {},
|
||||
authProfiles: {
|
||||
"byteplus:default": {
|
||||
type: "api_key",
|
||||
provider: "byteplus",
|
||||
key: "runtime-byteplus-key",
|
||||
keyRef: { source: "file", provider: "vault", id: "/byteplus/apiKey" },
|
||||
},
|
||||
},
|
||||
assertProviders(providers) {
|
||||
expect(providers?.byteplus?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
|
||||
expect(providers?.["byteplus-plan"]?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "oauth profiles still inject compatibility providers",
|
||||
env: {},
|
||||
authProfiles: {
|
||||
"openai-codex:default": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "codex-access-token",
|
||||
refresh: "codex-refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
"minimax-portal:default": {
|
||||
type: "oauth",
|
||||
provider: "minimax-portal",
|
||||
access: "minimax-access-token",
|
||||
refresh: "minimax-refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
assertProviders(providers) {
|
||||
expect(providers?.["openai-codex"]).toMatchObject({
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
api: "openai-codex-responses",
|
||||
models: [],
|
||||
});
|
||||
expect(providers?.["openai-codex"]).not.toHaveProperty("apiKey");
|
||||
expect(providers?.["minimax-portal"]?.apiKey).toBe(MINIMAX_OAUTH_MARKER);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explicit vllm config suppresses implicit vllm injection",
|
||||
env: { VLLM_API_KEY: "test-vllm-key" },
|
||||
explicitProviders: {
|
||||
vllm: {
|
||||
baseUrl: "http://127.0.0.1:8000/v1",
|
||||
api: "openai-completions",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
assertProviders(providers) {
|
||||
expect(providers?.vllm).toBeUndefined();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explicit ollama models still normalize the returned provider",
|
||||
env: {},
|
||||
explicitProviders: {
|
||||
ollama: {
|
||||
baseUrl: "http://remote-ollama:11434/v1",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-oss:20b",
|
||||
name: "GPT-OSS 20B",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 81920,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
assertProviders(providers) {
|
||||
expect(providers?.ollama?.baseUrl).toBe("http://remote-ollama:11434");
|
||||
expect(providers?.ollama?.api).toBe("ollama");
|
||||
expect(providers?.ollama?.apiKey).toBe(OLLAMA_LOCAL_AUTH_MARKER);
|
||||
expect(providers?.ollama?.models).toHaveLength(1);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("implicit provider resolution matrix", () => {
|
||||
it.each(MATRIX_CASES)(
|
||||
"$name",
|
||||
async ({ env, authProfiles, explicitProviders, assertProviders }) => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
await writeAuthProfiles(agentDir, authProfiles);
|
||||
|
||||
const providers = await resolveImplicitProvidersForTest({
|
||||
agentDir,
|
||||
env,
|
||||
explicitProviders,
|
||||
});
|
||||
|
||||
assertProviders(providers);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveImplicitProviders } from "./models-config.providers.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
|
||||
describe("minimax provider catalog", () => {
|
||||
it("does not advertise the removed lightning model for api-key or oauth providers", async () => {
|
||||
@@ -34,7 +34,7 @@ describe("minimax provider catalog", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.minimax?.models?.map((model) => model.id)).toEqual([
|
||||
"MiniMax-VL-01",
|
||||
"MiniMax-M2.5",
|
||||
|
||||
@@ -5,13 +5,14 @@ import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { resolveApiKeyForProvider } from "./model-auth.js";
|
||||
import { buildNvidiaProvider, resolveImplicitProviders } from "./models-config.providers.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
import { buildNvidiaProvider } from "./models-config.providers.js";
|
||||
|
||||
describe("NVIDIA provider", () => {
|
||||
it("should include nvidia when NVIDIA_API_KEY is configured", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
await withEnvAsync({ NVIDIA_API_KEY: "test-key" }, async () => {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.nvidia).toBeDefined();
|
||||
expect(providers?.nvidia?.models?.length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -52,7 +53,7 @@ describe("MiniMax implicit provider (#15275)", () => {
|
||||
it("should use anthropic-messages API for API-key provider", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
await withEnvAsync({ MINIMAX_API_KEY: "test-key" }, async () => {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.minimax).toBeDefined();
|
||||
expect(providers?.minimax?.api).toBe("anthropic-messages");
|
||||
expect(providers?.minimax?.authHeader).toBe(true);
|
||||
@@ -83,14 +84,14 @@ describe("MiniMax implicit provider (#15275)", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.["minimax-portal"]?.authHeader).toBe(true);
|
||||
});
|
||||
|
||||
it("should include minimax portal provider when MINIMAX_OAUTH_TOKEN is configured", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
await withEnvAsync({ MINIMAX_OAUTH_TOKEN: "portal-token" }, async () => {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.["minimax-portal"]).toBeDefined();
|
||||
expect(providers?.["minimax-portal"]?.authHeader).toBe(true);
|
||||
expect(providers?.["minimax-portal"]?.models?.some((m) => m.id === "MiniMax-VL-01")).toBe(
|
||||
@@ -104,7 +105,7 @@ describe("vLLM provider", () => {
|
||||
it("should not include vllm when no API key is configured", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
await withEnvAsync({ VLLM_API_KEY: undefined }, async () => {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.vllm).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -112,7 +113,7 @@ describe("vLLM provider", () => {
|
||||
it("should include vllm when VLLM_API_KEY is set", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
await withEnvAsync({ VLLM_API_KEY: "test-key" }, async () => {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
|
||||
expect(providers?.vllm).toBeDefined();
|
||||
expect(providers?.vllm?.apiKey).toBe("VLLM_API_KEY");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveImplicitProviders } from "./models-config.providers.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
|
||||
describe("Ollama auto-discovery", () => {
|
||||
let originalVitest: string | undefined;
|
||||
@@ -55,7 +55,7 @@ describe("Ollama auto-discovery", () => {
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
|
||||
expect(providers?.ollama).toBeDefined();
|
||||
expect(providers?.ollama?.apiKey).toBe("ollama-local");
|
||||
@@ -73,7 +73,7 @@ describe("Ollama auto-discovery", () => {
|
||||
mockOllamaUnreachable();
|
||||
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
|
||||
expect(providers?.ollama).toBeUndefined();
|
||||
const ollamaWarnings = warnSpy.mock.calls.filter(
|
||||
@@ -89,7 +89,7 @@ describe("Ollama auto-discovery", () => {
|
||||
mockOllamaUnreachable();
|
||||
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
await resolveImplicitProviders({
|
||||
await resolveImplicitProvidersForTest({
|
||||
agentDir,
|
||||
explicitProviders: {
|
||||
ollama: {
|
||||
|
||||
@@ -3,7 +3,8 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ModelDefinitionConfig } from "../config/types.models.js";
|
||||
import { resolveImplicitProviders, resolveOllamaApiBase } from "./models-config.providers.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
import { resolveOllamaApiBase } from "./models-config.providers.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
@@ -60,7 +61,7 @@ describe("Ollama provider", () => {
|
||||
}
|
||||
|
||||
async function resolveProvidersWithOllamaKey(agentDir: string) {
|
||||
return await withOllamaApiKey(async () => await resolveImplicitProviders({ agentDir }));
|
||||
return await withOllamaApiKey(async () => await resolveImplicitProvidersForTest({ agentDir }));
|
||||
}
|
||||
|
||||
const createTagModel = (name: string) => ({ name, modified_at: "", size: 1, digest: "" });
|
||||
@@ -78,7 +79,7 @@ describe("Ollama provider", () => {
|
||||
|
||||
it("should not include ollama when no API key is configured", async () => {
|
||||
const agentDir = createAgentDir();
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
|
||||
expect(providers?.ollama).toBeUndefined();
|
||||
});
|
||||
@@ -86,7 +87,7 @@ describe("Ollama provider", () => {
|
||||
it("should use native ollama api type", async () => {
|
||||
const agentDir = createAgentDir();
|
||||
await withOllamaApiKey(async () => {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
|
||||
expect(providers?.ollama).toBeDefined();
|
||||
expect(providers?.ollama?.apiKey).toBe("OLLAMA_API_KEY");
|
||||
@@ -98,7 +99,7 @@ describe("Ollama provider", () => {
|
||||
it("should preserve explicit ollama baseUrl on implicit provider injection", async () => {
|
||||
const agentDir = createAgentDir();
|
||||
await withOllamaApiKey(async () => {
|
||||
const providers = await resolveImplicitProviders({
|
||||
const providers = await resolveImplicitProvidersForTest({
|
||||
agentDir,
|
||||
explicitProviders: {
|
||||
ollama: {
|
||||
@@ -239,7 +240,7 @@ describe("Ollama provider", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const providers = await resolveImplicitProviders({
|
||||
const providers = await resolveImplicitProvidersForTest({
|
||||
agentDir,
|
||||
explicitProviders: {
|
||||
ollama: {
|
||||
@@ -264,7 +265,7 @@ describe("Ollama provider", () => {
|
||||
it("should preserve explicit apiKey when discovery path has no models and no env key", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
|
||||
const providers = await resolveImplicitProviders({
|
||||
const providers = await resolveImplicitProvidersForTest({
|
||||
agentDir,
|
||||
explicitProviders: {
|
||||
ollama: {
|
||||
|
||||
@@ -5,12 +5,12 @@ import { resolveOpenClawAgentDir } from "./agent-paths.js";
|
||||
import {
|
||||
installModelsConfigTestHooks,
|
||||
MODELS_CONFIG_IMPLICIT_ENV_VARS,
|
||||
resolveImplicitProvidersForTest,
|
||||
unsetEnv,
|
||||
withModelsTempHome,
|
||||
withTempEnv,
|
||||
} from "./models-config.e2e-harness.js";
|
||||
import { ensureOpenClawModelsJson } from "./models-config.js";
|
||||
import { resolveImplicitProviders } from "./models-config.providers.js";
|
||||
import { readGeneratedModelsJson } from "./models-config.test-utils.js";
|
||||
|
||||
installModelsConfigTestHooks();
|
||||
@@ -50,7 +50,7 @@ describe("openai-codex implicit provider", () => {
|
||||
const agentDir = resolveOpenClawAgentDir();
|
||||
await writeCodexOauthProfile(agentDir);
|
||||
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.["openai-codex"]).toMatchObject({
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
api: "openai-codex-responses",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { resolveImplicitProviders } from "./models-config.providers.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
|
||||
const qianfanApiKeyEnv = ["QIANFAN_API", "KEY"].join("_");
|
||||
|
||||
@@ -13,7 +13,7 @@ describe("Qianfan provider", () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const qianfanApiKey = "test-key"; // pragma: allowlist secret
|
||||
await withEnvAsync({ [qianfanApiKeyEnv]: qianfanApiKey }, async () => {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.qianfan).toBeDefined();
|
||||
expect(providers?.qianfan?.apiKey).toBe("QIANFAN_API_KEY");
|
||||
});
|
||||
|
||||
440
src/agents/models-config.providers.static.ts
Normal file
440
src/agents/models-config.providers.static.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
KILOCODE_BASE_URL,
|
||||
KILOCODE_DEFAULT_CONTEXT_WINDOW,
|
||||
KILOCODE_DEFAULT_COST,
|
||||
KILOCODE_DEFAULT_MAX_TOKENS,
|
||||
KILOCODE_MODEL_CATALOG,
|
||||
} from "../providers/kilocode-shared.js";
|
||||
import {
|
||||
buildBytePlusModelDefinition,
|
||||
BYTEPLUS_BASE_URL,
|
||||
BYTEPLUS_MODEL_CATALOG,
|
||||
BYTEPLUS_CODING_BASE_URL,
|
||||
BYTEPLUS_CODING_MODEL_CATALOG,
|
||||
} from "./byteplus-models.js";
|
||||
import {
|
||||
buildDoubaoModelDefinition,
|
||||
DOUBAO_BASE_URL,
|
||||
DOUBAO_MODEL_CATALOG,
|
||||
DOUBAO_CODING_BASE_URL,
|
||||
DOUBAO_CODING_MODEL_CATALOG,
|
||||
} from "./doubao-models.js";
|
||||
import {
|
||||
buildSyntheticModelDefinition,
|
||||
SYNTHETIC_BASE_URL,
|
||||
SYNTHETIC_MODEL_CATALOG,
|
||||
} from "./synthetic-models.js";
|
||||
import {
|
||||
TOGETHER_BASE_URL,
|
||||
TOGETHER_MODEL_CATALOG,
|
||||
buildTogetherModelDefinition,
|
||||
} from "./together-models.js";
|
||||
|
||||
type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
|
||||
type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
|
||||
type ProviderModelConfig = NonNullable<ProviderConfig["models"]>[number];
|
||||
|
||||
const MINIMAX_PORTAL_BASE_URL = "https://api.minimax.io/anthropic";
|
||||
const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.5";
|
||||
const MINIMAX_DEFAULT_VISION_MODEL_ID = "MiniMax-VL-01";
|
||||
const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000;
|
||||
const MINIMAX_DEFAULT_MAX_TOKENS = 8192;
|
||||
const MINIMAX_API_COST = {
|
||||
input: 0.3,
|
||||
output: 1.2,
|
||||
cacheRead: 0.03,
|
||||
cacheWrite: 0.12,
|
||||
};
|
||||
|
||||
function buildMinimaxModel(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
input: ProviderModelConfig["input"];
|
||||
}): ProviderModelConfig {
|
||||
return {
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
reasoning: params.reasoning,
|
||||
input: params.input,
|
||||
cost: MINIMAX_API_COST,
|
||||
contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: MINIMAX_DEFAULT_MAX_TOKENS,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMinimaxTextModel(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
}): ProviderModelConfig {
|
||||
return buildMinimaxModel({ ...params, input: ["text"] });
|
||||
}
|
||||
|
||||
const XIAOMI_BASE_URL = "https://api.xiaomimimo.com/anthropic";
|
||||
export const XIAOMI_DEFAULT_MODEL_ID = "mimo-v2-flash";
|
||||
const XIAOMI_DEFAULT_CONTEXT_WINDOW = 262144;
|
||||
const XIAOMI_DEFAULT_MAX_TOKENS = 8192;
|
||||
const XIAOMI_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1";
|
||||
const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2.5";
|
||||
const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000;
|
||||
const MOONSHOT_DEFAULT_MAX_TOKENS = 8192;
|
||||
const MOONSHOT_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/";
|
||||
const KIMI_CODING_DEFAULT_MODEL_ID = "k2p5";
|
||||
const KIMI_CODING_DEFAULT_CONTEXT_WINDOW = 262144;
|
||||
const KIMI_CODING_DEFAULT_MAX_TOKENS = 32768;
|
||||
const KIMI_CODING_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
const QWEN_PORTAL_BASE_URL = "https://portal.qwen.ai/v1";
|
||||
const QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW = 128000;
|
||||
const QWEN_PORTAL_DEFAULT_MAX_TOKENS = 8192;
|
||||
const QWEN_PORTAL_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
||||
const OPENROUTER_DEFAULT_MODEL_ID = "auto";
|
||||
const OPENROUTER_DEFAULT_CONTEXT_WINDOW = 200000;
|
||||
const OPENROUTER_DEFAULT_MAX_TOKENS = 8192;
|
||||
const OPENROUTER_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
export const QIANFAN_BASE_URL = "https://qianfan.baidubce.com/v2";
|
||||
export const QIANFAN_DEFAULT_MODEL_ID = "deepseek-v3.2";
|
||||
const QIANFAN_DEFAULT_CONTEXT_WINDOW = 98304;
|
||||
const QIANFAN_DEFAULT_MAX_TOKENS = 32768;
|
||||
const QIANFAN_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
|
||||
const NVIDIA_DEFAULT_MODEL_ID = "nvidia/llama-3.1-nemotron-70b-instruct";
|
||||
const NVIDIA_DEFAULT_CONTEXT_WINDOW = 131072;
|
||||
const NVIDIA_DEFAULT_MAX_TOKENS = 4096;
|
||||
const NVIDIA_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
||||
|
||||
export function buildMinimaxProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: MINIMAX_PORTAL_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
authHeader: true,
|
||||
models: [
|
||||
buildMinimaxModel({
|
||||
id: MINIMAX_DEFAULT_VISION_MODEL_ID,
|
||||
name: "MiniMax VL 01",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
}),
|
||||
buildMinimaxTextModel({
|
||||
id: "MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
reasoning: true,
|
||||
}),
|
||||
buildMinimaxTextModel({
|
||||
id: "MiniMax-M2.5-highspeed",
|
||||
name: "MiniMax M2.5 Highspeed",
|
||||
reasoning: true,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMinimaxPortalProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: MINIMAX_PORTAL_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
authHeader: true,
|
||||
models: [
|
||||
buildMinimaxModel({
|
||||
id: MINIMAX_DEFAULT_VISION_MODEL_ID,
|
||||
name: "MiniMax VL 01",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
}),
|
||||
buildMinimaxTextModel({
|
||||
id: MINIMAX_DEFAULT_MODEL_ID,
|
||||
name: "MiniMax M2.5",
|
||||
reasoning: true,
|
||||
}),
|
||||
buildMinimaxTextModel({
|
||||
id: "MiniMax-M2.5-highspeed",
|
||||
name: "MiniMax M2.5 Highspeed",
|
||||
reasoning: true,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMoonshotProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: MOONSHOT_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: MOONSHOT_DEFAULT_MODEL_ID,
|
||||
name: "Kimi K2.5",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: MOONSHOT_DEFAULT_COST,
|
||||
contextWindow: MOONSHOT_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: MOONSHOT_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildKimiCodingProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: KIMI_CODING_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: KIMI_CODING_DEFAULT_MODEL_ID,
|
||||
name: "Kimi for Coding",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: KIMI_CODING_DEFAULT_COST,
|
||||
contextWindow: KIMI_CODING_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: KIMI_CODING_DEFAULT_MAX_TOKENS,
|
||||
compat: {
|
||||
requiresOpenAiAnthropicToolPayload: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildQwenPortalProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: QWEN_PORTAL_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "coder-model",
|
||||
name: "Qwen Coder",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: QWEN_PORTAL_DEFAULT_COST,
|
||||
contextWindow: QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: QWEN_PORTAL_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
{
|
||||
id: "vision-model",
|
||||
name: "Qwen Vision",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: QWEN_PORTAL_DEFAULT_COST,
|
||||
contextWindow: QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: QWEN_PORTAL_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSyntheticProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: SYNTHETIC_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
models: SYNTHETIC_MODEL_CATALOG.map(buildSyntheticModelDefinition),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDoubaoProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: DOUBAO_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: DOUBAO_MODEL_CATALOG.map(buildDoubaoModelDefinition),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDoubaoCodingProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: DOUBAO_CODING_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: DOUBAO_CODING_MODEL_CATALOG.map(buildDoubaoModelDefinition),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBytePlusProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: BYTEPLUS_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: BYTEPLUS_MODEL_CATALOG.map(buildBytePlusModelDefinition),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBytePlusCodingProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: BYTEPLUS_CODING_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: BYTEPLUS_CODING_MODEL_CATALOG.map(buildBytePlusModelDefinition),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildXiaomiProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: XIAOMI_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: XIAOMI_DEFAULT_MODEL_ID,
|
||||
name: "Xiaomi MiMo V2 Flash",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: XIAOMI_DEFAULT_COST,
|
||||
contextWindow: XIAOMI_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: XIAOMI_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTogetherProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: TOGETHER_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: TOGETHER_MODEL_CATALOG.map(buildTogetherModelDefinition),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOpenrouterProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: OPENROUTER_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: OPENROUTER_DEFAULT_MODEL_ID,
|
||||
name: "OpenRouter Auto",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: OPENROUTER_DEFAULT_COST,
|
||||
contextWindow: OPENROUTER_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: OPENROUTER_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOpenAICodexProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: OPENAI_CODEX_BASE_URL,
|
||||
api: "openai-codex-responses",
|
||||
models: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildQianfanProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: QIANFAN_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: QIANFAN_DEFAULT_MODEL_ID,
|
||||
name: "DEEPSEEK V3.2",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: QIANFAN_DEFAULT_COST,
|
||||
contextWindow: QIANFAN_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: QIANFAN_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
{
|
||||
id: "ernie-5.0-thinking-preview",
|
||||
name: "ERNIE-5.0-Thinking-Preview",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: QIANFAN_DEFAULT_COST,
|
||||
contextWindow: 119000,
|
||||
maxTokens: 64000,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNvidiaProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: NVIDIA_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: NVIDIA_DEFAULT_MODEL_ID,
|
||||
name: "NVIDIA Llama 3.1 Nemotron 70B Instruct",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: NVIDIA_DEFAULT_COST,
|
||||
contextWindow: NVIDIA_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: NVIDIA_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
{
|
||||
id: "meta/llama-3.3-70b-instruct",
|
||||
name: "Meta Llama 3.3 70B Instruct",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: NVIDIA_DEFAULT_COST,
|
||||
contextWindow: 131072,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
{
|
||||
id: "nvidia/mistral-nemo-minitron-8b-8k-instruct",
|
||||
name: "NVIDIA Mistral NeMo Minitron 8B Instruct",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: NVIDIA_DEFAULT_COST,
|
||||
contextWindow: 8192,
|
||||
maxTokens: 2048,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildKilocodeProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: KILOCODE_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: KILOCODE_MODEL_CATALOG.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
reasoning: model.reasoning,
|
||||
input: model.input,
|
||||
cost: KILOCODE_DEFAULT_COST,
|
||||
contextWindow: model.contextWindow ?? KILOCODE_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: model.maxTokens ?? KILOCODE_DEFAULT_MAX_TOKENS,
|
||||
})),
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
319
src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts
Normal file
319
src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts
Normal 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");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
113
src/agents/pi-embedded-runner/moonshot-stream-wrappers.ts
Normal file
113
src/agents/pi-embedded-runner/moonshot-stream-wrappers.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
257
src/agents/pi-embedded-runner/openai-stream-wrappers.ts
Normal file
257
src/agents/pi-embedded-runner/openai-stream-wrappers.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
145
src/agents/pi-embedded-runner/proxy-stream-wrappers.ts
Normal file
145
src/agents/pi-embedded-runner/proxy-stream-wrappers.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")}`);
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
100
src/browser/server-context.loopback-direct-ws.test.ts
Normal file
100
src/browser/server-context.loopback-direct-ws.test.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
? (() => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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(() => ({}));
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
34
src/commands/doctor-config-analysis.test.ts
Normal file
34
src/commands/doctor-config-analysis.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
152
src/commands/doctor-config-analysis.ts
Normal file
152
src/commands/doctor-config-analysis.ts
Normal 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",
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
60
src/commands/setup.test.ts
Normal file
60
src/commands/setup.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -26,6 +26,7 @@ export type ModelCompatConfig = {
|
||||
requiresAssistantAfterToolResult?: boolean;
|
||||
requiresThinkingAsText?: boolean;
|
||||
requiresMistralToolIds?: boolean;
|
||||
requiresOpenAiAnthropicToolPayload?: boolean;
|
||||
};
|
||||
|
||||
export type ModelProviderAuthMode = "api-key" | "aws-sdk" | "oauth" | "token";
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user