Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7d11f6c33 | |||
| 6f048f59cb | |||
| b9efba1faf | |||
| 4dcc39c25c | |||
| 6e012d7feb | |||
| 2df10e81c8 | |||
| 9d3d7f9e65 | |||
| a0ad47440a | |||
| 3ff2f85bad | |||
| 203eebec2f | |||
| 399fae33ca | |||
| 8067ae50fa |
@@ -16,6 +16,15 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
|
||||
- Pass `--json` for machine-readable summaries.
|
||||
- Per-phase logs land under `/tmp/openclaw-parallels-*`.
|
||||
- Do not run local and gateway agent turns in parallel on the same fresh workspace or session.
|
||||
- For `prlctl exec`, pass the VM name before `--current-user` (`prlctl exec "$VM" --current-user ...`), not the other way around.
|
||||
|
||||
## npm install then update
|
||||
|
||||
- Preferred entrypoint: `pnpm test:parallels:npm-update`
|
||||
- Flow: fresh snapshot -> install npm package baseline -> smoke -> install current main tgz on the same guest -> smoke again.
|
||||
- Same-guest update verification should set the default model explicitly to `openai/gpt-5.4` before the agent turn and use a fresh explicit `--session-id` so old session model state does not leak into the check.
|
||||
- On Windows same-guest update checks, restart the gateway after the npm upgrade before `gateway status` / `agent`; in-place global npm updates can otherwise leave stale hashed `dist/*` module imports alive in the running service.
|
||||
- Linux same-guest update verification should also export `HOME=/root`, pass `OPENAI_API_KEY` via `prlctl exec ... /usr/bin/env`, and use `openclaw agent --local`; the fresh Linux baseline does not rely on persisted gateway credentials.
|
||||
|
||||
## macOS flow
|
||||
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openclaw",
|
||||
"version": "2026.3.22-beta.1",
|
||||
"version": "2026.3.22",
|
||||
"description": "Multi-channel AI gateway with extensible messaging integrations",
|
||||
"keywords": [],
|
||||
"homepage": "https://github.com/openclaw/openclaw#readme",
|
||||
@@ -724,6 +724,7 @@
|
||||
"test:macmini": "OPENCLAW_TEST_PROFILE=macmini node scripts/test-parallel.mjs",
|
||||
"test:parallels:linux": "bash scripts/e2e/parallels-linux-smoke.sh",
|
||||
"test:parallels:macos": "bash scripts/e2e/parallels-macos-smoke.sh",
|
||||
"test:parallels:npm-update": "bash scripts/e2e/parallels-npm-update-smoke.sh",
|
||||
"test:parallels:windows": "bash scripts/e2e/parallels-windows-smoke.sh",
|
||||
"test:perf:budget": "node scripts/test-perf-budget.mjs",
|
||||
"test:perf:find-thread-candidates": "node scripts/test-find-thread-candidates.mjs",
|
||||
|
||||
Executable
+377
@@ -0,0 +1,377 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
|
||||
MACOS_VM="macOS Tahoe"
|
||||
WINDOWS_VM="Windows 11"
|
||||
LINUX_VM="Ubuntu 25.10"
|
||||
OPENAI_API_KEY_ENV="OPENAI_API_KEY"
|
||||
PACKAGE_SPEC=""
|
||||
JSON_OUTPUT=0
|
||||
RUN_DIR="$(mktemp -d /tmp/openclaw-parallels-npm-update.XXXXXX)"
|
||||
MAIN_TGZ_DIR="$(mktemp -d)"
|
||||
MAIN_TGZ_PATH=""
|
||||
SERVER_PID=""
|
||||
HOST_IP=""
|
||||
HOST_PORT=""
|
||||
LATEST_VERSION=""
|
||||
CURRENT_HEAD=""
|
||||
CURRENT_HEAD_SHORT=""
|
||||
OPENAI_API_KEY_VALUE=""
|
||||
|
||||
MACOS_FRESH_STATUS="skip"
|
||||
WINDOWS_FRESH_STATUS="skip"
|
||||
LINUX_FRESH_STATUS="skip"
|
||||
MACOS_UPDATE_STATUS="skip"
|
||||
WINDOWS_UPDATE_STATUS="skip"
|
||||
LINUX_UPDATE_STATUS="skip"
|
||||
MACOS_UPDATE_VERSION="skip"
|
||||
WINDOWS_UPDATE_VERSION="skip"
|
||||
LINUX_UPDATE_VERSION="skip"
|
||||
|
||||
say() {
|
||||
printf '==> %s\n' "$*"
|
||||
}
|
||||
|
||||
warn() {
|
||||
printf 'warn: %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
die() {
|
||||
printf 'error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "${SERVER_PID:-}" ]]; then
|
||||
kill "$SERVER_PID" >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -rf "$MAIN_TGZ_DIR"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: bash scripts/e2e/parallels-npm-update-smoke.sh [options]
|
||||
|
||||
Options:
|
||||
--package-spec <npm-spec> Baseline npm package spec. Default: openclaw@latest
|
||||
--openai-api-key-env <var> Host env var name for OpenAI API key. Default: OPENAI_API_KEY
|
||||
--json Print machine-readable JSON summary.
|
||||
-h, --help Show help.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--package-spec)
|
||||
PACKAGE_SPEC="$2"
|
||||
shift 2
|
||||
;;
|
||||
--openai-api-key-env)
|
||||
OPENAI_API_KEY_ENV="$2"
|
||||
shift 2
|
||||
;;
|
||||
--json)
|
||||
JSON_OUTPUT=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
die "unknown arg: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
OPENAI_API_KEY_VALUE="${!OPENAI_API_KEY_ENV:-}"
|
||||
[[ -n "$OPENAI_API_KEY_VALUE" ]] || die "$OPENAI_API_KEY_ENV is required"
|
||||
|
||||
resolve_latest_version() {
|
||||
npm view openclaw version --userconfig "$(mktemp)"
|
||||
}
|
||||
|
||||
resolve_host_ip() {
|
||||
local detected
|
||||
detected="$(ifconfig | awk '/inet 10\.211\./ { print $2; exit }')"
|
||||
[[ -n "$detected" ]] || die "failed to detect Parallels host IP"
|
||||
printf '%s\n' "$detected"
|
||||
}
|
||||
|
||||
allocate_host_port() {
|
||||
python3 - <<'PY'
|
||||
import socket
|
||||
|
||||
sock = socket.socket()
|
||||
sock.bind(("0.0.0.0", 0))
|
||||
print(sock.getsockname()[1])
|
||||
sock.close()
|
||||
PY
|
||||
}
|
||||
|
||||
ensure_current_build() {
|
||||
say "Build dist for current head"
|
||||
pnpm build
|
||||
}
|
||||
|
||||
pack_main_tgz() {
|
||||
local pkg
|
||||
CURRENT_HEAD="$(git rev-parse HEAD)"
|
||||
CURRENT_HEAD_SHORT="$(git rev-parse --short=7 HEAD)"
|
||||
ensure_current_build
|
||||
pkg="$(
|
||||
npm pack --ignore-scripts --json --pack-destination "$MAIN_TGZ_DIR" \
|
||||
| python3 -c 'import json, sys; data = json.load(sys.stdin); print(data[-1]["filename"])'
|
||||
)"
|
||||
MAIN_TGZ_PATH="$MAIN_TGZ_DIR/openclaw-main-$CURRENT_HEAD_SHORT.tgz"
|
||||
cp "$MAIN_TGZ_DIR/$pkg" "$MAIN_TGZ_PATH"
|
||||
}
|
||||
|
||||
start_server() {
|
||||
HOST_IP="$(resolve_host_ip)"
|
||||
HOST_PORT="$(allocate_host_port)"
|
||||
say "Serve current main tgz on $HOST_IP:$HOST_PORT"
|
||||
(
|
||||
cd "$MAIN_TGZ_DIR"
|
||||
exec python3 -m http.server "$HOST_PORT" --bind 0.0.0.0
|
||||
) >/tmp/openclaw-parallels-npm-update-http.log 2>&1 &
|
||||
SERVER_PID=$!
|
||||
sleep 1
|
||||
kill -0 "$SERVER_PID" >/dev/null 2>&1 || die "failed to start host HTTP server"
|
||||
}
|
||||
|
||||
wait_job() {
|
||||
local label="$1"
|
||||
local pid="$2"
|
||||
if wait "$pid"; then
|
||||
return 0
|
||||
fi
|
||||
warn "$label failed"
|
||||
return 1
|
||||
}
|
||||
|
||||
extract_last_version() {
|
||||
local log_path="$1"
|
||||
python3 - "$log_path" <<'PY'
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
text = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace")
|
||||
matches = re.findall(r"OpenClaw [^\r\n]+", text)
|
||||
print(matches[-1] if matches else "")
|
||||
PY
|
||||
}
|
||||
|
||||
guest_powershell() {
|
||||
local script="$1"
|
||||
local encoded
|
||||
encoded="$(
|
||||
SCRIPT_CONTENT="$script" python3 - <<'PY'
|
||||
import base64
|
||||
import os
|
||||
|
||||
script = "$ProgressPreference = 'SilentlyContinue'\n" + os.environ["SCRIPT_CONTENT"]
|
||||
payload = script.encode("utf-16le")
|
||||
print(base64.b64encode(payload).decode("ascii"))
|
||||
PY
|
||||
)"
|
||||
prlctl exec "$WINDOWS_VM" --current-user powershell.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand "$encoded"
|
||||
}
|
||||
|
||||
run_macos_update() {
|
||||
local tgz_url="$1"
|
||||
local head_short="$2"
|
||||
cat <<EOF | prlctl exec "$MACOS_VM" --current-user /usr/bin/tee /tmp/openclaw-main-update.sh >/dev/null
|
||||
set -euo pipefail
|
||||
export PATH=/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin
|
||||
if [ -z "\${HOME:-}" ]; then export HOME="/Users/\$(id -un)"; fi
|
||||
cd "\$HOME"
|
||||
curl -fsSL "$tgz_url" -o /tmp/openclaw-main-update.tgz
|
||||
/opt/homebrew/bin/npm install -g /tmp/openclaw-main-update.tgz
|
||||
version="\$(/opt/homebrew/bin/openclaw --version)"
|
||||
printf '%s\n' "\$version"
|
||||
case "\$version" in
|
||||
*"$head_short"*) ;;
|
||||
*)
|
||||
echo "version mismatch: expected substring $head_short" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
/opt/homebrew/bin/openclaw models set openai/gpt-5.4
|
||||
/opt/homebrew/bin/node /opt/homebrew/lib/node_modules/openclaw/openclaw.mjs gateway status --deep --require-rpc
|
||||
/opt/homebrew/bin/node /opt/homebrew/lib/node_modules/openclaw/openclaw.mjs agent --agent main --session-id parallels-npm-update-macos-$head_short --message "Reply with exact ASCII text OK only." --json
|
||||
EOF
|
||||
prlctl exec "$MACOS_VM" --current-user /bin/bash /tmp/openclaw-main-update.sh
|
||||
}
|
||||
|
||||
run_windows_update() {
|
||||
local tgz_url="$1"
|
||||
local head_short="$2"
|
||||
guest_powershell "$(cat <<EOF
|
||||
\$env:PATH = "\$env:LOCALAPPDATA\OpenClaw\deps\portable-git\cmd;\$env:LOCALAPPDATA\OpenClaw\deps\portable-git\mingw64\bin;\$env:LOCALAPPDATA\OpenClaw\deps\portable-git\usr\bin;\$env:PATH"
|
||||
\$tgz = Join-Path \$env:TEMP 'openclaw-main-update.tgz'
|
||||
curl.exe -fsSL '$tgz_url' -o \$tgz
|
||||
npm.cmd install -g \$tgz --no-fund --no-audit
|
||||
\$openclaw = Join-Path \$env:APPDATA 'npm\openclaw.cmd'
|
||||
\$version = & \$openclaw --version
|
||||
\$version
|
||||
if (\$version -notmatch '$head_short') {
|
||||
throw 'version mismatch: expected substring $head_short'
|
||||
}
|
||||
& \$openclaw models set openai/gpt-5.4
|
||||
# Windows can keep the old hashed dist modules alive across in-place global npm upgrades.
|
||||
# Restart the gateway/service before verifying status or the next agent turn.
|
||||
& \$openclaw gateway restart
|
||||
Start-Sleep -Seconds 5
|
||||
& \$openclaw gateway status --deep --require-rpc
|
||||
& \$openclaw agent --agent main --session-id parallels-npm-update-windows-$head_short --message 'Reply with exact ASCII text OK only.' --json
|
||||
EOF
|
||||
)"
|
||||
}
|
||||
|
||||
run_linux_update() {
|
||||
local tgz_url="$1"
|
||||
local head_short="$2"
|
||||
cat <<EOF | prlctl exec "$LINUX_VM" /usr/bin/tee /tmp/openclaw-main-update.sh >/dev/null
|
||||
set -euo pipefail
|
||||
export HOME=/root
|
||||
cd "\$HOME"
|
||||
curl -fsSL "$tgz_url" -o /tmp/openclaw-main-update.tgz
|
||||
npm install -g /tmp/openclaw-main-update.tgz --no-fund --no-audit
|
||||
version="\$(openclaw --version)"
|
||||
printf '%s\n' "\$version"
|
||||
case "\$version" in
|
||||
*"$head_short"*) ;;
|
||||
*)
|
||||
echo "version mismatch: expected substring $head_short" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
openclaw models set openai/gpt-5.4
|
||||
openclaw agent --local --agent main --session-id parallels-npm-update-linux-$head_short --message "Reply with exact ASCII text OK only." --json
|
||||
EOF
|
||||
prlctl exec "$LINUX_VM" /usr/bin/env "OPENAI_API_KEY=$OPENAI_API_KEY_VALUE" /bin/bash /tmp/openclaw-main-update.sh
|
||||
}
|
||||
|
||||
write_summary_json() {
|
||||
local summary_path="$RUN_DIR/summary.json"
|
||||
python3 - "$summary_path" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
summary = {
|
||||
"packageSpec": os.environ["SUMMARY_PACKAGE_SPEC"],
|
||||
"latestVersion": os.environ["SUMMARY_LATEST_VERSION"],
|
||||
"currentHead": os.environ["SUMMARY_CURRENT_HEAD"],
|
||||
"runDir": os.environ["SUMMARY_RUN_DIR"],
|
||||
"fresh": {
|
||||
"macos": {"status": os.environ["SUMMARY_MACOS_FRESH_STATUS"]},
|
||||
"windows": {"status": os.environ["SUMMARY_WINDOWS_FRESH_STATUS"]},
|
||||
"linux": {"status": os.environ["SUMMARY_LINUX_FRESH_STATUS"]},
|
||||
},
|
||||
"update": {
|
||||
"macos": {
|
||||
"status": os.environ["SUMMARY_MACOS_UPDATE_STATUS"],
|
||||
"version": os.environ["SUMMARY_MACOS_UPDATE_VERSION"],
|
||||
},
|
||||
"windows": {
|
||||
"status": os.environ["SUMMARY_WINDOWS_UPDATE_STATUS"],
|
||||
"version": os.environ["SUMMARY_WINDOWS_UPDATE_VERSION"],
|
||||
},
|
||||
"linux": {
|
||||
"status": os.environ["SUMMARY_LINUX_UPDATE_STATUS"],
|
||||
"version": os.environ["SUMMARY_LINUX_UPDATE_VERSION"],
|
||||
"mode": "local-with-openai-env",
|
||||
},
|
||||
},
|
||||
}
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
||||
json.dump(summary, handle, indent=2, sort_keys=True)
|
||||
print(sys.argv[1])
|
||||
PY
|
||||
}
|
||||
|
||||
LATEST_VERSION="$(resolve_latest_version)"
|
||||
if [[ -z "$PACKAGE_SPEC" ]]; then
|
||||
PACKAGE_SPEC="openclaw@$LATEST_VERSION"
|
||||
fi
|
||||
|
||||
say "Run fresh npm baseline: $PACKAGE_SPEC"
|
||||
bash "$ROOT_DIR/scripts/e2e/parallels-macos-smoke.sh" \
|
||||
--mode fresh \
|
||||
--target-package-spec "$PACKAGE_SPEC" \
|
||||
--json >"$RUN_DIR/macos-fresh.log" 2>&1 &
|
||||
macos_fresh_pid=$!
|
||||
|
||||
bash "$ROOT_DIR/scripts/e2e/parallels-windows-smoke.sh" \
|
||||
--mode fresh \
|
||||
--target-package-spec "$PACKAGE_SPEC" \
|
||||
--json >"$RUN_DIR/windows-fresh.log" 2>&1 &
|
||||
windows_fresh_pid=$!
|
||||
|
||||
bash "$ROOT_DIR/scripts/e2e/parallels-linux-smoke.sh" \
|
||||
--mode fresh \
|
||||
--target-package-spec "$PACKAGE_SPEC" \
|
||||
--json >"$RUN_DIR/linux-fresh.log" 2>&1 &
|
||||
linux_fresh_pid=$!
|
||||
|
||||
wait_job "macOS fresh" "$macos_fresh_pid" && MACOS_FRESH_STATUS="pass" || MACOS_FRESH_STATUS="fail"
|
||||
wait_job "Windows fresh" "$windows_fresh_pid" && WINDOWS_FRESH_STATUS="pass" || WINDOWS_FRESH_STATUS="fail"
|
||||
wait_job "Linux fresh" "$linux_fresh_pid" && LINUX_FRESH_STATUS="pass" || LINUX_FRESH_STATUS="fail"
|
||||
|
||||
[[ "$MACOS_FRESH_STATUS" == "pass" ]] || die "macOS fresh baseline failed"
|
||||
[[ "$WINDOWS_FRESH_STATUS" == "pass" ]] || die "Windows fresh baseline failed"
|
||||
[[ "$LINUX_FRESH_STATUS" == "pass" ]] || die "Linux fresh baseline failed"
|
||||
|
||||
pack_main_tgz
|
||||
start_server
|
||||
|
||||
tgz_url="http://$HOST_IP:$HOST_PORT/$(basename "$MAIN_TGZ_PATH")"
|
||||
|
||||
say "Run same-guest update to current main"
|
||||
run_macos_update "$tgz_url" "$CURRENT_HEAD_SHORT" >"$RUN_DIR/macos-update.log" 2>&1 &
|
||||
macos_update_pid=$!
|
||||
run_windows_update "$tgz_url" "$CURRENT_HEAD_SHORT" >"$RUN_DIR/windows-update.log" 2>&1 &
|
||||
windows_update_pid=$!
|
||||
run_linux_update "$tgz_url" "$CURRENT_HEAD_SHORT" >"$RUN_DIR/linux-update.log" 2>&1 &
|
||||
linux_update_pid=$!
|
||||
|
||||
wait_job "macOS update" "$macos_update_pid" && MACOS_UPDATE_STATUS="pass" || MACOS_UPDATE_STATUS="fail"
|
||||
wait_job "Windows update" "$windows_update_pid" && WINDOWS_UPDATE_STATUS="pass" || WINDOWS_UPDATE_STATUS="fail"
|
||||
wait_job "Linux update" "$linux_update_pid" && LINUX_UPDATE_STATUS="pass" || LINUX_UPDATE_STATUS="fail"
|
||||
|
||||
[[ "$MACOS_UPDATE_STATUS" == "pass" ]] || die "macOS update failed"
|
||||
[[ "$WINDOWS_UPDATE_STATUS" == "pass" ]] || die "Windows update failed"
|
||||
[[ "$LINUX_UPDATE_STATUS" == "pass" ]] || die "Linux update failed"
|
||||
|
||||
MACOS_UPDATE_VERSION="$(extract_last_version "$RUN_DIR/macos-update.log")"
|
||||
WINDOWS_UPDATE_VERSION="$(extract_last_version "$RUN_DIR/windows-update.log")"
|
||||
LINUX_UPDATE_VERSION="$(extract_last_version "$RUN_DIR/linux-update.log")"
|
||||
|
||||
SUMMARY_PACKAGE_SPEC="$PACKAGE_SPEC" \
|
||||
SUMMARY_LATEST_VERSION="$LATEST_VERSION" \
|
||||
SUMMARY_CURRENT_HEAD="$CURRENT_HEAD_SHORT" \
|
||||
SUMMARY_RUN_DIR="$RUN_DIR" \
|
||||
SUMMARY_MACOS_FRESH_STATUS="$MACOS_FRESH_STATUS" \
|
||||
SUMMARY_WINDOWS_FRESH_STATUS="$WINDOWS_FRESH_STATUS" \
|
||||
SUMMARY_LINUX_FRESH_STATUS="$LINUX_FRESH_STATUS" \
|
||||
SUMMARY_MACOS_UPDATE_STATUS="$MACOS_UPDATE_STATUS" \
|
||||
SUMMARY_WINDOWS_UPDATE_STATUS="$WINDOWS_UPDATE_STATUS" \
|
||||
SUMMARY_LINUX_UPDATE_STATUS="$LINUX_UPDATE_STATUS" \
|
||||
SUMMARY_MACOS_UPDATE_VERSION="$MACOS_UPDATE_VERSION" \
|
||||
SUMMARY_WINDOWS_UPDATE_VERSION="$WINDOWS_UPDATE_VERSION" \
|
||||
SUMMARY_LINUX_UPDATE_VERSION="$LINUX_UPDATE_VERSION" \
|
||||
write_summary_json >/dev/null
|
||||
|
||||
if [[ "$JSON_OUTPUT" -eq 1 ]]; then
|
||||
cat "$RUN_DIR/summary.json"
|
||||
else
|
||||
say "Run dir: $RUN_DIR"
|
||||
cat "$RUN_DIR/summary.json"
|
||||
fi
|
||||
@@ -13,6 +13,15 @@ const LIVE = isLiveTestEnabled(["BYTEPLUS_LIVE_TEST"]);
|
||||
|
||||
const describeLive = LIVE && BYTEPLUS_KEY ? describe : describe.skip;
|
||||
|
||||
function isBytePlusSubscriptionError(message: string): boolean {
|
||||
const lower = message.toLowerCase();
|
||||
return (
|
||||
lower.includes("coding plan subscription") ||
|
||||
lower.includes("subscription has expired") ||
|
||||
(lower.includes("subscription") && lower.includes("renewal"))
|
||||
);
|
||||
}
|
||||
|
||||
describeLive("byteplus coding plan live", () => {
|
||||
it("returns assistant text", async () => {
|
||||
const model: Model<"openai-completions"> = {
|
||||
@@ -36,6 +45,15 @@ describeLive("byteplus coding plan live", () => {
|
||||
{ apiKey: BYTEPLUS_KEY, maxTokens: 64 },
|
||||
);
|
||||
|
||||
if (res.stopReason === "error") {
|
||||
const message = res.errorMessage ?? "";
|
||||
if (isBytePlusSubscriptionError(message)) {
|
||||
expect(message.toLowerCase()).toContain("subscription");
|
||||
return;
|
||||
}
|
||||
throw new Error(message || "byteplus returned error with no message");
|
||||
}
|
||||
|
||||
const text = extractNonEmptyAssistantText(res.content);
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
}, 30000);
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { ModelProviderAuthMode, ModelProviderConfig } from "../config/types
|
||||
import { coerceSecretRef } from "../config/types.secrets.js";
|
||||
import { getShellEnvAppliedKeys } from "../infra/shell-env.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { buildProviderMissingAuthMessageWithPlugin } from "../plugins/provider-runtime.runtime.js";
|
||||
import { buildProviderMissingAuthMessageWithPlugin } from "../plugins/provider-runtime.js";
|
||||
import { resolveOwningPluginIdsForProvider } from "../plugins/providers.js";
|
||||
import {
|
||||
normalizeOptionalSecretInput,
|
||||
@@ -369,7 +369,7 @@ export async function resolveApiKeyForProvider(params: {
|
||||
})
|
||||
: undefined;
|
||||
if (owningPluginIds?.length) {
|
||||
const pluginMissingAuthMessage = await buildProviderMissingAuthMessageWithPlugin({
|
||||
const pluginMissingAuthMessage = buildProviderMissingAuthMessageWithPlugin({
|
||||
provider,
|
||||
config: cfg,
|
||||
context: {
|
||||
|
||||
@@ -253,7 +253,11 @@ describeGeminiLive("pi embedded extra params (gemini live)", () => {
|
||||
const thinkingConfig = (
|
||||
capturedPayload?.config as { thinkingConfig?: Record<string, unknown> } | undefined
|
||||
)?.thinkingConfig;
|
||||
expect(thinkingConfig?.thinkingBudget).toBeUndefined();
|
||||
const thinkingBudget = thinkingConfig?.thinkingBudget;
|
||||
if (thinkingBudget !== undefined) {
|
||||
expect(typeof thinkingBudget).toBe("number");
|
||||
expect(thinkingBudget).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
expect(thinkingConfig?.thinkingLevel).toBe("HIGH");
|
||||
|
||||
const imagePart = (
|
||||
|
||||
+129
-71
@@ -4,14 +4,12 @@ import path from "node:path";
|
||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { registerLogTransport, resetLogger, setLoggerOverride } from "../logging/logger.js";
|
||||
import { redactIdentifier } from "../logging/redact-identifier.js";
|
||||
import type { AuthProfileFailureReason } from "./auth-profiles.js";
|
||||
import type { EmbeddedRunAttemptResult } from "./pi-embedded-runner/run/types.js";
|
||||
|
||||
const runEmbeddedAttemptMock = vi.fn<(params: unknown) => Promise<EmbeddedRunAttemptResult>>();
|
||||
const resolveCopilotApiTokenMock = vi.fn();
|
||||
const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token";
|
||||
const { computeBackoffMock, sleepWithAbortMock } = vi.hoisted(() => ({
|
||||
computeBackoffMock: vi.fn(
|
||||
(
|
||||
@@ -22,63 +20,121 @@ const { computeBackoffMock, sleepWithAbortMock } = vi.hoisted(() => ({
|
||||
sleepWithAbortMock: vi.fn(async (_ms: number, _abortSignal?: AbortSignal) => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("./pi-embedded-runner/run/attempt.js", () => ({
|
||||
runEmbeddedAttempt: (params: unknown) => runEmbeddedAttemptMock(params),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/backoff.js", () => ({
|
||||
computeBackoff: (
|
||||
policy: { initialMs: number; maxMs: number; factor: number; jitter: number },
|
||||
attempt: number,
|
||||
) => computeBackoffMock(policy, attempt),
|
||||
sleepWithAbort: (ms: number, abortSignal?: AbortSignal) => sleepWithAbortMock(ms, abortSignal),
|
||||
}));
|
||||
|
||||
vi.mock("../../extensions/github-copilot/token.js", () => ({
|
||||
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||
resolveCopilotApiToken: (...args: unknown[]) => resolveCopilotApiTokenMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./pi-embedded-runner/compact.js", () => ({
|
||||
compactEmbeddedPiSessionDirect: vi.fn(async () => {
|
||||
throw new Error("compact should not run in auth profile rotation tests");
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./models-config.js", async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import("./models-config.js")>();
|
||||
return {
|
||||
...mod,
|
||||
ensureOpenClawModelsJson: vi.fn(async () => ({ wrote: false })),
|
||||
};
|
||||
});
|
||||
const installRunEmbeddedMocks = () => {
|
||||
vi.doMock("../plugins/hook-runner-global.js", () => ({
|
||||
getGlobalHookRunner: vi.fn(() => undefined),
|
||||
}));
|
||||
vi.doMock("../context-engine/index.js", () => ({
|
||||
ensureContextEnginesInitialized: vi.fn(),
|
||||
resolveContextEngine: vi.fn(async () => ({
|
||||
dispose: async () => undefined,
|
||||
})),
|
||||
}));
|
||||
vi.doMock("./runtime-plugins.js", () => ({
|
||||
ensureRuntimePluginsLoaded: vi.fn(),
|
||||
}));
|
||||
vi.doMock("./pi-embedded-runner/model.js", () => ({
|
||||
resolveModelAsync: async (provider: string, modelId: string) => ({
|
||||
model: {
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
api: "openai-responses",
|
||||
provider,
|
||||
baseUrl:
|
||||
provider === "github-copilot" ? "https://api.copilot.example" : "https://example.com",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 16_000,
|
||||
maxTokens: 2048,
|
||||
},
|
||||
error: undefined,
|
||||
authStorage: {
|
||||
setRuntimeApiKey: vi.fn(),
|
||||
},
|
||||
modelRegistry: {},
|
||||
}),
|
||||
}));
|
||||
vi.doMock("./pi-embedded-runner/run/attempt.js", () => ({
|
||||
runEmbeddedAttempt: (params: unknown) => runEmbeddedAttemptMock(params),
|
||||
}));
|
||||
vi.doMock("../plugins/provider-runtime.runtime.js", () => ({
|
||||
prepareProviderRuntimeAuth: async (params: {
|
||||
provider: string;
|
||||
context: { apiKey: string };
|
||||
}) => {
|
||||
if (params.provider !== "github-copilot") {
|
||||
return undefined;
|
||||
}
|
||||
const token = await resolveCopilotApiTokenMock(params.context.apiKey);
|
||||
return {
|
||||
apiKey: token.token,
|
||||
baseUrl: token.baseUrl,
|
||||
expiresAt: token.expiresAt,
|
||||
};
|
||||
},
|
||||
}));
|
||||
vi.doMock("../infra/backoff.js", () => ({
|
||||
computeBackoff: (
|
||||
policy: { initialMs: number; maxMs: number; factor: number; jitter: number },
|
||||
attempt: number,
|
||||
) => computeBackoffMock(policy, attempt),
|
||||
sleepWithAbort: (ms: number, abortSignal?: AbortSignal) => sleepWithAbortMock(ms, abortSignal),
|
||||
}));
|
||||
vi.doMock("./pi-embedded-runner/compact.js", () => ({
|
||||
compactEmbeddedPiSessionDirect: vi.fn(async () => {
|
||||
throw new Error("compact should not run in auth profile rotation tests");
|
||||
}),
|
||||
}));
|
||||
vi.doMock("./models-config.js", async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import("./models-config.js")>();
|
||||
return {
|
||||
...mod,
|
||||
ensureOpenClawModelsJson: vi.fn(async () => ({ wrote: false })),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner/run.js").runEmbeddedPiAgent;
|
||||
let unregisterLogTransport: (() => void) | undefined;
|
||||
let registerLogTransportFn: typeof import("../logging/logger.js").registerLogTransport;
|
||||
let resetLoggerFn: typeof import("../logging/logger.js").resetLogger;
|
||||
let setLoggerOverrideFn: typeof import("../logging/logger.js").setLoggerOverride;
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.resetModules();
|
||||
installRunEmbeddedMocks();
|
||||
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner/run.js"));
|
||||
({
|
||||
registerLogTransport: registerLogTransportFn,
|
||||
resetLogger: resetLoggerFn,
|
||||
setLoggerOverride: setLoggerOverrideFn,
|
||||
} = await import("../logging/logger.js"));
|
||||
});
|
||||
|
||||
async function runEmbeddedPiAgentInline(
|
||||
params: Parameters<typeof runEmbeddedPiAgent>[0],
|
||||
): Promise<Awaited<ReturnType<typeof runEmbeddedPiAgent>>> {
|
||||
return await runEmbeddedPiAgent({
|
||||
...params,
|
||||
enqueue: async (task) => await task(),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
runEmbeddedAttemptMock.mockClear();
|
||||
runEmbeddedAttemptMock.mockReset();
|
||||
runEmbeddedAttemptMock.mockImplementation(async () => {
|
||||
throw new Error("unexpected extra runEmbeddedAttempt call");
|
||||
});
|
||||
resolveCopilotApiTokenMock.mockReset();
|
||||
resolveCopilotApiTokenMock.mockImplementation(async () => {
|
||||
throw new Error("unexpected extra Copilot token refresh");
|
||||
});
|
||||
globalThis.fetch = vi.fn(async (input: string | URL | Request) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
if (url !== COPILOT_TOKEN_URL) {
|
||||
throw new Error(`Unexpected fetch in test: ${url}`);
|
||||
}
|
||||
const token = await resolveCopilotApiTokenMock();
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
token: token.token,
|
||||
expires_at: Math.floor(token.expiresAt / 1000),
|
||||
}),
|
||||
} as Response;
|
||||
throw new Error(`Unexpected fetch in test: ${url}`);
|
||||
}) as typeof fetch;
|
||||
computeBackoffMock.mockClear();
|
||||
sleepWithAbortMock.mockClear();
|
||||
@@ -88,8 +144,8 @@ afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
unregisterLogTransport?.();
|
||||
unregisterLogTransport = undefined;
|
||||
setLoggerOverride(null);
|
||||
resetLogger();
|
||||
setLoggerOverrideFn(null);
|
||||
resetLoggerFn();
|
||||
});
|
||||
|
||||
const baseUsage = {
|
||||
@@ -324,7 +380,7 @@ async function runAutoPinnedOpenAiTurn(params: {
|
||||
runId: string;
|
||||
authProfileId?: string;
|
||||
}) {
|
||||
await runEmbeddedPiAgent({
|
||||
await runEmbeddedPiAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: params.sessionKey,
|
||||
sessionFile: path.join(params.workspaceDir, "session.jsonl"),
|
||||
@@ -368,7 +424,7 @@ async function runAutoPinnedRotationCase(params: {
|
||||
sessionKey: string;
|
||||
runId: string;
|
||||
}) {
|
||||
runEmbeddedAttemptMock.mockClear();
|
||||
runEmbeddedAttemptMock.mockReset();
|
||||
return withAgentWorkspace(async ({ agentDir, workspaceDir }) => {
|
||||
await writeAuthStore(agentDir);
|
||||
mockFailedThenSuccessfulAttempt(params.errorMessage);
|
||||
@@ -390,7 +446,7 @@ async function runAutoPinnedPromptErrorRotationCase(params: {
|
||||
sessionKey: string;
|
||||
runId: string;
|
||||
}) {
|
||||
runEmbeddedAttemptMock.mockClear();
|
||||
runEmbeddedAttemptMock.mockReset();
|
||||
return withAgentWorkspace(async ({ agentDir, workspaceDir }) => {
|
||||
await writeAuthStore(agentDir);
|
||||
mockPromptErrorThenSuccessfulAttempt(params.errorMessage);
|
||||
@@ -486,7 +542,7 @@ async function runTurnWithCooldownSeed(params: {
|
||||
});
|
||||
mockSingleSuccessfulAttempt();
|
||||
|
||||
await runEmbeddedPiAgent({
|
||||
await runEmbeddedPiAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: params.sessionKey,
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
@@ -518,7 +574,9 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
resolveCopilotApiTokenMock
|
||||
.mockResolvedValueOnce({
|
||||
token: "copilot-initial",
|
||||
expiresAt: now + 2 * 60 * 1000,
|
||||
// Keep expiry beyond the runtime refresh margin so the test only
|
||||
// exercises auth-error refresh, not the background scheduler.
|
||||
expiresAt: now + 10 * 60 * 1000,
|
||||
source: "mock",
|
||||
baseUrl: "https://api.copilot.example",
|
||||
})
|
||||
@@ -549,7 +607,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await runEmbeddedPiAgent({
|
||||
await runEmbeddedPiAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:copilot-auth-error",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
@@ -582,13 +640,14 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
resolveCopilotApiTokenMock
|
||||
.mockResolvedValueOnce({
|
||||
token: "copilot-initial",
|
||||
expiresAt: now + 2 * 60 * 1000,
|
||||
// Avoid an immediate scheduled refresh racing the explicit auth retry.
|
||||
expiresAt: now + 10 * 60 * 1000,
|
||||
source: "mock",
|
||||
baseUrl: "https://api.copilot.example",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
token: "copilot-refresh-1",
|
||||
expiresAt: now + 4 * 60 * 1000,
|
||||
expiresAt: now + 10 * 60 * 1000,
|
||||
source: "mock",
|
||||
baseUrl: "https://api.copilot.example",
|
||||
})
|
||||
@@ -633,7 +692,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await runEmbeddedPiAgent({
|
||||
await runEmbeddedPiAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:copilot-auth-repeat",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
@@ -647,7 +706,6 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
timeoutMs: 5_000,
|
||||
runId: "run:copilot-auth-repeat",
|
||||
});
|
||||
|
||||
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(4);
|
||||
expect(resolveCopilotApiTokenMock).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
@@ -682,7 +740,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const runPromise = runEmbeddedPiAgent({
|
||||
const runPromise = runEmbeddedPiAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:copilot-shutdown",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
@@ -744,12 +802,12 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
|
||||
it("logs structured failover decision metadata for overloaded assistant rotation", async () => {
|
||||
const records: Array<Record<string, unknown>> = [];
|
||||
setLoggerOverride({
|
||||
setLoggerOverrideFn({
|
||||
level: "trace",
|
||||
consoleLevel: "silent",
|
||||
file: path.join(os.tmpdir(), `openclaw-auth-rotation-${Date.now()}.log`),
|
||||
});
|
||||
unregisterLogTransport = registerLogTransport((record) => {
|
||||
unregisterLogTransport = registerLogTransportFn((record) => {
|
||||
records.push(record);
|
||||
});
|
||||
|
||||
@@ -858,7 +916,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runEmbeddedPiAgent({
|
||||
const result = await runEmbeddedPiAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:compaction-timeout",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
@@ -887,7 +945,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
|
||||
mockSingleErrorAttempt({ errorMessage: "rate limit" });
|
||||
|
||||
await runEmbeddedPiAgent({
|
||||
await runEmbeddedPiAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:user",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
@@ -935,7 +993,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await runEmbeddedPiAgent({
|
||||
await runEmbeddedPiAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:mismatch",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
@@ -977,7 +1035,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
runEmbeddedPiAgent({
|
||||
runEmbeddedPiAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:cooldown-failover",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
@@ -1021,7 +1079,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runEmbeddedPiAgent({
|
||||
const result = await runEmbeddedPiAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:cooldown-probe",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
@@ -1069,7 +1127,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runEmbeddedPiAgent({
|
||||
const result = await runEmbeddedPiAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:overloaded-cooldown-probe",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
@@ -1117,7 +1175,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runEmbeddedPiAgent({
|
||||
const result = await runEmbeddedPiAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:billing-cooldown-probe-no-fallbacks",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
@@ -1148,7 +1206,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
runEmbeddedPiAgent({
|
||||
runEmbeddedPiAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:support:cooldown-failover",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
@@ -1193,7 +1251,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
runEmbeddedPiAgent({
|
||||
runEmbeddedPiAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:disabled-failover",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
@@ -1227,7 +1285,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
await fs.writeFile(authPath, JSON.stringify({ version: 1, profiles: {}, usageStats: {} }));
|
||||
|
||||
await expect(
|
||||
runEmbeddedPiAgent({
|
||||
runEmbeddedPiAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:auth-unavailable",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
@@ -1265,7 +1323,7 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await runEmbeddedPiAgent({
|
||||
await runEmbeddedPiAgentInline({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:billing-failover-active-model",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
resolveTelegramReactionLevel,
|
||||
} from "../../plugin-sdk/telegram.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
import { prepareProviderRuntimeAuth } from "../../plugins/provider-runtime.runtime.js";
|
||||
import { prepareProviderRuntimeAuth } from "../../plugins/provider-runtime.js";
|
||||
import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
|
||||
import { isCronSessionKey, isSubagentSessionKey } from "../../routing/session-key.js";
|
||||
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createProviderRuntimeTestMock } from "./model.provider-runtime.test-support.js";
|
||||
|
||||
vi.mock("../pi-model-discovery.js", () => ({
|
||||
discoverAuthStorage: vi.fn(() => ({ mocked: true })),
|
||||
discoverModels: vi.fn(() => ({ find: vi.fn(() => null) })),
|
||||
}));
|
||||
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import {
|
||||
expectResolvedForwardCompatFallbackResult,
|
||||
expectUnknownModelErrorResult,
|
||||
} from "./model.forward-compat.test-support.js";
|
||||
import { resolveModel } from "./model.js";
|
||||
import {
|
||||
makeModel,
|
||||
mockDiscoveredModel,
|
||||
mockOpenAICodexTemplateModel,
|
||||
resetMockDiscoverModels,
|
||||
} from "./model.test-harness.js";
|
||||
|
||||
beforeEach(() => {
|
||||
resetMockDiscoverModels();
|
||||
});
|
||||
|
||||
function createRuntimeHooks() {
|
||||
return createProviderRuntimeTestMock({
|
||||
handledDynamicProviders: ["anthropic", "zai", "openai-codex"],
|
||||
});
|
||||
}
|
||||
|
||||
function resolveModelForTest(
|
||||
provider: string,
|
||||
modelId: string,
|
||||
agentDir?: string,
|
||||
cfg?: OpenClawConfig,
|
||||
) {
|
||||
return resolveModel(provider, modelId, agentDir, cfg, {
|
||||
runtimeHooks: createRuntimeHooks(),
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveModel forward-compat errors and overrides", () => {
|
||||
it("resolves supported antigravity thinking model ids", () => {
|
||||
expectResolvedForwardCompatFallbackResult({
|
||||
result: resolveModelForTest("google-antigravity", "claude-opus-4-6-thinking", "/tmp/agent"),
|
||||
expectedModel: {
|
||||
provider: "google-antigravity",
|
||||
id: "claude-opus-4-6-thinking",
|
||||
api: "google-gemini-cli",
|
||||
reasoning: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unknown-model errors when no antigravity non-thinking template exists", () => {
|
||||
expectUnknownModelErrorResult(
|
||||
resolveModelForTest("google-antigravity", "claude-opus-4-6", "/tmp/agent"),
|
||||
"google-antigravity",
|
||||
"claude-opus-4-6",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps unknown-model errors for non-gpt-5 openai-codex ids", () => {
|
||||
expectUnknownModelErrorResult(
|
||||
resolveModelForTest("openai-codex", "gpt-4.1-mini", "/tmp/agent"),
|
||||
"openai-codex",
|
||||
"gpt-4.1-mini",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects direct openai gpt-5.3-codex-spark with a codex-only hint", () => {
|
||||
const result = resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent");
|
||||
|
||||
expect(result.model).toBeUndefined();
|
||||
expect(result.error).toBe(
|
||||
"Unknown model: openai/gpt-5.3-codex-spark. gpt-5.3-codex-spark is only supported via openai-codex OAuth. Use openai-codex/gpt-5.3-codex-spark.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps suppressed openai gpt-5.3-codex-spark from falling through provider fallback", () => {
|
||||
const cfg = {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
api: "openai-responses",
|
||||
models: [{ ...makeModel("gpt-4.1"), api: "openai-responses" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent", cfg);
|
||||
|
||||
expect(result.model).toBeUndefined();
|
||||
expect(result.error).toBe(
|
||||
"Unknown model: openai/gpt-5.3-codex-spark. gpt-5.3-codex-spark is only supported via openai-codex OAuth. Use openai-codex/gpt-5.3-codex-spark.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects azure openai gpt-5.3-codex-spark with a codex-only hint", () => {
|
||||
const result = resolveModelForTest(
|
||||
"azure-openai-responses",
|
||||
"gpt-5.3-codex-spark",
|
||||
"/tmp/agent",
|
||||
);
|
||||
|
||||
expect(result.model).toBeUndefined();
|
||||
expect(result.error).toBe(
|
||||
"Unknown model: azure-openai-responses/gpt-5.3-codex-spark. gpt-5.3-codex-spark is only supported via openai-codex OAuth. Use openai-codex/gpt-5.3-codex-spark.",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses codex fallback even when openai-codex provider is configured", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
"openai-codex": {
|
||||
baseUrl: "https://custom.example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expectResolvedForwardCompatFallbackResult({
|
||||
result: resolveModelForTest("openai-codex", "gpt-5.4", "/tmp/agent", cfg),
|
||||
expectedModel: {
|
||||
api: "openai-codex-responses",
|
||||
id: "gpt-5.4",
|
||||
provider: "openai-codex",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses codex fallback when inline model omits api (#39682)", () => {
|
||||
mockOpenAICodexTemplateModel();
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
"openai-codex": {
|
||||
baseUrl: "https://custom.example.com",
|
||||
headers: { "X-Custom-Auth": "token-123" },
|
||||
models: [{ id: "gpt-5.4" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = resolveModelForTest("openai-codex", "gpt-5.4", "/tmp/agent", cfg);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model).toMatchObject({
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://custom.example.com",
|
||||
headers: { "X-Custom-Auth": "token-123" },
|
||||
id: "gpt-5.4",
|
||||
provider: "openai-codex",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes openai-codex gpt-5.4 overrides away from /v1/responses", () => {
|
||||
mockOpenAICodexTemplateModel();
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
"openai-codex": {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
api: "openai-responses",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expectResolvedForwardCompatFallbackResult({
|
||||
result: resolveModelForTest("openai-codex", "gpt-5.4", "/tmp/agent", cfg),
|
||||
expectedModel: {
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
id: "gpt-5.4",
|
||||
provider: "openai-codex",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not rewrite openai baseUrl when openai-codex api stays non-codex", () => {
|
||||
mockOpenAICodexTemplateModel();
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
"openai-codex": {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
api: "openai-completions",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expectResolvedForwardCompatFallbackResult({
|
||||
result: resolveModelForTest("openai-codex", "gpt-5.4", "/tmp/agent", cfg),
|
||||
expectedModel: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
id: "gpt-5.4",
|
||||
provider: "openai-codex",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("includes auth hint for unknown ollama models (#17328)", () => {
|
||||
const result = resolveModelForTest("ollama", "gemma3:4b", "/tmp/agent");
|
||||
|
||||
expect(result.model).toBeUndefined();
|
||||
expect(result.error).toContain("Unknown model: ollama/gemma3:4b");
|
||||
expect(result.error).toContain("OLLAMA_API_KEY");
|
||||
expect(result.error).toContain("docs.openclaw.ai/providers/ollama");
|
||||
});
|
||||
|
||||
it("includes auth hint for unknown vllm models", () => {
|
||||
const result = resolveModelForTest("vllm", "llama-3-70b", "/tmp/agent");
|
||||
|
||||
expect(result.model).toBeUndefined();
|
||||
expect(result.error).toContain("Unknown model: vllm/llama-3-70b");
|
||||
expect(result.error).toContain("VLLM_API_KEY");
|
||||
});
|
||||
|
||||
it("does not add auth hint for non-local providers", () => {
|
||||
const result = resolveModelForTest("google-antigravity", "some-model", "/tmp/agent");
|
||||
|
||||
expect(result.model).toBeUndefined();
|
||||
expect(result.error).toBe("Unknown model: google-antigravity/some-model");
|
||||
});
|
||||
|
||||
it("applies provider baseUrl override to registry-found models", () => {
|
||||
mockDiscoveredModel({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
templateModel: {
|
||||
id: "claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 64000,
|
||||
},
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
models: {
|
||||
providers: {
|
||||
anthropic: {
|
||||
baseUrl: "https://my-proxy.example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = resolveModelForTest("anthropic", "claude-sonnet-4-5", "/tmp/agent", cfg);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model?.baseUrl).toBe("https://my-proxy.example.com");
|
||||
});
|
||||
|
||||
it("applies provider headers override to registry-found models", () => {
|
||||
mockDiscoveredModel({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
templateModel: {
|
||||
id: "claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 64000,
|
||||
},
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
models: {
|
||||
providers: {
|
||||
anthropic: {
|
||||
headers: { "X-Custom-Auth": "token-123" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = resolveModelForTest("anthropic", "claude-sonnet-4-5", "/tmp/agent", cfg);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toEqual({
|
||||
"X-Custom-Auth": "token-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("lets provider config override registry-found kimi user agent headers", () => {
|
||||
mockDiscoveredModel({
|
||||
provider: "kimi",
|
||||
modelId: "kimi-code",
|
||||
templateModel: {
|
||||
id: "kimi-code",
|
||||
name: "Kimi Code",
|
||||
provider: "kimi",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.kimi.com/coding/",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 64000,
|
||||
headers: { "User-Agent": "claude-code/0.1.0" },
|
||||
},
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
models: {
|
||||
providers: {
|
||||
kimi: {
|
||||
headers: {
|
||||
"User-Agent": "custom-kimi-client/1.0",
|
||||
"X-Kimi-Tenant": "tenant-a",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = resolveModelForTest("kimi", "kimi-code", "/tmp/agent", cfg);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model?.id).toBe("kimi-code");
|
||||
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toEqual({
|
||||
"User-Agent": "custom-kimi-client/1.0",
|
||||
"X-Kimi-Tenant": "tenant-a",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not override when no provider config exists", () => {
|
||||
mockDiscoveredModel({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
templateModel: {
|
||||
id: "claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 64000,
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveModelForTest("anthropic", "claude-sonnet-4-5", "/tmp/agent");
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model?.baseUrl).toBe("https://api.anthropic.com");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { expect } from "vitest";
|
||||
|
||||
export function buildForwardCompatTemplate(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
api: "anthropic-messages" | "openai-completions" | "openai-responses";
|
||||
baseUrl: string;
|
||||
reasoning?: boolean;
|
||||
input?: readonly ["text"] | readonly ["text", "image"];
|
||||
cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
}) {
|
||||
return {
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
provider: params.provider,
|
||||
api: params.api,
|
||||
baseUrl: params.baseUrl,
|
||||
reasoning: params.reasoning ?? true,
|
||||
input: params.input ?? (["text", "image"] as const),
|
||||
cost: params.cost ?? { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
contextWindow: params.contextWindow ?? 200000,
|
||||
maxTokens: params.maxTokens ?? 64000,
|
||||
};
|
||||
}
|
||||
|
||||
export function expectResolvedForwardCompatFallbackResult(params: {
|
||||
result: {
|
||||
error?: string;
|
||||
model?: unknown;
|
||||
};
|
||||
expectedModel: Record<string, unknown>;
|
||||
}) {
|
||||
expect(params.result.error).toBeUndefined();
|
||||
expect(params.result.model).toMatchObject(params.expectedModel);
|
||||
}
|
||||
|
||||
export function expectResolvedForwardCompatFallbackWithRegistryResult(params: {
|
||||
result: unknown;
|
||||
expectedModel: Record<string, unknown>;
|
||||
}) {
|
||||
expect(params.result).toMatchObject(params.expectedModel);
|
||||
}
|
||||
|
||||
export function expectUnknownModelErrorResult(
|
||||
result: {
|
||||
error?: string;
|
||||
model?: unknown;
|
||||
},
|
||||
provider: string,
|
||||
id: string,
|
||||
) {
|
||||
expect(result.model).toBeUndefined();
|
||||
expect(result.error).toBe(`Unknown model: ${provider}/${id}`);
|
||||
}
|
||||
@@ -1,754 +1,158 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, it } from "vitest";
|
||||
import {
|
||||
buildForwardCompatTemplate,
|
||||
expectResolvedForwardCompatFallbackWithRegistryResult,
|
||||
} from "./model.forward-compat.test-support.js";
|
||||
import { resolveModelWithRegistry } from "./model.js";
|
||||
import { createProviderRuntimeTestMock } from "./model.provider-runtime.test-support.js";
|
||||
|
||||
vi.mock("../pi-model-discovery.js", () => ({
|
||||
discoverAuthStorage: vi.fn(() => ({ mocked: true })),
|
||||
discoverModels: vi.fn(() => ({ find: vi.fn(() => null) })),
|
||||
}));
|
||||
|
||||
const OPENAI_BASE_URL = "https://api.openai.com/v1";
|
||||
const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
||||
const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
||||
const ZAI_BASE_URL = "https://api.z.ai/api/paas/v4";
|
||||
const DEFAULT_CONTEXT_WINDOW = 200_000;
|
||||
const OPENROUTER_FALLBACK_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
||||
|
||||
vi.mock("../../plugins/provider-runtime.js", () => {
|
||||
const findTemplate = (
|
||||
ctx: { modelRegistry: { find: (provider: string, modelId: string) => unknown } },
|
||||
provider: string,
|
||||
templateIds: readonly string[],
|
||||
) => {
|
||||
for (const templateId of templateIds) {
|
||||
const template = ctx.modelRegistry.find(provider, templateId) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
if (template) {
|
||||
return template;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const cloneTemplate = (
|
||||
template: Record<string, unknown> | undefined,
|
||||
modelId: string,
|
||||
patch: Record<string, unknown>,
|
||||
fallback: Record<string, unknown>,
|
||||
) =>
|
||||
({
|
||||
...(template ?? fallback),
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
...patch,
|
||||
}) as Record<string, unknown>;
|
||||
const buildDynamicModel = (params: {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
modelRegistry: { find: (provider: string, modelId: string) => unknown };
|
||||
}) => {
|
||||
const modelId = params.modelId.trim();
|
||||
const lower = modelId.toLowerCase();
|
||||
switch (params.provider) {
|
||||
case "anthropic": {
|
||||
if (lower !== "claude-opus-4-6" && lower !== "claude-sonnet-4-6") {
|
||||
return undefined;
|
||||
}
|
||||
const template = findTemplate(
|
||||
params,
|
||||
"anthropic",
|
||||
lower === "claude-opus-4-6" ? ["claude-opus-4-5"] : ["claude-sonnet-4-5"],
|
||||
);
|
||||
return cloneTemplate(
|
||||
template,
|
||||
modelId,
|
||||
{
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: ANTHROPIC_BASE_URL,
|
||||
reasoning: true,
|
||||
},
|
||||
{
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: ANTHROPIC_BASE_URL,
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: OPENROUTER_FALLBACK_COST,
|
||||
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_CONTEXT_WINDOW,
|
||||
},
|
||||
);
|
||||
}
|
||||
case "zai": {
|
||||
if (lower !== "glm-5") {
|
||||
return undefined;
|
||||
}
|
||||
const template = findTemplate(params, "zai", ["glm-4.7"]);
|
||||
return cloneTemplate(
|
||||
template,
|
||||
modelId,
|
||||
{
|
||||
provider: "zai",
|
||||
api: "openai-completions",
|
||||
baseUrl: ZAI_BASE_URL,
|
||||
reasoning: true,
|
||||
},
|
||||
{
|
||||
provider: "zai",
|
||||
api: "openai-completions",
|
||||
baseUrl: ZAI_BASE_URL,
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: OPENROUTER_FALLBACK_COST,
|
||||
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_CONTEXT_WINDOW,
|
||||
},
|
||||
);
|
||||
}
|
||||
case "openai-codex": {
|
||||
const template =
|
||||
lower === "gpt-5.4"
|
||||
? findTemplate(params, "openai-codex", ["gpt-5.4", "gpt-5.2-codex"])
|
||||
: lower === "gpt-5.3-codex-spark"
|
||||
? findTemplate(params, "openai-codex", ["gpt-5.4", "gpt-5.2-codex"])
|
||||
: findTemplate(params, "openai-codex", ["gpt-5.2-codex"]);
|
||||
const fallback = {
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: OPENAI_CODEX_BASE_URL,
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: OPENROUTER_FALLBACK_COST,
|
||||
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_CONTEXT_WINDOW,
|
||||
};
|
||||
if (lower === "gpt-5.4") {
|
||||
return cloneTemplate(
|
||||
template,
|
||||
modelId,
|
||||
{
|
||||
contextWindow: 1_050_000,
|
||||
maxTokens: 128_000,
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: OPENAI_CODEX_BASE_URL,
|
||||
},
|
||||
fallback,
|
||||
);
|
||||
}
|
||||
if (lower === "gpt-5.3-codex-spark") {
|
||||
return cloneTemplate(
|
||||
template,
|
||||
modelId,
|
||||
{
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: OPENAI_CODEX_BASE_URL,
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: OPENROUTER_FALLBACK_COST,
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 128_000,
|
||||
},
|
||||
fallback,
|
||||
);
|
||||
}
|
||||
if (lower === "gpt-5.4") {
|
||||
return cloneTemplate(
|
||||
template,
|
||||
modelId,
|
||||
{
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: OPENAI_CODEX_BASE_URL,
|
||||
},
|
||||
fallback,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const normalizeDynamicModel = (params: { provider: string; model: Record<string, unknown> }) => {
|
||||
if (params.provider !== "openai-codex") {
|
||||
return undefined;
|
||||
}
|
||||
const baseUrl = typeof params.model.baseUrl === "string" ? params.model.baseUrl : undefined;
|
||||
const nextApi =
|
||||
params.model.api === "openai-responses" &&
|
||||
(!baseUrl || baseUrl === OPENAI_BASE_URL || baseUrl === OPENAI_CODEX_BASE_URL)
|
||||
? "openai-codex-responses"
|
||||
: params.model.api;
|
||||
const nextBaseUrl =
|
||||
nextApi === "openai-codex-responses" && (!baseUrl || baseUrl === OPENAI_BASE_URL)
|
||||
? OPENAI_CODEX_BASE_URL
|
||||
: baseUrl;
|
||||
if (nextApi !== params.model.api || nextBaseUrl !== baseUrl) {
|
||||
return { ...params.model, api: nextApi, baseUrl: nextBaseUrl };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
return {
|
||||
clearProviderRuntimeHookCache: () => {},
|
||||
resolveProviderBuiltInModelSuppression: (params: {
|
||||
context: {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
};
|
||||
}) => {
|
||||
if (
|
||||
(params.context.provider === "openai" ||
|
||||
params.context.provider === "azure-openai-responses") &&
|
||||
params.context.modelId === "gpt-5.3-codex-spark"
|
||||
) {
|
||||
return {
|
||||
suppress: true,
|
||||
errorMessage: `Unknown model: ${params.context.provider}/gpt-5.3-codex-spark. gpt-5.3-codex-spark is only supported via openai-codex OAuth. Use openai-codex/gpt-5.3-codex-spark.`,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
resolveProviderRuntimePlugin: (params: { provider: string }) =>
|
||||
params.provider === "anthropic" ||
|
||||
params.provider === "zai" ||
|
||||
params.provider === "openai-codex"
|
||||
? {
|
||||
id: params.provider,
|
||||
resolveDynamicModel: (ctx: {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
modelRegistry: { find: (provider: string, modelId: string) => unknown };
|
||||
}) => buildDynamicModel(ctx),
|
||||
normalizeResolvedModel: (ctx: { provider: string; model: Record<string, unknown> }) =>
|
||||
normalizeDynamicModel(ctx),
|
||||
}
|
||||
: undefined,
|
||||
runProviderDynamicModel: (params: {
|
||||
provider: string;
|
||||
context: {
|
||||
modelId: string;
|
||||
modelRegistry: { find: (provider: string, modelId: string) => unknown };
|
||||
};
|
||||
}) =>
|
||||
buildDynamicModel({
|
||||
provider: params.provider,
|
||||
modelId: params.context.modelId,
|
||||
modelRegistry: params.context.modelRegistry,
|
||||
}),
|
||||
prepareProviderDynamicModel: async () => undefined,
|
||||
normalizeProviderResolvedModelWithPlugin: (params: {
|
||||
provider: string;
|
||||
context: { model: unknown };
|
||||
}) =>
|
||||
normalizeDynamicModel({
|
||||
provider: params.provider,
|
||||
model: params.context.model as Record<string, unknown>,
|
||||
}),
|
||||
};
|
||||
const ANTHROPIC_OPUS_TEMPLATE = buildForwardCompatTemplate({
|
||||
id: "claude-opus-4-5",
|
||||
name: "Claude Opus 4.5",
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
});
|
||||
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { clearProviderRuntimeHookCache } from "../../plugins/provider-runtime.js";
|
||||
import { discoverModels } from "../pi-model-discovery.js";
|
||||
import { resolveModel, resolveModelWithRegistry } from "./model.js";
|
||||
|
||||
const OPENAI_CODEX_TEMPLATE_MODEL = {
|
||||
id: "gpt-5.2-codex",
|
||||
name: "GPT-5.2 Codex",
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
const ANTHROPIC_OPUS_EXPECTED = {
|
||||
provider: "anthropic",
|
||||
id: "claude-opus-4-6",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
reasoning: true,
|
||||
input: ["text", "image"] as const,
|
||||
cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
|
||||
contextWindow: 272000,
|
||||
maxTokens: 128000,
|
||||
};
|
||||
|
||||
function makeModel(id: string) {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
reasoning: false,
|
||||
input: ["text"] as const,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1,
|
||||
maxTokens: 1,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearProviderRuntimeHookCache();
|
||||
const ANTHROPIC_SONNET_TEMPLATE = buildForwardCompatTemplate({
|
||||
id: "claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
});
|
||||
|
||||
function buildForwardCompatTemplate(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
api: "anthropic-messages" | "openai-completions" | "openai-responses";
|
||||
baseUrl: string;
|
||||
reasoning?: boolean;
|
||||
input?: readonly ["text"] | readonly ["text", "image"];
|
||||
cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
}) {
|
||||
return {
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
provider: params.provider,
|
||||
api: params.api,
|
||||
baseUrl: params.baseUrl,
|
||||
reasoning: params.reasoning ?? true,
|
||||
input: params.input ?? (["text", "image"] as const),
|
||||
cost: params.cost ?? { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
contextWindow: params.contextWindow ?? 200000,
|
||||
maxTokens: params.maxTokens ?? 64000,
|
||||
};
|
||||
}
|
||||
const ANTHROPIC_SONNET_EXPECTED = {
|
||||
provider: "anthropic",
|
||||
id: "claude-sonnet-4-6",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
reasoning: true,
|
||||
};
|
||||
|
||||
function expectResolvedForwardCompatFallback(params: {
|
||||
provider: string;
|
||||
id: string;
|
||||
expectedModel: Record<string, unknown>;
|
||||
cfg?: OpenClawConfig;
|
||||
}) {
|
||||
const result = resolveModel(params.provider, params.id, "/tmp/agent", params.cfg);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model).toMatchObject(params.expectedModel);
|
||||
}
|
||||
const ZAI_GLM5_CASE = {
|
||||
provider: "zai",
|
||||
id: "glm-5",
|
||||
expectedModel: {
|
||||
provider: "zai",
|
||||
id: "glm-5",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.z.ai/api/paas/v4",
|
||||
reasoning: true,
|
||||
},
|
||||
registryEntries: [
|
||||
{
|
||||
provider: "zai",
|
||||
modelId: "glm-4.7",
|
||||
model: buildForwardCompatTemplate({
|
||||
id: "glm-4.7",
|
||||
name: "GLM-4.7",
|
||||
provider: "zai",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.z.ai/api/paas/v4",
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
maxTokens: 131072,
|
||||
}),
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
function mockOpenAICodexTemplateModel() {
|
||||
return {
|
||||
provider: "openai-codex",
|
||||
modelId: "gpt-5.2-codex",
|
||||
model: OPENAI_CODEX_TEMPLATE_MODEL,
|
||||
};
|
||||
}
|
||||
|
||||
function mockDiscoveredModel(params: {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
templateModel: unknown;
|
||||
}) {
|
||||
vi.mocked(discoverModels).mockReturnValue({
|
||||
find: vi.fn((provider: string, modelId: string) => {
|
||||
if (provider === params.provider && modelId === params.modelId) {
|
||||
return params.templateModel;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
} as unknown as ReturnType<typeof discoverModels>);
|
||||
}
|
||||
|
||||
function expectResolvedForwardCompatFallbackWithRegistry(params: {
|
||||
provider: string;
|
||||
id: string;
|
||||
expectedModel: Record<string, unknown>;
|
||||
cfg?: OpenClawConfig;
|
||||
registryEntries: Array<{
|
||||
provider: string;
|
||||
modelId: string;
|
||||
model: unknown;
|
||||
}>;
|
||||
}) {
|
||||
const result = resolveModelWithRegistry({
|
||||
provider: params.provider,
|
||||
modelId: params.id,
|
||||
cfg: params.cfg,
|
||||
agentDir: "/tmp/agent",
|
||||
modelRegistry: {
|
||||
find(provider: string, modelId: string) {
|
||||
const match = params.registryEntries.find(
|
||||
(entry) => entry.provider === provider && entry.modelId === modelId,
|
||||
);
|
||||
return match?.model ?? null;
|
||||
},
|
||||
} as never,
|
||||
function createRuntimeHooks() {
|
||||
return createProviderRuntimeTestMock({
|
||||
handledDynamicProviders: ["anthropic", "zai", "openai-codex"],
|
||||
});
|
||||
expect(result).toMatchObject(params.expectedModel);
|
||||
}
|
||||
|
||||
function expectUnknownModelError(provider: string, id: string) {
|
||||
const result = resolveModel(provider, id, "/tmp/agent");
|
||||
expect(result.model).toBeUndefined();
|
||||
expect(result.error).toBe(`Unknown model: ${provider}/${id}`);
|
||||
function createRegistry(
|
||||
entries: Array<{ provider: string; modelId: string; model: Record<string, unknown> }>,
|
||||
) {
|
||||
return {
|
||||
find(provider: string, modelId: string) {
|
||||
const match = entries.find(
|
||||
(entry) => entry.provider === provider && entry.modelId === modelId,
|
||||
);
|
||||
return match?.model ?? null;
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
function runAnthropicOpusForwardCompatFallback() {
|
||||
expectResolvedForwardCompatFallbackWithRegistryResult({
|
||||
result: resolveModelWithRegistry({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-opus-4-6",
|
||||
agentDir: "/tmp/agent",
|
||||
modelRegistry: createRegistry([
|
||||
{
|
||||
provider: "anthropic",
|
||||
modelId: "claude-opus-4-5",
|
||||
model: ANTHROPIC_OPUS_TEMPLATE,
|
||||
},
|
||||
]),
|
||||
runtimeHooks: createRuntimeHooks(),
|
||||
}),
|
||||
expectedModel: ANTHROPIC_OPUS_EXPECTED,
|
||||
});
|
||||
}
|
||||
|
||||
function runAnthropicSonnetForwardCompatFallback() {
|
||||
expectResolvedForwardCompatFallbackWithRegistryResult({
|
||||
result: resolveModelWithRegistry({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
agentDir: "/tmp/agent",
|
||||
modelRegistry: createRegistry([
|
||||
{
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
model: ANTHROPIC_SONNET_TEMPLATE,
|
||||
},
|
||||
]),
|
||||
runtimeHooks: createRuntimeHooks(),
|
||||
}),
|
||||
expectedModel: ANTHROPIC_SONNET_EXPECTED,
|
||||
});
|
||||
}
|
||||
|
||||
function runZaiForwardCompatFallback() {
|
||||
const result = resolveModelWithRegistry({
|
||||
provider: ZAI_GLM5_CASE.provider,
|
||||
modelId: ZAI_GLM5_CASE.id,
|
||||
agentDir: "/tmp/agent",
|
||||
modelRegistry: createRegistry(
|
||||
ZAI_GLM5_CASE.registryEntries.map((entry) => ({
|
||||
provider: entry.provider,
|
||||
modelId: entry.modelId,
|
||||
model: entry.model,
|
||||
})),
|
||||
),
|
||||
runtimeHooks: createRuntimeHooks(),
|
||||
});
|
||||
expectResolvedForwardCompatFallbackWithRegistryResult({
|
||||
result,
|
||||
expectedModel: ZAI_GLM5_CASE.expectedModel,
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveModel forward-compat tail", () => {
|
||||
it("builds an anthropic forward-compat fallback for claude-opus-4-6", () => {
|
||||
mockDiscoveredModel({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-opus-4-5",
|
||||
templateModel: buildForwardCompatTemplate({
|
||||
id: "claude-opus-4-5",
|
||||
name: "Claude Opus 4.5",
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
}),
|
||||
});
|
||||
it(
|
||||
"builds an anthropic forward-compat fallback for claude-opus-4-6",
|
||||
runAnthropicOpusForwardCompatFallback,
|
||||
);
|
||||
|
||||
expectResolvedForwardCompatFallback({
|
||||
provider: "anthropic",
|
||||
id: "claude-opus-4-6",
|
||||
expectedModel: {
|
||||
provider: "anthropic",
|
||||
id: "claude-opus-4-6",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
reasoning: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
it(
|
||||
"builds an anthropic forward-compat fallback for claude-sonnet-4-6",
|
||||
runAnthropicSonnetForwardCompatFallback,
|
||||
);
|
||||
|
||||
it("builds an anthropic forward-compat fallback for claude-sonnet-4-6", () => {
|
||||
mockDiscoveredModel({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
templateModel: buildForwardCompatTemplate({
|
||||
id: "claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
}),
|
||||
});
|
||||
|
||||
expectResolvedForwardCompatFallback({
|
||||
provider: "anthropic",
|
||||
id: "claude-sonnet-4-6",
|
||||
expectedModel: {
|
||||
provider: "anthropic",
|
||||
id: "claude-sonnet-4-6",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
reasoning: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a zai forward-compat fallback for glm-5", () => {
|
||||
expectResolvedForwardCompatFallbackWithRegistry({
|
||||
provider: "zai",
|
||||
id: "glm-5",
|
||||
expectedModel: {
|
||||
provider: "zai",
|
||||
id: "glm-5",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.z.ai/api/paas/v4",
|
||||
reasoning: true,
|
||||
},
|
||||
registryEntries: [
|
||||
{
|
||||
provider: "zai",
|
||||
modelId: "glm-4.7",
|
||||
model: buildForwardCompatTemplate({
|
||||
id: "glm-4.7",
|
||||
name: "GLM-4.7",
|
||||
provider: "zai",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.z.ai/api/paas/v4",
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
maxTokens: 131072,
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unknown-model errors when no antigravity thinking template exists", () => {
|
||||
expectUnknownModelError("google-antigravity", "claude-opus-4-6-thinking");
|
||||
});
|
||||
|
||||
it("keeps unknown-model errors when no antigravity non-thinking template exists", () => {
|
||||
expectUnknownModelError("google-antigravity", "claude-opus-4-6");
|
||||
});
|
||||
|
||||
it("keeps unknown-model errors for non-gpt-5 openai-codex ids", () => {
|
||||
expectUnknownModelError("openai-codex", "gpt-4.1-mini");
|
||||
});
|
||||
|
||||
it("rejects direct openai gpt-5.3-codex-spark with a codex-only hint", () => {
|
||||
const result = resolveModel("openai", "gpt-5.3-codex-spark", "/tmp/agent");
|
||||
|
||||
expect(result.model).toBeUndefined();
|
||||
expect(result.error).toBe(
|
||||
"Unknown model: openai/gpt-5.3-codex-spark. gpt-5.3-codex-spark is only supported via openai-codex OAuth. Use openai-codex/gpt-5.3-codex-spark.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps suppressed openai gpt-5.3-codex-spark from falling through provider fallback", () => {
|
||||
const cfg = {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
api: "openai-responses",
|
||||
models: [{ ...makeModel("gpt-4.1"), api: "openai-responses" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = resolveModel("openai", "gpt-5.3-codex-spark", "/tmp/agent", cfg);
|
||||
|
||||
expect(result.model).toBeUndefined();
|
||||
expect(result.error).toBe(
|
||||
"Unknown model: openai/gpt-5.3-codex-spark. gpt-5.3-codex-spark is only supported via openai-codex OAuth. Use openai-codex/gpt-5.3-codex-spark.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects azure openai gpt-5.3-codex-spark with a codex-only hint", () => {
|
||||
const result = resolveModel("azure-openai-responses", "gpt-5.3-codex-spark", "/tmp/agent");
|
||||
|
||||
expect(result.model).toBeUndefined();
|
||||
expect(result.error).toBe(
|
||||
"Unknown model: azure-openai-responses/gpt-5.3-codex-spark. gpt-5.3-codex-spark is only supported via openai-codex OAuth. Use openai-codex/gpt-5.3-codex-spark.",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses codex fallback even when openai-codex provider is configured", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
"openai-codex": {
|
||||
baseUrl: "https://custom.example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expectResolvedForwardCompatFallback({
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.4",
|
||||
cfg,
|
||||
expectedModel: {
|
||||
api: "openai-codex-responses",
|
||||
id: "gpt-5.4",
|
||||
provider: "openai-codex",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses codex fallback when inline model omits api (#39682)", () => {
|
||||
mockOpenAICodexTemplateModel();
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
"openai-codex": {
|
||||
baseUrl: "https://custom.example.com",
|
||||
headers: { "X-Custom-Auth": "token-123" },
|
||||
models: [{ id: "gpt-5.4" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = resolveModel("openai-codex", "gpt-5.4", "/tmp/agent", cfg);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model).toMatchObject({
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://custom.example.com",
|
||||
headers: { "X-Custom-Auth": "token-123" },
|
||||
id: "gpt-5.4",
|
||||
provider: "openai-codex",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes openai-codex gpt-5.4 overrides away from /v1/responses", () => {
|
||||
mockOpenAICodexTemplateModel();
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
"openai-codex": {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
api: "openai-responses",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expectResolvedForwardCompatFallback({
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.4",
|
||||
cfg,
|
||||
expectedModel: {
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
id: "gpt-5.4",
|
||||
provider: "openai-codex",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not rewrite openai baseUrl when openai-codex api stays non-codex", () => {
|
||||
mockOpenAICodexTemplateModel();
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
"openai-codex": {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
api: "openai-completions",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
expectResolvedForwardCompatFallback({
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.4",
|
||||
cfg,
|
||||
expectedModel: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
id: "gpt-5.4",
|
||||
provider: "openai-codex",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("includes auth hint for unknown ollama models (#17328)", () => {
|
||||
const result = resolveModel("ollama", "gemma3:4b", "/tmp/agent");
|
||||
|
||||
expect(result.model).toBeUndefined();
|
||||
expect(result.error).toContain("Unknown model: ollama/gemma3:4b");
|
||||
expect(result.error).toContain("OLLAMA_API_KEY");
|
||||
expect(result.error).toContain("docs.openclaw.ai/providers/ollama");
|
||||
});
|
||||
|
||||
it("includes auth hint for unknown vllm models", () => {
|
||||
const result = resolveModel("vllm", "llama-3-70b", "/tmp/agent");
|
||||
|
||||
expect(result.model).toBeUndefined();
|
||||
expect(result.error).toContain("Unknown model: vllm/llama-3-70b");
|
||||
expect(result.error).toContain("VLLM_API_KEY");
|
||||
});
|
||||
|
||||
it("does not add auth hint for non-local providers", () => {
|
||||
const result = resolveModel("google-antigravity", "some-model", "/tmp/agent");
|
||||
|
||||
expect(result.model).toBeUndefined();
|
||||
expect(result.error).toBe("Unknown model: google-antigravity/some-model");
|
||||
});
|
||||
|
||||
it("applies provider baseUrl override to registry-found models", () => {
|
||||
mockDiscoveredModel({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
templateModel: buildForwardCompatTemplate({
|
||||
id: "claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
}),
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
models: {
|
||||
providers: {
|
||||
anthropic: {
|
||||
baseUrl: "https://my-proxy.example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = resolveModel("anthropic", "claude-sonnet-4-5", "/tmp/agent", cfg);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model?.baseUrl).toBe("https://my-proxy.example.com");
|
||||
});
|
||||
|
||||
it("applies provider headers override to registry-found models", () => {
|
||||
mockDiscoveredModel({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
templateModel: buildForwardCompatTemplate({
|
||||
id: "claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
}),
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
models: {
|
||||
providers: {
|
||||
anthropic: {
|
||||
headers: { "X-Custom-Auth": "token-123" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = resolveModel("anthropic", "claude-sonnet-4-5", "/tmp/agent", cfg);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toEqual({
|
||||
"X-Custom-Auth": "token-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("lets provider config override registry-found kimi user agent headers", () => {
|
||||
mockDiscoveredModel({
|
||||
provider: "kimi",
|
||||
modelId: "kimi-code",
|
||||
templateModel: {
|
||||
...buildForwardCompatTemplate({
|
||||
id: "kimi-code",
|
||||
name: "Kimi Code",
|
||||
provider: "kimi",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.kimi.com/coding/",
|
||||
}),
|
||||
headers: { "User-Agent": "claude-code/0.1.0" },
|
||||
},
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
models: {
|
||||
providers: {
|
||||
kimi: {
|
||||
headers: {
|
||||
"User-Agent": "custom-kimi-client/1.0",
|
||||
"X-Kimi-Tenant": "tenant-a",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = resolveModel("kimi", "kimi-code", "/tmp/agent", cfg);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model?.id).toBe("kimi-for-coding");
|
||||
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toEqual({
|
||||
"User-Agent": "custom-kimi-client/1.0",
|
||||
"X-Kimi-Tenant": "tenant-a",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not override when no provider config exists", () => {
|
||||
mockDiscoveredModel({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
templateModel: buildForwardCompatTemplate({
|
||||
id: "claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = resolveModel("anthropic", "claude-sonnet-4-5", "/tmp/agent");
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model?.baseUrl).toBe("https://api.anthropic.com");
|
||||
});
|
||||
it("builds a zai forward-compat fallback for glm-5", runZaiForwardCompatFallback);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import type { OpenRouterModelCapabilities } from "./openrouter-model-capabilities.js";
|
||||
|
||||
const OPENAI_BASE_URL = "https://api.openai.com/v1";
|
||||
const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
||||
const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
||||
const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
||||
const ZAI_BASE_URL = "https://api.z.ai/api/paas/v4";
|
||||
const DEFAULT_CONTEXT_WINDOW = 200_000;
|
||||
const DEFAULT_MAX_TOKENS = 8192;
|
||||
const OPENROUTER_FALLBACK_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
||||
|
||||
type ModelRegistryLike = {
|
||||
find: (provider: string, modelId: string) => unknown;
|
||||
};
|
||||
|
||||
type DynamicModelContext = {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
modelRegistry: ModelRegistryLike;
|
||||
};
|
||||
|
||||
type ResolvedModelLike = Record<string, unknown>;
|
||||
|
||||
type ProviderRuntimeTestMockOptions = {
|
||||
clearHookCache?: () => void;
|
||||
getOpenRouterModelCapabilities?: (modelId: string) => OpenRouterModelCapabilities | undefined;
|
||||
handledDynamicProviders?: readonly string[];
|
||||
loadOpenRouterModelCapabilities?: (modelId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
function findTemplate(
|
||||
ctx: { modelRegistry: ModelRegistryLike },
|
||||
provider: string,
|
||||
templateIds: readonly string[],
|
||||
) {
|
||||
for (const templateId of templateIds) {
|
||||
const template = ctx.modelRegistry.find(provider, templateId) as ResolvedModelLike | null;
|
||||
if (template) {
|
||||
return template;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function cloneTemplate(
|
||||
template: ResolvedModelLike | undefined,
|
||||
modelId: string,
|
||||
patch: ResolvedModelLike,
|
||||
fallback: ResolvedModelLike,
|
||||
) {
|
||||
return {
|
||||
...(template ?? fallback),
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
...patch,
|
||||
} as ResolvedModelLike;
|
||||
}
|
||||
|
||||
function normalizeDynamicModel(params: { provider: string; model: ResolvedModelLike }) {
|
||||
if (params.provider === "openai") {
|
||||
const baseUrl = typeof params.model.baseUrl === "string" ? params.model.baseUrl : undefined;
|
||||
if (params.model.api === "openai-completions" && (!baseUrl || baseUrl === OPENAI_BASE_URL)) {
|
||||
return { ...params.model, api: "openai-responses" };
|
||||
}
|
||||
}
|
||||
if (params.provider !== "openai-codex") {
|
||||
return undefined;
|
||||
}
|
||||
const baseUrl = typeof params.model.baseUrl === "string" ? params.model.baseUrl : undefined;
|
||||
const nextApi =
|
||||
params.model.api === "openai-responses" &&
|
||||
(!baseUrl || baseUrl === OPENAI_BASE_URL || baseUrl === OPENAI_CODEX_BASE_URL)
|
||||
? "openai-codex-responses"
|
||||
: params.model.api;
|
||||
const nextBaseUrl =
|
||||
nextApi === "openai-codex-responses" && (!baseUrl || baseUrl === OPENAI_BASE_URL)
|
||||
? OPENAI_CODEX_BASE_URL
|
||||
: baseUrl;
|
||||
if (nextApi !== params.model.api || nextBaseUrl !== baseUrl) {
|
||||
return { ...params.model, api: nextApi, baseUrl: nextBaseUrl };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildDynamicModel(
|
||||
params: DynamicModelContext,
|
||||
options: Required<
|
||||
Pick<
|
||||
ProviderRuntimeTestMockOptions,
|
||||
"getOpenRouterModelCapabilities" | "loadOpenRouterModelCapabilities"
|
||||
>
|
||||
>,
|
||||
) {
|
||||
const modelId = params.modelId.trim();
|
||||
const lower = modelId.toLowerCase();
|
||||
switch (params.provider) {
|
||||
case "openrouter": {
|
||||
const capabilities = options.getOpenRouterModelCapabilities(modelId);
|
||||
return {
|
||||
id: modelId,
|
||||
name: capabilities?.name ?? modelId,
|
||||
api: "openai-completions" as const,
|
||||
provider: "openrouter",
|
||||
baseUrl: OPENROUTER_BASE_URL,
|
||||
reasoning: capabilities?.reasoning ?? false,
|
||||
input: capabilities?.input ?? (["text"] as const),
|
||||
cost: capabilities?.cost ?? OPENROUTER_FALLBACK_COST,
|
||||
contextWindow: capabilities?.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: capabilities?.maxTokens ?? DEFAULT_MAX_TOKENS,
|
||||
};
|
||||
}
|
||||
case "github-copilot": {
|
||||
const existing = params.modelRegistry.find("github-copilot", lower);
|
||||
if (existing) {
|
||||
return undefined;
|
||||
}
|
||||
const template = findTemplate(params, "github-copilot", ["gpt-5.2-codex"]);
|
||||
if (lower === "gpt-5.4" && template) {
|
||||
return cloneTemplate(
|
||||
template,
|
||||
modelId,
|
||||
{},
|
||||
{
|
||||
provider: "github-copilot",
|
||||
api: "openai-responses",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: OPENROUTER_FALLBACK_COST,
|
||||
contextWindow: 128_000,
|
||||
maxTokens: DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
provider: "github-copilot",
|
||||
api: "openai-responses",
|
||||
reasoning: /^o[13](\b|$)/.test(lower),
|
||||
input: ["text", "image"],
|
||||
cost: OPENROUTER_FALLBACK_COST,
|
||||
contextWindow: 128_000,
|
||||
maxTokens: DEFAULT_MAX_TOKENS,
|
||||
};
|
||||
}
|
||||
case "openai-codex": {
|
||||
const template =
|
||||
lower === "gpt-5.4"
|
||||
? findTemplate(params, "openai-codex", ["gpt-5.4", "gpt-5.2-codex"])
|
||||
: lower === "gpt-5.3-codex-spark"
|
||||
? findTemplate(params, "openai-codex", ["gpt-5.4", "gpt-5.2-codex"])
|
||||
: findTemplate(params, "openai-codex", ["gpt-5.2-codex"]);
|
||||
const fallback = {
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: OPENAI_CODEX_BASE_URL,
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: OPENROUTER_FALLBACK_COST,
|
||||
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_CONTEXT_WINDOW,
|
||||
};
|
||||
if (lower === "gpt-5.4") {
|
||||
return cloneTemplate(
|
||||
template,
|
||||
modelId,
|
||||
{
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: OPENAI_CODEX_BASE_URL,
|
||||
contextWindow: 1_050_000,
|
||||
maxTokens: 128_000,
|
||||
},
|
||||
fallback,
|
||||
);
|
||||
}
|
||||
if (lower === "gpt-5.3-codex-spark") {
|
||||
return cloneTemplate(
|
||||
template,
|
||||
modelId,
|
||||
{
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: OPENAI_CODEX_BASE_URL,
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: OPENROUTER_FALLBACK_COST,
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 128_000,
|
||||
},
|
||||
fallback,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
case "openai": {
|
||||
const templateIds =
|
||||
lower === "gpt-5.4"
|
||||
? ["gpt-5.2"]
|
||||
: lower === "gpt-5.4-pro"
|
||||
? ["gpt-5.2-pro", "gpt-5.2"]
|
||||
: lower === "gpt-5.4-mini"
|
||||
? ["gpt-5-mini"]
|
||||
: lower === "gpt-5.4-nano"
|
||||
? ["gpt-5-nano", "gpt-5-mini"]
|
||||
: undefined;
|
||||
if (!templateIds) {
|
||||
return undefined;
|
||||
}
|
||||
const template = findTemplate(params, "openai", templateIds);
|
||||
const patch =
|
||||
lower === "gpt-5.4" || lower === "gpt-5.4-pro"
|
||||
? {
|
||||
provider: "openai",
|
||||
api: "openai-responses",
|
||||
baseUrl: OPENAI_BASE_URL,
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 1_050_000,
|
||||
maxTokens: 128_000,
|
||||
}
|
||||
: {
|
||||
provider: "openai",
|
||||
api: "openai-responses",
|
||||
baseUrl: OPENAI_BASE_URL,
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
};
|
||||
return cloneTemplate(template, modelId, patch, {
|
||||
provider: "openai",
|
||||
api: "openai-responses",
|
||||
baseUrl: OPENAI_BASE_URL,
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: OPENROUTER_FALLBACK_COST,
|
||||
contextWindow: patch.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: patch.maxTokens ?? DEFAULT_CONTEXT_WINDOW,
|
||||
});
|
||||
}
|
||||
case "anthropic": {
|
||||
if (lower !== "claude-opus-4-6" && lower !== "claude-sonnet-4-6") {
|
||||
return undefined;
|
||||
}
|
||||
const template = findTemplate(
|
||||
params,
|
||||
"anthropic",
|
||||
lower === "claude-opus-4-6" ? ["claude-opus-4-5"] : ["claude-sonnet-4-5"],
|
||||
);
|
||||
return cloneTemplate(
|
||||
template,
|
||||
modelId,
|
||||
{
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: ANTHROPIC_BASE_URL,
|
||||
reasoning: true,
|
||||
},
|
||||
{
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: ANTHROPIC_BASE_URL,
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: OPENROUTER_FALLBACK_COST,
|
||||
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_CONTEXT_WINDOW,
|
||||
},
|
||||
);
|
||||
}
|
||||
case "zai": {
|
||||
if (lower !== "glm-5") {
|
||||
return undefined;
|
||||
}
|
||||
const template = findTemplate(params, "zai", ["glm-4.7"]);
|
||||
return cloneTemplate(
|
||||
template,
|
||||
modelId,
|
||||
{
|
||||
provider: "zai",
|
||||
api: "openai-completions",
|
||||
baseUrl: ZAI_BASE_URL,
|
||||
reasoning: true,
|
||||
},
|
||||
{
|
||||
provider: "zai",
|
||||
api: "openai-completions",
|
||||
baseUrl: ZAI_BASE_URL,
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: OPENROUTER_FALLBACK_COST,
|
||||
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_CONTEXT_WINDOW,
|
||||
},
|
||||
);
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function createProviderRuntimeTestMock(options: ProviderRuntimeTestMockOptions = {}) {
|
||||
const handledDynamicProviders = new Set(
|
||||
options.handledDynamicProviders ?? [
|
||||
"openrouter",
|
||||
"github-copilot",
|
||||
"openai-codex",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"zai",
|
||||
],
|
||||
);
|
||||
const getOpenRouterModelCapabilities =
|
||||
options.getOpenRouterModelCapabilities ?? (() => undefined);
|
||||
const loadOpenRouterModelCapabilities =
|
||||
options.loadOpenRouterModelCapabilities ?? (async () => {});
|
||||
|
||||
return {
|
||||
clearProviderRuntimeHookCache: options.clearHookCache ?? (() => {}),
|
||||
resolveProviderRuntimePlugin: ({ provider }: { provider: string }) =>
|
||||
handledDynamicProviders.has(provider)
|
||||
? {
|
||||
id: provider,
|
||||
prepareDynamicModel:
|
||||
provider === "openrouter"
|
||||
? async ({ modelId }: { modelId: string }) => {
|
||||
await loadOpenRouterModelCapabilities(modelId);
|
||||
}
|
||||
: undefined,
|
||||
resolveDynamicModel: (ctx: DynamicModelContext) =>
|
||||
buildDynamicModel(ctx, {
|
||||
getOpenRouterModelCapabilities,
|
||||
loadOpenRouterModelCapabilities,
|
||||
}),
|
||||
normalizeResolvedModel: (ctx: { provider: string; model: ResolvedModelLike }) =>
|
||||
normalizeDynamicModel(ctx),
|
||||
}
|
||||
: undefined,
|
||||
runProviderDynamicModel: (params: {
|
||||
provider: string;
|
||||
context: { modelId: string; modelRegistry: ModelRegistryLike };
|
||||
}) =>
|
||||
handledDynamicProviders.has(params.provider)
|
||||
? buildDynamicModel(
|
||||
{
|
||||
provider: params.provider,
|
||||
modelId: params.context.modelId,
|
||||
modelRegistry: params.context.modelRegistry,
|
||||
},
|
||||
{
|
||||
getOpenRouterModelCapabilities,
|
||||
loadOpenRouterModelCapabilities,
|
||||
},
|
||||
)
|
||||
: undefined,
|
||||
prepareProviderDynamicModel: async (params: {
|
||||
provider: string;
|
||||
context: { modelId: string };
|
||||
}) =>
|
||||
params.provider === "openrouter"
|
||||
? await loadOpenRouterModelCapabilities(params.context.modelId)
|
||||
: undefined,
|
||||
normalizeProviderResolvedModelWithPlugin: (params: {
|
||||
provider: string;
|
||||
context: { model: unknown };
|
||||
}) =>
|
||||
handledDynamicProviders.has(params.provider)
|
||||
? normalizeDynamicModel({
|
||||
provider: params.provider,
|
||||
model: params.context.model as ResolvedModelLike,
|
||||
})
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createProviderRuntimeTestMock } from "./model.provider-runtime.test-support.js";
|
||||
|
||||
vi.mock("../pi-model-discovery.js", () => ({
|
||||
discoverAuthStorage: vi.fn(() => ({ mocked: true })),
|
||||
discoverModels: vi.fn(() => ({ find: vi.fn(() => null) })),
|
||||
}));
|
||||
|
||||
const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
||||
const OPENROUTER_FALLBACK_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
||||
|
||||
import type { OpenRouterModelCapabilities } from "./openrouter-model-capabilities.js";
|
||||
|
||||
const mockGetOpenRouterModelCapabilities = vi.fn<
|
||||
@@ -22,371 +20,7 @@ vi.mock("./openrouter-model-capabilities.js", () => ({
|
||||
mockLoadOpenRouterModelCapabilities(modelId),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/provider-runtime.js", () => {
|
||||
const HANDLED_DYNAMIC_PROVIDERS = new Set([
|
||||
"openrouter",
|
||||
"github-copilot",
|
||||
"openai-codex",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"zai",
|
||||
]);
|
||||
const OPENAI_BASE_URL = "https://api.openai.com/v1";
|
||||
const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
||||
const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
||||
const ZAI_BASE_URL = "https://api.z.ai/api/paas/v4";
|
||||
const DEFAULT_CONTEXT_WINDOW = 200_000;
|
||||
const DEFAULT_MAX_TOKENS = 8192;
|
||||
const findTemplate = (
|
||||
ctx: { modelRegistry: { find: (provider: string, modelId: string) => unknown } },
|
||||
provider: string,
|
||||
templateIds: readonly string[],
|
||||
) => {
|
||||
for (const templateId of templateIds) {
|
||||
const template = ctx.modelRegistry.find(provider, templateId) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
if (template) {
|
||||
return template;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const cloneTemplate = (
|
||||
template: Record<string, unknown> | undefined,
|
||||
modelId: string,
|
||||
patch: Record<string, unknown>,
|
||||
fallback: Record<string, unknown>,
|
||||
) =>
|
||||
({
|
||||
...(template ?? fallback),
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
...patch,
|
||||
}) as Record<string, unknown>;
|
||||
const buildOpenRouterModel = (modelId: string) => {
|
||||
const capabilities = mockGetOpenRouterModelCapabilities(modelId);
|
||||
return {
|
||||
id: modelId,
|
||||
name: capabilities?.name ?? modelId,
|
||||
api: "openai-completions" as const,
|
||||
provider: "openrouter",
|
||||
baseUrl: OPENROUTER_BASE_URL,
|
||||
reasoning: capabilities?.reasoning ?? false,
|
||||
input: capabilities?.input ?? (["text"] as const),
|
||||
cost: capabilities?.cost ?? OPENROUTER_FALLBACK_COST,
|
||||
contextWindow: capabilities?.contextWindow ?? 200_000,
|
||||
maxTokens: capabilities?.maxTokens ?? 8192,
|
||||
};
|
||||
};
|
||||
const buildDynamicModel = (params: {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
modelRegistry: { find: (provider: string, modelId: string) => unknown };
|
||||
}) => {
|
||||
const modelId = params.modelId.trim();
|
||||
const lower = modelId.toLowerCase();
|
||||
switch (params.provider) {
|
||||
case "openrouter":
|
||||
return buildOpenRouterModel(modelId);
|
||||
case "github-copilot": {
|
||||
const existing = params.modelRegistry.find("github-copilot", lower);
|
||||
if (existing) {
|
||||
return undefined;
|
||||
}
|
||||
const template = findTemplate(params, "github-copilot", ["gpt-5.2-codex"]);
|
||||
if (lower === "gpt-5.4" && template) {
|
||||
return cloneTemplate(
|
||||
template,
|
||||
modelId,
|
||||
{},
|
||||
{
|
||||
provider: "github-copilot",
|
||||
api: "openai-responses",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: OPENROUTER_FALLBACK_COST,
|
||||
contextWindow: 128_000,
|
||||
maxTokens: DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
provider: "github-copilot",
|
||||
api: "openai-responses",
|
||||
reasoning: /^o[13](\\b|$)/.test(lower),
|
||||
input: ["text", "image"],
|
||||
cost: OPENROUTER_FALLBACK_COST,
|
||||
contextWindow: 128_000,
|
||||
maxTokens: DEFAULT_MAX_TOKENS,
|
||||
};
|
||||
}
|
||||
case "openai-codex": {
|
||||
const template =
|
||||
lower === "gpt-5.4"
|
||||
? findTemplate(params, "openai-codex", ["gpt-5.4", "gpt-5.2-codex"])
|
||||
: lower === "gpt-5.3-codex-spark"
|
||||
? findTemplate(params, "openai-codex", ["gpt-5.4", "gpt-5.2-codex"])
|
||||
: findTemplate(params, "openai-codex", ["gpt-5.2-codex"]);
|
||||
const fallback = {
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: OPENAI_CODEX_BASE_URL,
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: OPENROUTER_FALLBACK_COST,
|
||||
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_CONTEXT_WINDOW,
|
||||
};
|
||||
if (lower === "gpt-5.4") {
|
||||
return cloneTemplate(
|
||||
template,
|
||||
modelId,
|
||||
{
|
||||
contextWindow: 1_050_000,
|
||||
maxTokens: 128_000,
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: OPENAI_CODEX_BASE_URL,
|
||||
},
|
||||
fallback,
|
||||
);
|
||||
}
|
||||
if (lower === "gpt-5.3-codex-spark") {
|
||||
return cloneTemplate(
|
||||
template,
|
||||
modelId,
|
||||
{
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: OPENAI_CODEX_BASE_URL,
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: OPENROUTER_FALLBACK_COST,
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 128_000,
|
||||
},
|
||||
fallback,
|
||||
);
|
||||
}
|
||||
if (lower === "gpt-5.4") {
|
||||
return cloneTemplate(
|
||||
template,
|
||||
modelId,
|
||||
{
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: OPENAI_CODEX_BASE_URL,
|
||||
},
|
||||
fallback,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
case "openai": {
|
||||
const templateIds =
|
||||
lower === "gpt-5.4"
|
||||
? ["gpt-5.2"]
|
||||
: lower === "gpt-5.4-pro"
|
||||
? ["gpt-5.2-pro", "gpt-5.2"]
|
||||
: lower === "gpt-5.4-mini"
|
||||
? ["gpt-5-mini"]
|
||||
: lower === "gpt-5.4-nano"
|
||||
? ["gpt-5-nano", "gpt-5-mini"]
|
||||
: undefined;
|
||||
if (!templateIds) {
|
||||
return undefined;
|
||||
}
|
||||
const template = findTemplate(params, "openai", templateIds);
|
||||
const patch =
|
||||
lower === "gpt-5.4" || lower === "gpt-5.4-pro"
|
||||
? {
|
||||
provider: "openai",
|
||||
api: "openai-responses",
|
||||
baseUrl: OPENAI_BASE_URL,
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 1_050_000,
|
||||
maxTokens: 128_000,
|
||||
}
|
||||
: {
|
||||
provider: "openai",
|
||||
api: "openai-responses",
|
||||
baseUrl: OPENAI_BASE_URL,
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
};
|
||||
return cloneTemplate(template, modelId, patch, {
|
||||
provider: "openai",
|
||||
api: "openai-responses",
|
||||
baseUrl: OPENAI_BASE_URL,
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: OPENROUTER_FALLBACK_COST,
|
||||
contextWindow: patch.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: patch.maxTokens ?? DEFAULT_CONTEXT_WINDOW,
|
||||
});
|
||||
}
|
||||
case "anthropic": {
|
||||
if (lower !== "claude-opus-4-6" && lower !== "claude-sonnet-4-6") {
|
||||
return undefined;
|
||||
}
|
||||
const template = findTemplate(
|
||||
params,
|
||||
"anthropic",
|
||||
lower === "claude-opus-4-6" ? ["claude-opus-4-5"] : ["claude-sonnet-4-5"],
|
||||
);
|
||||
return cloneTemplate(
|
||||
template,
|
||||
modelId,
|
||||
{
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: ANTHROPIC_BASE_URL,
|
||||
reasoning: true,
|
||||
},
|
||||
{
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: ANTHROPIC_BASE_URL,
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: OPENROUTER_FALLBACK_COST,
|
||||
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_CONTEXT_WINDOW,
|
||||
},
|
||||
);
|
||||
}
|
||||
case "zai": {
|
||||
if (lower !== "glm-5") {
|
||||
return undefined;
|
||||
}
|
||||
const template = findTemplate(params, "zai", ["glm-4.7"]);
|
||||
return cloneTemplate(
|
||||
template,
|
||||
modelId,
|
||||
{
|
||||
provider: "zai",
|
||||
api: "openai-completions",
|
||||
baseUrl: ZAI_BASE_URL,
|
||||
reasoning: true,
|
||||
},
|
||||
{
|
||||
provider: "zai",
|
||||
api: "openai-completions",
|
||||
baseUrl: ZAI_BASE_URL,
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: OPENROUTER_FALLBACK_COST,
|
||||
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_CONTEXT_WINDOW,
|
||||
},
|
||||
);
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const normalizeDynamicModel = (params: { provider: string; model: Record<string, unknown> }) => {
|
||||
if (params.provider === "openai") {
|
||||
const baseUrl = typeof params.model.baseUrl === "string" ? params.model.baseUrl : undefined;
|
||||
if (params.model.api === "openai-completions" && (!baseUrl || baseUrl === OPENAI_BASE_URL)) {
|
||||
return { ...params.model, api: "openai-responses" };
|
||||
}
|
||||
}
|
||||
if (params.provider === "openai-codex") {
|
||||
const baseUrl = typeof params.model.baseUrl === "string" ? params.model.baseUrl : undefined;
|
||||
const nextApi =
|
||||
params.model.api === "openai-responses" &&
|
||||
(!baseUrl || baseUrl === OPENAI_BASE_URL || baseUrl === OPENAI_CODEX_BASE_URL)
|
||||
? "openai-codex-responses"
|
||||
: params.model.api;
|
||||
const nextBaseUrl =
|
||||
nextApi === "openai-codex-responses" && (!baseUrl || baseUrl === OPENAI_BASE_URL)
|
||||
? OPENAI_CODEX_BASE_URL
|
||||
: baseUrl;
|
||||
if (nextApi !== params.model.api || nextBaseUrl !== baseUrl) {
|
||||
return { ...params.model, api: nextApi, baseUrl: nextBaseUrl };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
return {
|
||||
clearProviderRuntimeHookCache: () => {},
|
||||
resolveProviderBuiltInModelSuppression: (params: {
|
||||
context: {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
};
|
||||
}) => {
|
||||
if (
|
||||
(params.context.provider === "openai" ||
|
||||
params.context.provider === "azure-openai-responses") &&
|
||||
params.context.modelId === "gpt-5.3-codex-spark"
|
||||
) {
|
||||
return {
|
||||
suppress: true,
|
||||
errorMessage: `Unknown model: ${params.context.provider}/gpt-5.3-codex-spark. gpt-5.3-codex-spark is only supported via openai-codex OAuth. Use openai-codex/gpt-5.3-codex-spark.`,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
resolveProviderRuntimePlugin: (params: { provider: string }) =>
|
||||
HANDLED_DYNAMIC_PROVIDERS.has(params.provider)
|
||||
? {
|
||||
id: params.provider,
|
||||
prepareDynamicModel:
|
||||
params.provider === "openrouter"
|
||||
? async (ctx: { modelId: string }) => {
|
||||
await mockLoadOpenRouterModelCapabilities(ctx.modelId);
|
||||
}
|
||||
: undefined,
|
||||
resolveDynamicModel: (ctx: {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
modelRegistry: { find: (provider: string, modelId: string) => unknown };
|
||||
}) => buildDynamicModel(ctx),
|
||||
normalizeResolvedModel: (ctx: { provider: string; model: Record<string, unknown> }) =>
|
||||
normalizeDynamicModel(ctx),
|
||||
}
|
||||
: undefined,
|
||||
runProviderDynamicModel: (params: {
|
||||
provider: string;
|
||||
context: {
|
||||
modelId: string;
|
||||
modelRegistry: { find: (provider: string, modelId: string) => unknown };
|
||||
};
|
||||
}) =>
|
||||
buildDynamicModel({
|
||||
provider: params.provider,
|
||||
modelId: params.context.modelId,
|
||||
modelRegistry: params.context.modelRegistry,
|
||||
}),
|
||||
prepareProviderDynamicModel: async (params: {
|
||||
provider: string;
|
||||
context: { modelId: string };
|
||||
}) =>
|
||||
params.provider === "openrouter"
|
||||
? await mockLoadOpenRouterModelCapabilities(params.context.modelId)
|
||||
: undefined,
|
||||
normalizeProviderResolvedModelWithPlugin: (params: {
|
||||
provider: string;
|
||||
context: { model: unknown };
|
||||
}) =>
|
||||
HANDLED_DYNAMIC_PROVIDERS.has(params.provider)
|
||||
? normalizeDynamicModel({
|
||||
provider: params.provider,
|
||||
model: params.context.model as Record<string, unknown>,
|
||||
})
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { clearProviderRuntimeHookCache } from "../../plugins/provider-runtime.js";
|
||||
import { buildInlineProviderModels, resolveModel, resolveModelAsync } from "./model.js";
|
||||
import {
|
||||
buildOpenAICodexForwardCompatExpectation,
|
||||
@@ -397,7 +31,6 @@ import {
|
||||
} from "./model.test-harness.js";
|
||||
|
||||
beforeEach(() => {
|
||||
clearProviderRuntimeHookCache();
|
||||
resetMockDiscoverModels();
|
||||
mockGetOpenRouterModelCapabilities.mockReset();
|
||||
mockGetOpenRouterModelCapabilities.mockReturnValue(undefined);
|
||||
@@ -405,6 +38,48 @@ beforeEach(() => {
|
||||
mockLoadOpenRouterModelCapabilities.mockResolvedValue();
|
||||
});
|
||||
|
||||
function createRuntimeHooks() {
|
||||
return createProviderRuntimeTestMock({
|
||||
handledDynamicProviders: [
|
||||
"openrouter",
|
||||
"github-copilot",
|
||||
"openai-codex",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"zai",
|
||||
],
|
||||
getOpenRouterModelCapabilities: (modelId: string) =>
|
||||
mockGetOpenRouterModelCapabilities(modelId),
|
||||
loadOpenRouterModelCapabilities: async (modelId: string) => {
|
||||
await mockLoadOpenRouterModelCapabilities(modelId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function resolveModelForTest(
|
||||
provider: string,
|
||||
modelId: string,
|
||||
agentDir?: string,
|
||||
cfg?: OpenClawConfig,
|
||||
) {
|
||||
return resolveModel(provider, modelId, agentDir, cfg, {
|
||||
runtimeHooks: createRuntimeHooks(),
|
||||
});
|
||||
}
|
||||
|
||||
function resolveModelAsyncForTest(
|
||||
provider: string,
|
||||
modelId: string,
|
||||
agentDir?: string,
|
||||
cfg?: OpenClawConfig,
|
||||
options?: { retryTransientProviderRuntimeMiss?: boolean },
|
||||
) {
|
||||
return resolveModelAsync(provider, modelId, agentDir, cfg, {
|
||||
...options,
|
||||
runtimeHooks: createRuntimeHooks(),
|
||||
});
|
||||
}
|
||||
|
||||
function buildForwardCompatTemplate(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -590,7 +265,7 @@ describe("resolveModel", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveModel("custom", "missing-input", "/tmp/agent", {
|
||||
const result = resolveModelForTest("custom", "missing-input", "/tmp/agent", {
|
||||
models: {
|
||||
providers: {
|
||||
custom: {
|
||||
@@ -620,7 +295,7 @@ describe("resolveModel", () => {
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = resolveModel("custom", "missing-model", "/tmp/agent", cfg);
|
||||
const result = resolveModelForTest("custom", "missing-model", "/tmp/agent", cfg);
|
||||
|
||||
expect(result.model?.baseUrl).toBe("http://localhost:9000");
|
||||
expect(result.model?.provider).toBe("custom");
|
||||
@@ -641,7 +316,7 @@ describe("resolveModel", () => {
|
||||
} as OpenClawConfig;
|
||||
|
||||
// Requesting a non-listed model forces the providerCfg fallback branch.
|
||||
const result = resolveModel("custom", "missing-model", "/tmp/agent", cfg);
|
||||
const result = resolveModelForTest("custom", "missing-model", "/tmp/agent", cfg);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toEqual({
|
||||
@@ -666,7 +341,7 @@ describe("resolveModel", () => {
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = resolveModel("custom", "missing-model", "/tmp/agent", cfg);
|
||||
const result = resolveModelForTest("custom", "missing-model", "/tmp/agent", cfg);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toEqual({
|
||||
@@ -689,7 +364,7 @@ describe("resolveModel", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveModel("custom", "listed-model", "/tmp/agent");
|
||||
const result = resolveModelForTest("custom", "listed-model", "/tmp/agent");
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toEqual({
|
||||
@@ -720,7 +395,7 @@ describe("resolveModel", () => {
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = resolveModel("custom", "model-b", "/tmp/agent", cfg);
|
||||
const result = resolveModelForTest("custom", "model-b", "/tmp/agent", cfg);
|
||||
|
||||
expect(result.model?.contextWindow).toBe(262144);
|
||||
expect(result.model?.maxTokens).toBe(32768);
|
||||
@@ -747,7 +422,7 @@ describe("resolveModel", () => {
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = resolveModel("custom", "model-b", "/tmp/agent", cfg);
|
||||
const result = resolveModelForTest("custom", "model-b", "/tmp/agent", cfg);
|
||||
|
||||
expect(result.model?.reasoning).toBe(true);
|
||||
});
|
||||
@@ -806,7 +481,7 @@ describe("resolveModel", () => {
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
});
|
||||
|
||||
const result = resolveModel("openrouter", "openrouter/healer-alpha", "/tmp/agent");
|
||||
const result = resolveModelForTest("openrouter", "openrouter/healer-alpha", "/tmp/agent");
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model).toMatchObject({
|
||||
@@ -823,7 +498,7 @@ describe("resolveModel", () => {
|
||||
it("falls back to text-only when OpenRouter API cache is empty", () => {
|
||||
mockGetOpenRouterModelCapabilities.mockReturnValue(undefined);
|
||||
|
||||
const result = resolveModel("openrouter", "openrouter/healer-alpha", "/tmp/agent");
|
||||
const result = resolveModelForTest("openrouter", "openrouter/healer-alpha", "/tmp/agent");
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model).toMatchObject({
|
||||
@@ -848,7 +523,7 @@ describe("resolveModel", () => {
|
||||
}
|
||||
});
|
||||
|
||||
const result = await resolveModelAsync(
|
||||
const result = await resolveModelAsyncForTest(
|
||||
"openrouter",
|
||||
"google/gemini-3.1-flash-image-preview",
|
||||
"/tmp/agent",
|
||||
@@ -886,7 +561,11 @@ describe("resolveModel", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await resolveModelAsync("openrouter", "openrouter/healer-alpha", "/tmp/agent");
|
||||
const result = await resolveModelAsyncForTest(
|
||||
"openrouter",
|
||||
"openrouter/healer-alpha",
|
||||
"/tmp/agent",
|
||||
);
|
||||
|
||||
expect(mockLoadOpenRouterModelCapabilities).not.toHaveBeenCalled();
|
||||
expect(result.error).toBeUndefined();
|
||||
@@ -935,7 +614,7 @@ describe("resolveModel", () => {
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = resolveModel("onehub", "glm-5", "/tmp/agent", cfg);
|
||||
const result = resolveModelForTest("onehub", "glm-5", "/tmp/agent", cfg);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model).toMatchObject({
|
||||
@@ -994,7 +673,7 @@ describe("resolveModel", () => {
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = resolveModel("qwen", "qwen3-coder-plus", "/tmp/agent", cfg);
|
||||
const result = resolveModelForTest("qwen", "qwen3-coder-plus", "/tmp/agent", cfg);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model).toMatchObject({
|
||||
@@ -1012,7 +691,7 @@ describe("resolveModel", () => {
|
||||
it("builds an openai-codex fallback for gpt-5.4", () => {
|
||||
mockOpenAICodexTemplateModel();
|
||||
|
||||
const result = resolveModel("openai-codex", "gpt-5.4", "/tmp/agent");
|
||||
const result = resolveModelForTest("openai-codex", "gpt-5.4", "/tmp/agent");
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model).toMatchObject(buildOpenAICodexForwardCompatExpectation("gpt-5.4"));
|
||||
@@ -1021,7 +700,7 @@ describe("resolveModel", () => {
|
||||
it("builds an openai-codex fallback for gpt-5.4", () => {
|
||||
mockOpenAICodexTemplateModel();
|
||||
|
||||
const result = resolveModel("openai-codex", "gpt-5.4", "/tmp/agent");
|
||||
const result = resolveModelForTest("openai-codex", "gpt-5.4", "/tmp/agent");
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model).toMatchObject(buildOpenAICodexForwardCompatExpectation("gpt-5.4"));
|
||||
@@ -1030,7 +709,7 @@ describe("resolveModel", () => {
|
||||
it("builds an openai-codex fallback for gpt-5.3-codex-spark", () => {
|
||||
mockOpenAICodexTemplateModel();
|
||||
|
||||
const result = resolveModel("openai-codex", "gpt-5.3-codex-spark", "/tmp/agent");
|
||||
const result = resolveModelForTest("openai-codex", "gpt-5.3-codex-spark", "/tmp/agent");
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model).toMatchObject(
|
||||
@@ -1049,7 +728,7 @@ describe("resolveModel", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = resolveModel("openai-codex", "gpt-5.3-codex-spark", "/tmp/agent");
|
||||
const result = resolveModelForTest("openai-codex", "gpt-5.3-codex-spark", "/tmp/agent");
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model).toMatchObject({
|
||||
@@ -1073,7 +752,7 @@ describe("resolveModel", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const result = resolveModel("openai", "gpt-5.3-codex-spark", "/tmp/agent");
|
||||
const result = resolveModelForTest("openai", "gpt-5.3-codex-spark", "/tmp/agent");
|
||||
|
||||
expect(result.model).toBeUndefined();
|
||||
expect(result.error).toBe(
|
||||
@@ -1105,7 +784,7 @@ describe("resolveModel", () => {
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = resolveModel("openai", "gpt-5.4", "/tmp/agent", cfg);
|
||||
const result = resolveModelForTest("openai", "gpt-5.4", "/tmp/agent", cfg);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model).toMatchObject({
|
||||
@@ -1143,7 +822,7 @@ describe("resolveModel", () => {
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = resolveModel("github-copilot", "gpt-5.4-mini", "/tmp/agent", cfg);
|
||||
const result = resolveModelForTest("github-copilot", "gpt-5.4-mini", "/tmp/agent", cfg);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model).toMatchObject({
|
||||
@@ -1180,7 +859,7 @@ describe("resolveModel", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const result = resolveModel("openai", "gpt-5.4-mini", "/tmp/agent");
|
||||
const result = resolveModelForTest("openai", "gpt-5.4-mini", "/tmp/agent");
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model).toMatchObject({
|
||||
@@ -1212,7 +891,7 @@ describe("resolveModel", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const result = resolveModel("openai", "gpt-5.4-nano", "/tmp/agent");
|
||||
const result = resolveModelForTest("openai", "gpt-5.4-nano", "/tmp/agent");
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model).toMatchObject({
|
||||
@@ -1240,7 +919,7 @@ describe("resolveModel", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const result = resolveModel("openai", "gpt-5.4", "/tmp/agent");
|
||||
const result = resolveModelForTest("openai", "gpt-5.4", "/tmp/agent");
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model).toMatchObject({
|
||||
@@ -1264,7 +943,7 @@ describe("resolveModel", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const result = resolveModel("openai", "gpt-5.4", "/tmp/agent");
|
||||
const result = resolveModelForTest("openai", "gpt-5.4", "/tmp/agent");
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model).toMatchObject({
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { ModelDefinitionConfig } from "../../config/types.js";
|
||||
import {
|
||||
clearProviderRuntimeHookCache,
|
||||
prepareProviderDynamicModel,
|
||||
resolveProviderRuntimePlugin,
|
||||
runProviderDynamicModel,
|
||||
normalizeProviderResolvedModelWithPlugin,
|
||||
} from "../../plugins/provider-runtime.js";
|
||||
@@ -34,6 +33,22 @@ type InlineProviderConfig = {
|
||||
headers?: unknown;
|
||||
};
|
||||
|
||||
type ProviderRuntimeHooks = {
|
||||
prepareProviderDynamicModel: (
|
||||
params: Parameters<typeof prepareProviderDynamicModel>[0],
|
||||
) => Promise<void>;
|
||||
runProviderDynamicModel: (params: Parameters<typeof runProviderDynamicModel>[0]) => unknown;
|
||||
normalizeProviderResolvedModelWithPlugin: (
|
||||
params: Parameters<typeof normalizeProviderResolvedModelWithPlugin>[0],
|
||||
) => unknown;
|
||||
};
|
||||
|
||||
const DEFAULT_PROVIDER_RUNTIME_HOOKS: ProviderRuntimeHooks = {
|
||||
prepareProviderDynamicModel,
|
||||
runProviderDynamicModel,
|
||||
normalizeProviderResolvedModelWithPlugin,
|
||||
};
|
||||
|
||||
function sanitizeModelHeaders(
|
||||
headers: unknown,
|
||||
opts?: { stripSecretRefMarkers?: boolean },
|
||||
@@ -59,8 +74,10 @@ function normalizeResolvedModel(params: {
|
||||
model: Model<Api>;
|
||||
cfg?: OpenClawConfig;
|
||||
agentDir?: string;
|
||||
runtimeHooks?: ProviderRuntimeHooks;
|
||||
}): Model<Api> {
|
||||
const pluginNormalized = normalizeProviderResolvedModelWithPlugin({
|
||||
const runtimeHooks = params.runtimeHooks ?? DEFAULT_PROVIDER_RUNTIME_HOOKS;
|
||||
const pluginNormalized = runtimeHooks.normalizeProviderResolvedModelWithPlugin({
|
||||
provider: params.provider,
|
||||
config: params.cfg,
|
||||
context: {
|
||||
@@ -70,7 +87,7 @@ function normalizeResolvedModel(params: {
|
||||
modelId: params.model.id,
|
||||
model: params.model,
|
||||
},
|
||||
});
|
||||
}) as Model<Api> | undefined;
|
||||
if (pluginNormalized) {
|
||||
return normalizeModelCompat(pluginNormalized);
|
||||
}
|
||||
@@ -188,8 +205,9 @@ function resolveExplicitModelWithRegistry(params: {
|
||||
modelRegistry: ModelRegistry;
|
||||
cfg?: OpenClawConfig;
|
||||
agentDir?: string;
|
||||
runtimeHooks?: ProviderRuntimeHooks;
|
||||
}): { kind: "resolved"; model: Model<Api> } | { kind: "suppressed" } | undefined {
|
||||
const { provider, modelId, modelRegistry, cfg, agentDir } = params;
|
||||
const { provider, modelId, modelRegistry, cfg, agentDir, runtimeHooks } = params;
|
||||
if (shouldSuppressBuiltInModel({ provider, id: modelId })) {
|
||||
return { kind: "suppressed" };
|
||||
}
|
||||
@@ -207,6 +225,7 @@ function resolveExplicitModelWithRegistry(params: {
|
||||
cfg,
|
||||
agentDir,
|
||||
model: inlineMatch as Model<Api>,
|
||||
runtimeHooks,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -224,6 +243,7 @@ function resolveExplicitModelWithRegistry(params: {
|
||||
providerConfig,
|
||||
modelId,
|
||||
}),
|
||||
runtimeHooks,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -240,6 +260,7 @@ function resolveExplicitModelWithRegistry(params: {
|
||||
cfg,
|
||||
agentDir,
|
||||
model: fallbackInlineMatch as Model<Api>,
|
||||
runtimeHooks,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -247,24 +268,18 @@ function resolveExplicitModelWithRegistry(params: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveModelWithRegistry(params: {
|
||||
function resolvePluginDynamicModelWithRegistry(params: {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
modelRegistry: ModelRegistry;
|
||||
cfg?: OpenClawConfig;
|
||||
agentDir?: string;
|
||||
runtimeHooks?: ProviderRuntimeHooks;
|
||||
}): Model<Api> | undefined {
|
||||
const explicitModel = resolveExplicitModelWithRegistry(params);
|
||||
if (explicitModel?.kind === "suppressed") {
|
||||
return undefined;
|
||||
}
|
||||
if (explicitModel?.kind === "resolved") {
|
||||
return explicitModel.model;
|
||||
}
|
||||
|
||||
const { provider, modelId, cfg, modelRegistry, agentDir } = params;
|
||||
const { provider, modelId, modelRegistry, cfg, agentDir } = params;
|
||||
const runtimeHooks = params.runtimeHooks ?? DEFAULT_PROVIDER_RUNTIME_HOOKS;
|
||||
const providerConfig = resolveConfiguredProviderConfig(cfg, provider);
|
||||
const pluginDynamicModel = runProviderDynamicModel({
|
||||
const pluginDynamicModel = runtimeHooks.runProviderDynamicModel({
|
||||
provider,
|
||||
config: cfg,
|
||||
context: {
|
||||
@@ -275,20 +290,33 @@ export function resolveModelWithRegistry(params: {
|
||||
modelRegistry,
|
||||
providerConfig,
|
||||
},
|
||||
});
|
||||
if (pluginDynamicModel) {
|
||||
return normalizeResolvedModel({
|
||||
provider,
|
||||
cfg,
|
||||
agentDir,
|
||||
model: applyConfiguredProviderOverrides({
|
||||
discoveredModel: pluginDynamicModel as Model<Api>,
|
||||
providerConfig,
|
||||
modelId,
|
||||
}),
|
||||
});
|
||||
}) as Model<Api> | undefined;
|
||||
if (!pluginDynamicModel) {
|
||||
return undefined;
|
||||
}
|
||||
const overriddenDynamicModel = applyConfiguredProviderOverrides({
|
||||
discoveredModel: pluginDynamicModel,
|
||||
providerConfig,
|
||||
modelId,
|
||||
});
|
||||
return normalizeResolvedModel({
|
||||
provider,
|
||||
cfg,
|
||||
agentDir,
|
||||
model: overriddenDynamicModel,
|
||||
runtimeHooks,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveConfiguredFallbackModel(params: {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
cfg?: OpenClawConfig;
|
||||
agentDir?: string;
|
||||
runtimeHooks?: ProviderRuntimeHooks;
|
||||
}): Model<Api> | undefined {
|
||||
const { provider, modelId, cfg, agentDir, runtimeHooks } = params;
|
||||
const providerConfig = resolveConfiguredProviderConfig(cfg, provider);
|
||||
const configuredModel = providerConfig?.models?.find((candidate) => candidate.id === modelId);
|
||||
const providerHeaders = sanitizeModelHeaders(providerConfig?.headers, {
|
||||
stripSecretRefMarkers: true,
|
||||
@@ -296,35 +324,59 @@ export function resolveModelWithRegistry(params: {
|
||||
const modelHeaders = sanitizeModelHeaders(configuredModel?.headers, {
|
||||
stripSecretRefMarkers: true,
|
||||
});
|
||||
if (providerConfig || modelId.startsWith("mock-")) {
|
||||
return normalizeResolvedModel({
|
||||
if (!providerConfig && !modelId.startsWith("mock-")) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeResolvedModel({
|
||||
provider,
|
||||
cfg,
|
||||
agentDir,
|
||||
model: {
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
api: providerConfig?.api ?? "openai-responses",
|
||||
provider,
|
||||
cfg,
|
||||
agentDir,
|
||||
model: {
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
api: providerConfig?.api ?? "openai-responses",
|
||||
provider,
|
||||
baseUrl: providerConfig?.baseUrl,
|
||||
reasoning: configuredModel?.reasoning ?? false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow:
|
||||
configuredModel?.contextWindow ??
|
||||
providerConfig?.models?.[0]?.contextWindow ??
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
maxTokens:
|
||||
configuredModel?.maxTokens ??
|
||||
providerConfig?.models?.[0]?.maxTokens ??
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
headers:
|
||||
providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined,
|
||||
} as Model<Api>,
|
||||
});
|
||||
baseUrl: providerConfig?.baseUrl,
|
||||
reasoning: configuredModel?.reasoning ?? false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow:
|
||||
configuredModel?.contextWindow ??
|
||||
providerConfig?.models?.[0]?.contextWindow ??
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
maxTokens:
|
||||
configuredModel?.maxTokens ??
|
||||
providerConfig?.models?.[0]?.maxTokens ??
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
headers:
|
||||
providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined,
|
||||
} as Model<Api>,
|
||||
runtimeHooks,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveModelWithRegistry(params: {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
modelRegistry: ModelRegistry;
|
||||
cfg?: OpenClawConfig;
|
||||
agentDir?: string;
|
||||
runtimeHooks?: ProviderRuntimeHooks;
|
||||
}): Model<Api> | undefined {
|
||||
const explicitModel = resolveExplicitModelWithRegistry(params);
|
||||
if (explicitModel?.kind === "suppressed") {
|
||||
return undefined;
|
||||
}
|
||||
if (explicitModel?.kind === "resolved") {
|
||||
return explicitModel.model;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
const pluginDynamicModel = resolvePluginDynamicModelWithRegistry(params);
|
||||
if (pluginDynamicModel) {
|
||||
return pluginDynamicModel;
|
||||
}
|
||||
|
||||
return resolveConfiguredFallbackModel(params);
|
||||
}
|
||||
|
||||
export function resolveModel(
|
||||
@@ -332,6 +384,9 @@ export function resolveModel(
|
||||
modelId: string,
|
||||
agentDir?: string,
|
||||
cfg?: OpenClawConfig,
|
||||
options?: {
|
||||
runtimeHooks?: ProviderRuntimeHooks;
|
||||
},
|
||||
): {
|
||||
model?: Model<Api>;
|
||||
error?: string;
|
||||
@@ -347,6 +402,7 @@ export function resolveModel(
|
||||
modelRegistry,
|
||||
cfg,
|
||||
agentDir: resolvedAgentDir,
|
||||
runtimeHooks: options?.runtimeHooks,
|
||||
});
|
||||
if (model) {
|
||||
return { model, authStorage, modelRegistry };
|
||||
@@ -366,6 +422,7 @@ export async function resolveModelAsync(
|
||||
cfg?: OpenClawConfig,
|
||||
options?: {
|
||||
retryTransientProviderRuntimeMiss?: boolean;
|
||||
runtimeHooks?: ProviderRuntimeHooks;
|
||||
},
|
||||
): Promise<{
|
||||
model?: Model<Api>;
|
||||
@@ -382,6 +439,7 @@ export async function resolveModelAsync(
|
||||
modelRegistry,
|
||||
cfg,
|
||||
agentDir: resolvedAgentDir,
|
||||
runtimeHooks: options?.runtimeHooks,
|
||||
});
|
||||
if (explicitModel?.kind === "suppressed") {
|
||||
return {
|
||||
@@ -391,34 +449,30 @@ export async function resolveModelAsync(
|
||||
};
|
||||
}
|
||||
const providerConfig = resolveConfiguredProviderConfig(cfg, provider);
|
||||
const resolveDynamicAttempt = async (options?: { clearHookCache?: boolean }) => {
|
||||
if (options?.clearHookCache) {
|
||||
const runtimeHooks = options?.runtimeHooks ?? DEFAULT_PROVIDER_RUNTIME_HOOKS;
|
||||
const resolveDynamicAttempt = async (attemptOptions?: { clearHookCache?: boolean }) => {
|
||||
if (attemptOptions?.clearHookCache) {
|
||||
clearProviderRuntimeHookCache();
|
||||
}
|
||||
const providerPlugin = resolveProviderRuntimePlugin({
|
||||
await runtimeHooks.prepareProviderDynamicModel({
|
||||
provider,
|
||||
config: cfg,
|
||||
});
|
||||
if (providerPlugin?.prepareDynamicModel) {
|
||||
await prepareProviderDynamicModel({
|
||||
provider,
|
||||
context: {
|
||||
config: cfg,
|
||||
context: {
|
||||
config: cfg,
|
||||
agentDir: resolvedAgentDir,
|
||||
provider,
|
||||
modelId,
|
||||
modelRegistry,
|
||||
providerConfig,
|
||||
},
|
||||
});
|
||||
}
|
||||
agentDir: resolvedAgentDir,
|
||||
provider,
|
||||
modelId,
|
||||
modelRegistry,
|
||||
providerConfig,
|
||||
},
|
||||
});
|
||||
return resolveModelWithRegistry({
|
||||
provider,
|
||||
modelId,
|
||||
modelRegistry,
|
||||
cfg,
|
||||
agentDir: resolvedAgentDir,
|
||||
runtimeHooks: options?.runtimeHooks,
|
||||
});
|
||||
};
|
||||
let model =
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { computeBackoff, sleepWithAbort, type BackoffPolicy } from "../../infra/backoff.js";
|
||||
import { generateSecureToken } from "../../infra/secure-random.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
import { prepareProviderRuntimeAuth } from "../../plugins/provider-runtime.runtime.js";
|
||||
import { prepareProviderRuntimeAuth } from "../../plugins/provider-runtime.js";
|
||||
import type { PluginHookBeforeAgentStartResult } from "../../plugins/types.js";
|
||||
import { enqueueCommandInLane } from "../../process/command-queue.js";
|
||||
import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js";
|
||||
|
||||
@@ -1,58 +1,71 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resetDiagnosticSessionStateForTest } from "../logging/diagnostic-session-state.js";
|
||||
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
|
||||
import { toClientToolDefinitions, toToolDefinitions } from "./pi-tool-definition-adapter.js";
|
||||
import { wrapToolWithAbortSignal } from "./pi-tools.abort.js";
|
||||
import {
|
||||
__testing as beforeToolCallTesting,
|
||||
consumeAdjustedParamsForToolCall,
|
||||
wrapToolWithBeforeToolCallHook,
|
||||
} from "./pi-tools.before-tool-call.js";
|
||||
initializeGlobalHookRunner,
|
||||
resetGlobalHookRunner,
|
||||
} from "../plugins/hook-runner-global.js";
|
||||
import { createMockPluginRegistry } from "../plugins/hooks.test-helpers.js";
|
||||
|
||||
vi.mock("../plugins/hook-runner-global.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../plugins/hook-runner-global.js")>();
|
||||
return {
|
||||
...actual,
|
||||
getGlobalHookRunner: vi.fn(),
|
||||
};
|
||||
type ToolDefinitionAdapterModule = typeof import("./pi-tool-definition-adapter.js");
|
||||
type PiToolsAbortModule = typeof import("./pi-tools.abort.js");
|
||||
type BeforeToolCallModule = typeof import("./pi-tools.before-tool-call.js");
|
||||
|
||||
type ToClientToolDefinitions = ToolDefinitionAdapterModule["toClientToolDefinitions"];
|
||||
type ToToolDefinitions = ToolDefinitionAdapterModule["toToolDefinitions"];
|
||||
type WrapToolWithAbortSignal = PiToolsAbortModule["wrapToolWithAbortSignal"];
|
||||
type BeforeToolCallTesting = BeforeToolCallModule["__testing"];
|
||||
type ConsumeAdjustedParamsForToolCall = BeforeToolCallModule["consumeAdjustedParamsForToolCall"];
|
||||
type WrapToolWithBeforeToolCallHook = BeforeToolCallModule["wrapToolWithBeforeToolCallHook"];
|
||||
|
||||
let toClientToolDefinitions!: ToClientToolDefinitions;
|
||||
let toToolDefinitions!: ToToolDefinitions;
|
||||
let wrapToolWithAbortSignal!: WrapToolWithAbortSignal;
|
||||
let beforeToolCallTesting!: BeforeToolCallTesting;
|
||||
let consumeAdjustedParamsForToolCall!: ConsumeAdjustedParamsForToolCall;
|
||||
let wrapToolWithBeforeToolCallHook!: WrapToolWithBeforeToolCallHook;
|
||||
|
||||
beforeEach(async () => {
|
||||
if (!wrapToolWithBeforeToolCallHook) {
|
||||
({ toClientToolDefinitions, toToolDefinitions } =
|
||||
await import("./pi-tool-definition-adapter.js"));
|
||||
({ wrapToolWithAbortSignal } = await import("./pi-tools.abort.js"));
|
||||
({
|
||||
__testing: beforeToolCallTesting,
|
||||
consumeAdjustedParamsForToolCall,
|
||||
wrapToolWithBeforeToolCallHook,
|
||||
} = await import("./pi-tools.before-tool-call.js"));
|
||||
}
|
||||
});
|
||||
|
||||
const mockGetGlobalHookRunner = vi.mocked(getGlobalHookRunner);
|
||||
type BeforeToolCallHandlerMock = ReturnType<typeof vi.fn>;
|
||||
|
||||
type HookRunnerMock = {
|
||||
hasHooks: ReturnType<typeof vi.fn>;
|
||||
runBeforeToolCall: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function installMockHookRunner(params?: {
|
||||
hasHooksReturn?: boolean;
|
||||
function installBeforeToolCallHook(params?: {
|
||||
enabled?: boolean;
|
||||
runBeforeToolCallImpl?: (...args: unknown[]) => unknown;
|
||||
}) {
|
||||
const hookRunner: HookRunnerMock = {
|
||||
hasHooks:
|
||||
params?.hasHooksReturn === undefined
|
||||
? vi.fn()
|
||||
: vi.fn(() => params.hasHooksReturn as boolean),
|
||||
runBeforeToolCall: params?.runBeforeToolCallImpl
|
||||
? vi.fn(params.runBeforeToolCallImpl)
|
||||
: vi.fn(),
|
||||
};
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
mockGetGlobalHookRunner.mockReturnValue(hookRunner as any);
|
||||
return hookRunner;
|
||||
}): BeforeToolCallHandlerMock {
|
||||
resetGlobalHookRunner();
|
||||
const handler = params?.runBeforeToolCallImpl
|
||||
? vi.fn(params.runBeforeToolCallImpl)
|
||||
: vi.fn(async () => undefined);
|
||||
if (params?.enabled === false) {
|
||||
return handler;
|
||||
}
|
||||
initializeGlobalHookRunner(createMockPluginRegistry([{ hookName: "before_tool_call", handler }]));
|
||||
return handler;
|
||||
}
|
||||
|
||||
describe("before_tool_call hook integration", () => {
|
||||
let hookRunner: HookRunnerMock;
|
||||
let beforeToolCallHook: BeforeToolCallHandlerMock;
|
||||
|
||||
beforeEach(() => {
|
||||
resetGlobalHookRunner();
|
||||
resetDiagnosticSessionStateForTest();
|
||||
beforeToolCallTesting.adjustedParamsByToolCallId.clear();
|
||||
hookRunner = installMockHookRunner();
|
||||
beforeToolCallHook = installBeforeToolCallHook();
|
||||
});
|
||||
|
||||
it("executes tool normally when no hook is registered", async () => {
|
||||
hookRunner.hasHooks.mockReturnValue(false);
|
||||
beforeToolCallHook = installBeforeToolCallHook({ enabled: false });
|
||||
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
const tool = wrapToolWithBeforeToolCallHook({ name: "Read", execute } as any, {
|
||||
@@ -63,7 +76,7 @@ describe("before_tool_call hook integration", () => {
|
||||
|
||||
await tool.execute("call-1", { path: "/tmp/file" }, undefined, extensionContext);
|
||||
|
||||
expect(hookRunner.runBeforeToolCall).not.toHaveBeenCalled();
|
||||
expect(beforeToolCallHook).not.toHaveBeenCalled();
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
"call-1",
|
||||
{ path: "/tmp/file" },
|
||||
@@ -73,8 +86,9 @@ describe("before_tool_call hook integration", () => {
|
||||
});
|
||||
|
||||
it("allows hook to modify parameters", async () => {
|
||||
hookRunner.hasHooks.mockReturnValue(true);
|
||||
hookRunner.runBeforeToolCall.mockResolvedValue({ params: { mode: "safe" } });
|
||||
beforeToolCallHook = installBeforeToolCallHook({
|
||||
runBeforeToolCallImpl: async () => ({ params: { mode: "safe" } }),
|
||||
});
|
||||
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
const tool = wrapToolWithBeforeToolCallHook({ name: "exec", execute } as any);
|
||||
@@ -91,10 +105,11 @@ describe("before_tool_call hook integration", () => {
|
||||
});
|
||||
|
||||
it("blocks tool execution when hook returns block=true", async () => {
|
||||
hookRunner.hasHooks.mockReturnValue(true);
|
||||
hookRunner.runBeforeToolCall.mockResolvedValue({
|
||||
block: true,
|
||||
blockReason: "blocked",
|
||||
beforeToolCallHook = installBeforeToolCallHook({
|
||||
runBeforeToolCallImpl: async () => ({
|
||||
block: true,
|
||||
blockReason: "blocked",
|
||||
}),
|
||||
});
|
||||
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
@@ -108,8 +123,11 @@ describe("before_tool_call hook integration", () => {
|
||||
});
|
||||
|
||||
it("continues execution when hook throws", async () => {
|
||||
hookRunner.hasHooks.mockReturnValue(true);
|
||||
hookRunner.runBeforeToolCall.mockRejectedValue(new Error("boom"));
|
||||
beforeToolCallHook = installBeforeToolCallHook({
|
||||
runBeforeToolCallImpl: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
});
|
||||
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
const tool = wrapToolWithBeforeToolCallHook({ name: "read", execute } as any);
|
||||
@@ -126,8 +144,9 @@ describe("before_tool_call hook integration", () => {
|
||||
});
|
||||
|
||||
it("normalizes non-object params for hook contract", async () => {
|
||||
hookRunner.hasHooks.mockReturnValue(true);
|
||||
hookRunner.runBeforeToolCall.mockResolvedValue(undefined);
|
||||
beforeToolCallHook = installBeforeToolCallHook({
|
||||
runBeforeToolCallImpl: async () => undefined,
|
||||
});
|
||||
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
const tool = wrapToolWithBeforeToolCallHook({ name: "ReAd", execute } as any, {
|
||||
@@ -140,7 +159,7 @@ describe("before_tool_call hook integration", () => {
|
||||
|
||||
await tool.execute("call-5", "not-an-object", undefined, extensionContext);
|
||||
|
||||
expect(hookRunner.runBeforeToolCall).toHaveBeenCalledWith(
|
||||
expect(beforeToolCallHook).toHaveBeenCalledWith(
|
||||
{
|
||||
toolName: "read",
|
||||
params: {},
|
||||
@@ -159,10 +178,12 @@ describe("before_tool_call hook integration", () => {
|
||||
});
|
||||
|
||||
it("keeps adjusted params isolated per run when toolCallId collides", async () => {
|
||||
hookRunner.hasHooks.mockReturnValue(true);
|
||||
hookRunner.runBeforeToolCall
|
||||
.mockResolvedValueOnce({ params: { marker: "A" } })
|
||||
.mockResolvedValueOnce({ params: { marker: "B" } });
|
||||
beforeToolCallHook = installBeforeToolCallHook({
|
||||
runBeforeToolCallImpl: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ params: { marker: "A" } })
|
||||
.mockResolvedValueOnce({ params: { marker: "B" } }),
|
||||
});
|
||||
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
const toolA = wrapToolWithBeforeToolCallHook({ name: "Read", execute } as any, {
|
||||
@@ -192,12 +213,12 @@ describe("before_tool_call hook integration", () => {
|
||||
});
|
||||
|
||||
describe("before_tool_call hook deduplication (#15502)", () => {
|
||||
let hookRunner: HookRunnerMock;
|
||||
let beforeToolCallHook: BeforeToolCallHandlerMock;
|
||||
|
||||
beforeEach(() => {
|
||||
resetGlobalHookRunner();
|
||||
resetDiagnosticSessionStateForTest();
|
||||
hookRunner = installMockHookRunner({
|
||||
hasHooksReturn: true,
|
||||
beforeToolCallHook = installBeforeToolCallHook({
|
||||
runBeforeToolCallImpl: async () => undefined,
|
||||
});
|
||||
});
|
||||
@@ -221,7 +242,7 @@ describe("before_tool_call hook deduplication (#15502)", () => {
|
||||
extensionContext,
|
||||
);
|
||||
|
||||
expect(hookRunner.runBeforeToolCall).toHaveBeenCalledTimes(1);
|
||||
expect(beforeToolCallHook).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fires hook exactly once when tool goes through wrap + abort + toToolDefinitions", async () => {
|
||||
@@ -246,21 +267,21 @@ describe("before_tool_call hook deduplication (#15502)", () => {
|
||||
extensionContext,
|
||||
);
|
||||
|
||||
expect(hookRunner.runBeforeToolCall).toHaveBeenCalledTimes(1);
|
||||
expect(beforeToolCallHook).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("before_tool_call hook integration for client tools", () => {
|
||||
let hookRunner: HookRunnerMock;
|
||||
|
||||
beforeEach(() => {
|
||||
resetGlobalHookRunner();
|
||||
resetDiagnosticSessionStateForTest();
|
||||
hookRunner = installMockHookRunner();
|
||||
installBeforeToolCallHook();
|
||||
});
|
||||
|
||||
it("passes modified params to client tool callbacks", async () => {
|
||||
hookRunner.hasHooks.mockReturnValue(true);
|
||||
hookRunner.runBeforeToolCall.mockResolvedValue({ params: { extra: true } });
|
||||
installBeforeToolCallHook({
|
||||
runBeforeToolCallImpl: async () => ({ params: { extra: true } }),
|
||||
});
|
||||
const onClientToolCall = vi.fn();
|
||||
const [tool] = toClientToolDefinitions(
|
||||
[
|
||||
|
||||
+116
-176
@@ -1,7 +1,6 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveSessionKey } from "../config/sessions.js";
|
||||
import {
|
||||
getProviderUsageMocks,
|
||||
getRunEmbeddedPiAgentMock,
|
||||
@@ -28,191 +27,132 @@ function getReplyFromConfigNow(getReplyFromConfig: () => GetReplyFromConfig): Ge
|
||||
return getReplyFromConfig();
|
||||
}
|
||||
|
||||
function seedUsageSummary(): void {
|
||||
usageMocks.loadProviderUsageSummary.mockClear();
|
||||
usageMocks.loadProviderUsageSummary.mockResolvedValue({
|
||||
updatedAt: 0,
|
||||
providers: [
|
||||
{
|
||||
provider: "anthropic",
|
||||
displayName: "Anthropic",
|
||||
windows: [
|
||||
{
|
||||
label: "5h",
|
||||
usedPercent: 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function registerTriggerHandlingUsageSummaryCases(params: {
|
||||
getReplyFromConfig: () => GetReplyFromConfig;
|
||||
}): void {
|
||||
describe("usage and status command handling", () => {
|
||||
it("handles status, usage cycles, and auth-profile status details", async () => {
|
||||
it("shows status without invoking the agent", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
|
||||
const getReplyFromConfig = getReplyFromConfigNow(params.getReplyFromConfig);
|
||||
usageMocks.loadProviderUsageSummary.mockClear();
|
||||
usageMocks.loadProviderUsageSummary.mockResolvedValue({
|
||||
updatedAt: 0,
|
||||
providers: [
|
||||
{
|
||||
provider: "anthropic",
|
||||
displayName: "Anthropic",
|
||||
windows: [
|
||||
{
|
||||
label: "5h",
|
||||
usedPercent: 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
seedUsageSummary();
|
||||
|
||||
{
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/status",
|
||||
From: "+1000",
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
makeCfg(home),
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Model:");
|
||||
expect(text).toContain("OpenClaw");
|
||||
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
{
|
||||
const cfg = makeCfg(home);
|
||||
cfg.session = { ...cfg.session, store: join(home, "usage-cycle.sessions.json") };
|
||||
const usageStorePath = requireSessionStorePath(cfg);
|
||||
const r0 = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/usage on",
|
||||
From: "+1000",
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
undefined,
|
||||
cfg,
|
||||
);
|
||||
expect(String((Array.isArray(r0) ? r0[0]?.text : r0?.text) ?? "")).toContain(
|
||||
"Usage footer: tokens",
|
||||
);
|
||||
|
||||
const r1 = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/usage",
|
||||
From: "+1000",
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
undefined,
|
||||
cfg,
|
||||
);
|
||||
expect(String((Array.isArray(r1) ? r1[0]?.text : r1?.text) ?? "")).toContain(
|
||||
"Usage footer: full",
|
||||
);
|
||||
|
||||
const r2 = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/usage",
|
||||
From: "+1000",
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
undefined,
|
||||
cfg,
|
||||
);
|
||||
expect(String((Array.isArray(r2) ? r2[0]?.text : r2?.text) ?? "")).toContain(
|
||||
"Usage footer: off",
|
||||
);
|
||||
|
||||
const r3 = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/usage",
|
||||
From: "+1000",
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
undefined,
|
||||
cfg,
|
||||
);
|
||||
expect(String((Array.isArray(r3) ? r3[0]?.text : r3?.text) ?? "")).toContain(
|
||||
"Usage footer: tokens",
|
||||
);
|
||||
const finalStore = await readSessionStore(usageStorePath);
|
||||
expect(pickFirstStoreEntry<{ responseUsage?: string }>(finalStore)?.responseUsage).toBe(
|
||||
"tokens",
|
||||
);
|
||||
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
{
|
||||
runEmbeddedPiAgentMock.mockClear();
|
||||
const cfg = makeCfg(home);
|
||||
cfg.session = { ...cfg.session, store: join(home, "auth-profile-status.sessions.json") };
|
||||
const agentDir = join(home, ".openclaw", "agents", "main", "agent");
|
||||
await mkdir(agentDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(agentDir, "auth-profiles.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:work": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-test-1234567890abcdef",
|
||||
},
|
||||
},
|
||||
lastGood: { anthropic: "anthropic:work" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const sessionKey = resolveSessionKey("per-sender", {
|
||||
From: "+1002",
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/status",
|
||||
From: "+1000",
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
} as Parameters<typeof resolveSessionKey>[1]);
|
||||
await writeFile(
|
||||
requireSessionStorePath(cfg),
|
||||
JSON.stringify(
|
||||
{
|
||||
[sessionKey]: {
|
||||
sessionId: "session-auth",
|
||||
updatedAt: Date.now(),
|
||||
authProfileOverride: "anthropic:work",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
SenderE164: "+1000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
makeCfg(home),
|
||||
);
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/status",
|
||||
From: "+1002",
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1002",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("api-key");
|
||||
expect(text).not.toContain("sk-test");
|
||||
expect(text).not.toContain("abcdef");
|
||||
expect(text).not.toContain("1234567890abcdef"); // pragma: allowlist secret
|
||||
expect(text).toContain("(anthropic:work)");
|
||||
expect(text).not.toContain("mixed");
|
||||
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
|
||||
}
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Model:");
|
||||
expect(text).toContain("OpenClaw");
|
||||
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("cycles usage footer modes and persists the final selection", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
|
||||
const getReplyFromConfig = getReplyFromConfigNow(params.getReplyFromConfig);
|
||||
const cfg = makeCfg(home);
|
||||
cfg.session = { ...cfg.session, store: join(home, "usage-cycle.sessions.json") };
|
||||
const usageStorePath = requireSessionStorePath(cfg);
|
||||
|
||||
const r0 = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/usage on",
|
||||
From: "+1000",
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
undefined,
|
||||
cfg,
|
||||
);
|
||||
expect(String((Array.isArray(r0) ? r0[0]?.text : r0?.text) ?? "")).toContain(
|
||||
"Usage footer: tokens",
|
||||
);
|
||||
|
||||
const r1 = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/usage",
|
||||
From: "+1000",
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
undefined,
|
||||
cfg,
|
||||
);
|
||||
expect(String((Array.isArray(r1) ? r1[0]?.text : r1?.text) ?? "")).toContain(
|
||||
"Usage footer: full",
|
||||
);
|
||||
|
||||
const r2 = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/usage",
|
||||
From: "+1000",
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
undefined,
|
||||
cfg,
|
||||
);
|
||||
expect(String((Array.isArray(r2) ? r2[0]?.text : r2?.text) ?? "")).toContain(
|
||||
"Usage footer: off",
|
||||
);
|
||||
|
||||
const r3 = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/usage",
|
||||
From: "+1000",
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
undefined,
|
||||
cfg,
|
||||
);
|
||||
expect(String((Array.isArray(r3) ? r3[0]?.text : r3?.text) ?? "")).toContain(
|
||||
"Usage footer: tokens",
|
||||
);
|
||||
|
||||
const finalStore = await readSessionStore(usageStorePath);
|
||||
expect(pickFirstStoreEntry<{ responseUsage?: string }>(finalStore)?.responseUsage).toBe(
|
||||
"tokens",
|
||||
);
|
||||
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+8
-4
@@ -2,7 +2,6 @@ import fs from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { loadSessionStore, resolveSessionKey } from "../config/sessions.js";
|
||||
import { getReplyFromConfig } from "./reply.js";
|
||||
import { registerGroupIntroPromptCases } from "./reply.triggers.group-intro-prompts.cases.js";
|
||||
import { registerTriggerHandlingUsageSummaryCases } from "./reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.js";
|
||||
import {
|
||||
@@ -10,7 +9,7 @@ import {
|
||||
getAbortEmbeddedPiRunMock,
|
||||
getCompactEmbeddedPiSessionMock,
|
||||
getRunEmbeddedPiAgentMock,
|
||||
installTriggerHandlingE2eTestHooks,
|
||||
installTriggerHandlingReplyHarness,
|
||||
MAIN_SESSION_KEY,
|
||||
makeCfg,
|
||||
mockRunEmbeddedPiAgentOk,
|
||||
@@ -21,6 +20,8 @@ import {
|
||||
import { enqueueFollowupRun, getFollowupQueueDepth, type FollowupRun } from "./reply/queue.js";
|
||||
import { HEARTBEAT_TOKEN } from "./tokens.js";
|
||||
|
||||
type GetReplyFromConfig = typeof import("./reply.js").getReplyFromConfig;
|
||||
|
||||
vi.mock("./reply/agent-runner.runtime.js", () => ({
|
||||
runReplyAgent: async (params: {
|
||||
commandBody: string;
|
||||
@@ -75,7 +76,10 @@ vi.mock("./reply/agent-runner.runtime.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
installTriggerHandlingE2eTestHooks();
|
||||
let getReplyFromConfig!: GetReplyFromConfig;
|
||||
installTriggerHandlingReplyHarness((impl) => {
|
||||
getReplyFromConfig = impl;
|
||||
});
|
||||
|
||||
const BASE_MESSAGE = {
|
||||
Body: "hello",
|
||||
@@ -83,7 +87,7 @@ const BASE_MESSAGE = {
|
||||
To: "+2000",
|
||||
} as const;
|
||||
|
||||
function maybeReplyText(reply: Awaited<ReturnType<typeof getReplyFromConfig>>) {
|
||||
function maybeReplyText(reply: Awaited<ReturnType<GetReplyFromConfig>>) {
|
||||
return Array.isArray(reply) ? reply[0]?.text : reply?.text;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, expect, vi } from "vitest";
|
||||
import { clearRuntimeAuthProfileStoreSnapshots } from "../agents/auth-profiles.js";
|
||||
import { resetCliCredentialCachesForTest } from "../agents/cli-credentials.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resetProviderRuntimeHookCacheForTest } from "../plugins/provider-runtime.js";
|
||||
|
||||
// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit).
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
@@ -107,6 +110,20 @@ const installModelCatalogMock = () =>
|
||||
|
||||
installModelCatalogMock();
|
||||
|
||||
vi.doMock("../agents/model-catalog.runtime.js", () => ({
|
||||
loadModelCatalog: (...args: unknown[]) => modelCatalogMocks.loadModelCatalog(...args),
|
||||
}));
|
||||
|
||||
vi.doMock("../plugins/provider-runtime.runtime.js", () => ({
|
||||
augmentModelCatalogWithProviderPlugins: async (params: { catalog?: unknown[] }) =>
|
||||
params.catalog ?? [],
|
||||
buildProviderAuthDoctorHintWithPlugin: () => undefined,
|
||||
buildProviderMissingAuthMessageWithPlugin: () => undefined,
|
||||
formatProviderAuthProfileApiKeyWithPlugin: (params: { apiKey?: string }) => params.apiKey,
|
||||
prepareProviderRuntimeAuth: async () => undefined,
|
||||
refreshProviderOAuthCredentialWithPlugin: async () => undefined,
|
||||
}));
|
||||
|
||||
const modelFallbackMocks = getSharedMocks("openclaw.trigger-handling.model-fallback-mocks", () => ({
|
||||
runWithModelFallback: vi.fn(
|
||||
async (params: {
|
||||
@@ -131,6 +148,10 @@ const installModelFallbackMock = () =>
|
||||
|
||||
installModelFallbackMock();
|
||||
|
||||
vi.doMock("../infra/git-commit.js", () => ({
|
||||
resolveCommitHash: vi.fn(() => "abcdef0"),
|
||||
}));
|
||||
|
||||
const webSessionMocks = getSharedMocks("openclaw.trigger-handling.web-session-mocks", () => ({
|
||||
webAuthExists: vi.fn().mockResolvedValue(true),
|
||||
getWebAuthAgeMs: vi.fn().mockReturnValue(120_000),
|
||||
@@ -419,6 +440,9 @@ export async function runGreetingPromptForBareNewOrReset(params: {
|
||||
|
||||
export function installTriggerHandlingE2eTestHooks() {
|
||||
afterEach(() => {
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
resetCliCredentialCachesForTest();
|
||||
resetProviderRuntimeHookCacheForTest();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,6 +29,29 @@ import type { CommandContext } from "./commands-types.js";
|
||||
import { getFollowupQueueDepth, resolveQueueSettings } from "./queue.js";
|
||||
import { resolveSubagentLabel } from "./subagents-utils.js";
|
||||
|
||||
// Some usage endpoints only work with CLI/session OAuth tokens, not API keys.
|
||||
// Skip those probes when the active auth mode cannot satisfy the endpoint.
|
||||
const USAGE_OAUTH_ONLY_PROVIDERS = new Set([
|
||||
"anthropic",
|
||||
"github-copilot",
|
||||
"google-gemini-cli",
|
||||
"openai-codex",
|
||||
]);
|
||||
|
||||
function shouldLoadUsageSummary(params: {
|
||||
provider?: string;
|
||||
selectedModelAuth?: string;
|
||||
}): boolean {
|
||||
if (!params.provider) {
|
||||
return false;
|
||||
}
|
||||
if (!USAGE_OAUTH_ONLY_PROVIDERS.has(params.provider)) {
|
||||
return true;
|
||||
}
|
||||
const auth = params.selectedModelAuth?.trim().toLowerCase();
|
||||
return Boolean(auth?.startsWith("oauth") || auth?.startsWith("token"));
|
||||
}
|
||||
|
||||
export async function buildStatusReply(params: {
|
||||
cfg: OpenClawConfig;
|
||||
command: CommandContext;
|
||||
@@ -78,6 +101,25 @@ export async function buildStatusReply(params: {
|
||||
? resolveSessionAgentId({ sessionKey, config: cfg })
|
||||
: resolveDefaultAgentId(cfg);
|
||||
const statusAgentDir = resolveAgentDir(cfg, statusAgentId);
|
||||
const modelRefs = resolveSelectedAndActiveModel({
|
||||
selectedProvider: provider,
|
||||
selectedModel: model,
|
||||
sessionEntry,
|
||||
});
|
||||
const selectedModelAuth = resolveModelAuthLabel({
|
||||
provider,
|
||||
cfg,
|
||||
sessionEntry,
|
||||
agentDir: statusAgentDir,
|
||||
});
|
||||
const activeModelAuth = modelRefs.activeDiffers
|
||||
? resolveModelAuthLabel({
|
||||
provider: modelRefs.active.provider,
|
||||
cfg,
|
||||
sessionEntry,
|
||||
agentDir: statusAgentDir,
|
||||
})
|
||||
: selectedModelAuth;
|
||||
const currentUsageProvider = (() => {
|
||||
try {
|
||||
return resolveUsageProviderId(provider);
|
||||
@@ -86,12 +128,32 @@ export async function buildStatusReply(params: {
|
||||
}
|
||||
})();
|
||||
let usageLine: string | null = null;
|
||||
if (currentUsageProvider) {
|
||||
if (
|
||||
currentUsageProvider &&
|
||||
shouldLoadUsageSummary({
|
||||
provider: currentUsageProvider,
|
||||
selectedModelAuth,
|
||||
})
|
||||
) {
|
||||
try {
|
||||
const usageSummary = await loadProviderUsageSummary({
|
||||
timeoutMs: 3500,
|
||||
providers: [currentUsageProvider],
|
||||
agentDir: statusAgentDir,
|
||||
const usageSummaryTimeoutMs = 3500;
|
||||
let usageTimeout: NodeJS.Timeout | undefined;
|
||||
const usageSummary = await Promise.race([
|
||||
loadProviderUsageSummary({
|
||||
timeoutMs: usageSummaryTimeoutMs,
|
||||
providers: [currentUsageProvider],
|
||||
agentDir: statusAgentDir,
|
||||
}),
|
||||
new Promise<never>((_, reject) => {
|
||||
usageTimeout = setTimeout(
|
||||
() => reject(new Error("usage summary timeout")),
|
||||
usageSummaryTimeoutMs,
|
||||
);
|
||||
}),
|
||||
]).finally(() => {
|
||||
if (usageTimeout) {
|
||||
clearTimeout(usageTimeout);
|
||||
}
|
||||
});
|
||||
const usageEntry = usageSummary.providers[0];
|
||||
if (usageEntry && !usageEntry.error && usageEntry.windows.length > 0) {
|
||||
@@ -143,25 +205,6 @@ export async function buildStatusReply(params: {
|
||||
const groupActivation = isGroup
|
||||
? (normalizeGroupActivation(sessionEntry?.groupActivation) ?? defaultGroupActivation())
|
||||
: undefined;
|
||||
const modelRefs = resolveSelectedAndActiveModel({
|
||||
selectedProvider: provider,
|
||||
selectedModel: model,
|
||||
sessionEntry,
|
||||
});
|
||||
const selectedModelAuth = resolveModelAuthLabel({
|
||||
provider,
|
||||
cfg,
|
||||
sessionEntry,
|
||||
agentDir: statusAgentDir,
|
||||
});
|
||||
const activeModelAuth = modelRefs.activeDiffers
|
||||
? resolveModelAuthLabel({
|
||||
provider: modelRefs.active.provider,
|
||||
cfg,
|
||||
sessionEntry,
|
||||
agentDir: statusAgentDir,
|
||||
})
|
||||
: selectedModelAuth;
|
||||
const agentDefaults = cfg.agents?.defaults ?? {};
|
||||
const effectiveFastMode =
|
||||
resolvedFastMode ??
|
||||
|
||||
@@ -314,88 +314,100 @@ export async function handleDirectiveOnly(
|
||||
directives.elevatedLevel !== undefined &&
|
||||
elevatedEnabled &&
|
||||
elevatedAllowed;
|
||||
const shouldPersistSessionEntry =
|
||||
(directives.hasThinkDirective && Boolean(directives.thinkLevel)) ||
|
||||
(directives.hasFastDirective && directives.fastMode !== undefined) ||
|
||||
(directives.hasVerboseDirective && Boolean(directives.verboseLevel)) ||
|
||||
(directives.hasReasoningDirective && Boolean(directives.reasoningLevel)) ||
|
||||
(directives.hasElevatedDirective && Boolean(directives.elevatedLevel)) ||
|
||||
(directives.hasExecDirective && directives.hasExecOptions && allowInternalExecPersistence) ||
|
||||
Boolean(modelSelection) ||
|
||||
directives.hasQueueDirective ||
|
||||
shouldDowngradeXHigh;
|
||||
const fastModeChanged =
|
||||
directives.hasFastDirective &&
|
||||
directives.fastMode !== undefined &&
|
||||
directives.fastMode !== currentFastMode;
|
||||
let reasoningChanged =
|
||||
directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
|
||||
if (directives.hasThinkDirective && directives.thinkLevel) {
|
||||
sessionEntry.thinkingLevel = directives.thinkLevel;
|
||||
}
|
||||
if (directives.hasFastDirective && directives.fastMode !== undefined) {
|
||||
sessionEntry.fastMode = directives.fastMode;
|
||||
}
|
||||
if (shouldDowngradeXHigh) {
|
||||
sessionEntry.thinkingLevel = "high";
|
||||
}
|
||||
if (directives.hasVerboseDirective && directives.verboseLevel) {
|
||||
applyVerboseOverride(sessionEntry, directives.verboseLevel);
|
||||
}
|
||||
if (directives.hasReasoningDirective && directives.reasoningLevel) {
|
||||
if (directives.reasoningLevel === "off") {
|
||||
// Persist explicit off so it overrides model-capability defaults.
|
||||
sessionEntry.reasoningLevel = "off";
|
||||
} else {
|
||||
sessionEntry.reasoningLevel = directives.reasoningLevel;
|
||||
if (shouldPersistSessionEntry) {
|
||||
if (directives.hasThinkDirective && directives.thinkLevel) {
|
||||
sessionEntry.thinkingLevel = directives.thinkLevel;
|
||||
}
|
||||
reasoningChanged =
|
||||
directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined;
|
||||
}
|
||||
if (directives.hasElevatedDirective && directives.elevatedLevel) {
|
||||
// Unlike other toggles, elevated defaults can be "on".
|
||||
// Persist "off" explicitly so `/elevated off` actually overrides defaults.
|
||||
sessionEntry.elevatedLevel = directives.elevatedLevel;
|
||||
elevatedChanged =
|
||||
elevatedChanged ||
|
||||
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
|
||||
}
|
||||
if (directives.hasExecDirective && directives.hasExecOptions && allowInternalExecPersistence) {
|
||||
if (directives.execHost) {
|
||||
sessionEntry.execHost = directives.execHost;
|
||||
if (directives.hasFastDirective && directives.fastMode !== undefined) {
|
||||
sessionEntry.fastMode = directives.fastMode;
|
||||
}
|
||||
if (directives.execSecurity) {
|
||||
sessionEntry.execSecurity = directives.execSecurity;
|
||||
if (shouldDowngradeXHigh) {
|
||||
sessionEntry.thinkingLevel = "high";
|
||||
}
|
||||
if (directives.execAsk) {
|
||||
sessionEntry.execAsk = directives.execAsk;
|
||||
if (directives.hasVerboseDirective && directives.verboseLevel) {
|
||||
applyVerboseOverride(sessionEntry, directives.verboseLevel);
|
||||
}
|
||||
if (directives.execNode) {
|
||||
sessionEntry.execNode = directives.execNode;
|
||||
if (directives.hasReasoningDirective && directives.reasoningLevel) {
|
||||
if (directives.reasoningLevel === "off") {
|
||||
// Persist explicit off so it overrides model-capability defaults.
|
||||
sessionEntry.reasoningLevel = "off";
|
||||
} else {
|
||||
sessionEntry.reasoningLevel = directives.reasoningLevel;
|
||||
}
|
||||
reasoningChanged =
|
||||
directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined;
|
||||
}
|
||||
}
|
||||
if (modelSelection) {
|
||||
applyModelOverrideToSessionEntry({
|
||||
entry: sessionEntry,
|
||||
selection: modelSelection,
|
||||
profileOverride,
|
||||
});
|
||||
}
|
||||
if (directives.hasQueueDirective && directives.queueReset) {
|
||||
delete sessionEntry.queueMode;
|
||||
delete sessionEntry.queueDebounceMs;
|
||||
delete sessionEntry.queueCap;
|
||||
delete sessionEntry.queueDrop;
|
||||
} else if (directives.hasQueueDirective) {
|
||||
if (directives.queueMode) {
|
||||
sessionEntry.queueMode = directives.queueMode;
|
||||
if (directives.hasElevatedDirective && directives.elevatedLevel) {
|
||||
// Unlike other toggles, elevated defaults can be "on".
|
||||
// Persist "off" explicitly so `/elevated off` actually overrides defaults.
|
||||
sessionEntry.elevatedLevel = directives.elevatedLevel;
|
||||
elevatedChanged =
|
||||
elevatedChanged ||
|
||||
(directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
|
||||
}
|
||||
if (typeof directives.debounceMs === "number") {
|
||||
sessionEntry.queueDebounceMs = directives.debounceMs;
|
||||
if (directives.hasExecDirective && directives.hasExecOptions && allowInternalExecPersistence) {
|
||||
if (directives.execHost) {
|
||||
sessionEntry.execHost = directives.execHost;
|
||||
}
|
||||
if (directives.execSecurity) {
|
||||
sessionEntry.execSecurity = directives.execSecurity;
|
||||
}
|
||||
if (directives.execAsk) {
|
||||
sessionEntry.execAsk = directives.execAsk;
|
||||
}
|
||||
if (directives.execNode) {
|
||||
sessionEntry.execNode = directives.execNode;
|
||||
}
|
||||
}
|
||||
if (typeof directives.cap === "number") {
|
||||
sessionEntry.queueCap = directives.cap;
|
||||
if (modelSelection) {
|
||||
applyModelOverrideToSessionEntry({
|
||||
entry: sessionEntry,
|
||||
selection: modelSelection,
|
||||
profileOverride,
|
||||
});
|
||||
}
|
||||
if (directives.dropPolicy) {
|
||||
sessionEntry.queueDrop = directives.dropPolicy;
|
||||
if (directives.hasQueueDirective && directives.queueReset) {
|
||||
delete sessionEntry.queueMode;
|
||||
delete sessionEntry.queueDebounceMs;
|
||||
delete sessionEntry.queueCap;
|
||||
delete sessionEntry.queueDrop;
|
||||
} else if (directives.hasQueueDirective) {
|
||||
if (directives.queueMode) {
|
||||
sessionEntry.queueMode = directives.queueMode;
|
||||
}
|
||||
if (typeof directives.debounceMs === "number") {
|
||||
sessionEntry.queueDebounceMs = directives.debounceMs;
|
||||
}
|
||||
if (typeof directives.cap === "number") {
|
||||
sessionEntry.queueCap = directives.cap;
|
||||
}
|
||||
if (directives.dropPolicy) {
|
||||
sessionEntry.queueDrop = directives.dropPolicy;
|
||||
}
|
||||
}
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = sessionEntry;
|
||||
});
|
||||
}
|
||||
}
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = sessionEntry;
|
||||
});
|
||||
}
|
||||
if (modelSelection) {
|
||||
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "../errors.js";
|
||||
import {
|
||||
assertBrowserNavigationAllowed,
|
||||
withBrowserNavigationPolicy,
|
||||
} from "../navigation-guard.js";
|
||||
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
||||
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
|
||||
import { getProfileContext, jsonError, toNumber, toStringOrEmpty } from "./utils.js";
|
||||
@@ -128,6 +132,10 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
|
||||
ctx,
|
||||
mapTabError: true,
|
||||
run: async (profileCtx) => {
|
||||
await assertBrowserNavigationAllowed({
|
||||
url,
|
||||
...withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy),
|
||||
});
|
||||
await profileCtx.ensureBrowserAvailable();
|
||||
const tab = await profileCtx.openTab(url);
|
||||
res.json(tab);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
import "./server-context.chrome-test-harness.js";
|
||||
import * as chromeModule from "./chrome.js";
|
||||
import type { RunningChrome } from "./chrome.js";
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import fs from "node:fs";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
import { createBrowserRouteContext } from "./server-context.js";
|
||||
import type { BrowserServerState } from "./server-context.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
import "./server-context.chrome-test-harness.js";
|
||||
import * as chromeModule from "./chrome.js";
|
||||
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
import "./server-context.chrome-test-harness.js";
|
||||
import * as cdpModule from "./cdp.js";
|
||||
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
|
||||
|
||||
@@ -56,8 +56,8 @@ vi.mock("./pw-ai-state.js", () => ({
|
||||
isPwAiLoaded: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
const { startBrowserControlServerFromConfig, stopBrowserControlServer } =
|
||||
await import("./server.js");
|
||||
let startBrowserControlServerFromConfig: typeof import("./server.js").startBrowserControlServerFromConfig;
|
||||
let stopBrowserControlServer: typeof import("./server.js").stopBrowserControlServer;
|
||||
|
||||
describe("browser control auth bootstrap failures", () => {
|
||||
beforeEach(async () => {
|
||||
@@ -65,10 +65,14 @@ describe("browser control auth bootstrap failures", () => {
|
||||
mocks.ensureBrowserControlAuth.mockClear();
|
||||
mocks.resolveBrowserControlAuth.mockClear();
|
||||
mocks.ensureExtensionRelayForProfiles.mockClear();
|
||||
vi.resetModules();
|
||||
({ startBrowserControlServerFromConfig, stopBrowserControlServer } =
|
||||
await import("./server.js"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopBrowserControlServer();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("fails closed when auth bootstrap throws and no auth is configured", async () => {
|
||||
|
||||
@@ -184,6 +184,18 @@ export function getChromeMcpMocks(): Record<string, MockFn> {
|
||||
const chromeUserDataDir = vi.hoisted(() => ({ dir: "/tmp/openclaw" }));
|
||||
installChromeUserDataDirHooks(chromeUserDataDir);
|
||||
|
||||
type BrowserServerModule = typeof import("./server.js");
|
||||
let browserServerModule: BrowserServerModule | null = null;
|
||||
|
||||
async function loadBrowserServerModule(): Promise<BrowserServerModule> {
|
||||
if (browserServerModule) {
|
||||
return browserServerModule;
|
||||
}
|
||||
vi.resetModules();
|
||||
browserServerModule = await import("./server.js");
|
||||
return browserServerModule;
|
||||
}
|
||||
|
||||
function makeProc(pid = 123) {
|
||||
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
return {
|
||||
@@ -303,9 +315,19 @@ vi.mock("./screenshot.js", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
const server = await import("./server.js");
|
||||
export const startBrowserControlServerFromConfig = server.startBrowserControlServerFromConfig;
|
||||
export const stopBrowserControlServer = server.stopBrowserControlServer;
|
||||
export async function startBrowserControlServerFromConfig() {
|
||||
const server = await loadBrowserServerModule();
|
||||
return await server.startBrowserControlServerFromConfig();
|
||||
}
|
||||
|
||||
export async function stopBrowserControlServer(): Promise<void> {
|
||||
const server = browserServerModule;
|
||||
browserServerModule = null;
|
||||
if (!server) {
|
||||
return;
|
||||
}
|
||||
await server.stopBrowserControlServer();
|
||||
}
|
||||
|
||||
export function makeResponse(
|
||||
body: unknown,
|
||||
@@ -387,6 +409,7 @@ export function installBrowserControlServerHooks() {
|
||||
});
|
||||
|
||||
await resetBrowserControlServerTestContext();
|
||||
await loadBrowserServerModule();
|
||||
|
||||
// Minimal CDP JSON endpoints used by the server.
|
||||
let putNewCalls = 0;
|
||||
|
||||
@@ -65,8 +65,8 @@ vi.mock("./server-context.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
const { startBrowserControlServerFromConfig, stopBrowserControlServer } =
|
||||
await import("./server.js");
|
||||
let startBrowserControlServerFromConfig: typeof import("./server.js").startBrowserControlServerFromConfig;
|
||||
let stopBrowserControlServer: typeof import("./server.js").stopBrowserControlServer;
|
||||
|
||||
describe("browser control evaluate gating", () => {
|
||||
beforeEach(async () => {
|
||||
@@ -83,6 +83,9 @@ describe("browser control evaluate gating", () => {
|
||||
pwMocks.evaluateViaPlaywright.mockClear();
|
||||
routeCtxMocks.profileCtx.ensureTabAvailable.mockClear();
|
||||
routeCtxMocks.profileCtx.stopRunningBrowser.mockClear();
|
||||
vi.resetModules();
|
||||
({ startBrowserControlServerFromConfig, stopBrowserControlServer } =
|
||||
await import("./server.js"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -104,6 +107,7 @@ describe("browser control evaluate gating", () => {
|
||||
}
|
||||
|
||||
await stopBrowserControlServer();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("blocks act:evaluate but still allows cookies/storage reads", async () => {
|
||||
|
||||
@@ -16286,6 +16286,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA = {
|
||||
tags: ["security", "auth"],
|
||||
},
|
||||
},
|
||||
version: "2026.3.22-beta.1",
|
||||
version: "2026.3.22",
|
||||
generatedAt: "2026-03-22T21:17:33.302Z",
|
||||
} as const satisfies BaseConfigSchemaResponse;
|
||||
|
||||
@@ -21,6 +21,7 @@ import { isModelNotFoundErrorMessage } from "../agents/live-model-errors.js";
|
||||
import { isModernModelRef } from "../agents/live-model-filter.js";
|
||||
import { isLiveTestEnabled } from "../agents/live-test-helpers.js";
|
||||
import { getApiKeyForModel } from "../agents/model-auth.js";
|
||||
import { normalizeGoogleModelId } from "../agents/model-id-normalization.js";
|
||||
import { shouldSuppressBuiltInModel } from "../agents/model-suppression.js";
|
||||
import { ensureOpenClawModelsJson } from "../agents/models-config.js";
|
||||
import { isRateLimitErrorMessage } from "../agents/pi-embedded-helpers/errors.js";
|
||||
@@ -60,7 +61,10 @@ const GATEWAY_LIVE_HEARTBEAT_MS = Math.max(
|
||||
1_000,
|
||||
toInt(process.env.OPENCLAW_LIVE_GATEWAY_HEARTBEAT_MS, 30_000),
|
||||
);
|
||||
const GATEWAY_LIVE_STRIP_SCAFFOLDING_MODEL_KEYS = new Set(["google/gemini-3-flash-preview"]);
|
||||
const GATEWAY_LIVE_STRIP_SCAFFOLDING_MODEL_KEYS = new Set([
|
||||
"google/gemini-3-flash-preview",
|
||||
"google/gemini-3-pro-preview",
|
||||
]);
|
||||
const GATEWAY_LIVE_MAX_MODELS = resolveGatewayLiveMaxModels();
|
||||
const GATEWAY_LIVE_SUITE_TIMEOUT_MS = resolveGatewayLiveSuiteTimeoutMs(GATEWAY_LIVE_MAX_MODELS);
|
||||
|
||||
@@ -271,7 +275,18 @@ function isMeaningful(text: string): boolean {
|
||||
}
|
||||
|
||||
function shouldStripAssistantScaffoldingForLiveModel(modelKey?: string): boolean {
|
||||
return !!modelKey && GATEWAY_LIVE_STRIP_SCAFFOLDING_MODEL_KEYS.has(modelKey);
|
||||
if (!modelKey) {
|
||||
return false;
|
||||
}
|
||||
if (GATEWAY_LIVE_STRIP_SCAFFOLDING_MODEL_KEYS.has(modelKey)) {
|
||||
return true;
|
||||
}
|
||||
const [provider, ...rest] = modelKey.split("/");
|
||||
if (provider !== "google" || rest.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const normalizedKey = `${provider}/${normalizeGoogleModelId(rest.join("/"))}`;
|
||||
return GATEWAY_LIVE_STRIP_SCAFFOLDING_MODEL_KEYS.has(normalizedKey);
|
||||
}
|
||||
|
||||
function maybeStripAssistantScaffoldingForLiveModel(text: string, modelKey?: string): string {
|
||||
@@ -282,11 +297,11 @@ function maybeStripAssistantScaffoldingForLiveModel(text: string, modelKey?: str
|
||||
}
|
||||
|
||||
describe("maybeStripAssistantScaffoldingForLiveModel", () => {
|
||||
it("strips scaffolding only for the targeted live model", () => {
|
||||
it("strips scaffolding for the gemini 3.1 flash alias and targeted live models", () => {
|
||||
expect(
|
||||
maybeStripAssistantScaffoldingForLiveModel(
|
||||
"<think>hidden</think>Visible",
|
||||
"google/gemini-3-flash-preview",
|
||||
"google/gemini-3.1-flash-preview",
|
||||
),
|
||||
).toBe("Visible");
|
||||
expect(
|
||||
@@ -294,6 +309,12 @@ describe("maybeStripAssistantScaffoldingForLiveModel", () => {
|
||||
"<think>hidden</think>Visible",
|
||||
"google/gemini-3-pro-preview",
|
||||
),
|
||||
).toBe("Visible");
|
||||
expect(
|
||||
maybeStripAssistantScaffoldingForLiveModel(
|
||||
"<think>hidden</think>Visible",
|
||||
"google/gemini-2.5-flash",
|
||||
),
|
||||
).toBe("<think>hidden</think>Visible");
|
||||
});
|
||||
});
|
||||
@@ -1385,9 +1406,14 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
|
||||
logProgress(`${progressLabel}: skip (tool probe refusal)`);
|
||||
break;
|
||||
}
|
||||
if (model.provider === "anthropic" && isToolNonceProbeMiss(message)) {
|
||||
if (
|
||||
(model.provider === "anthropic" ||
|
||||
model.provider === "minimax" ||
|
||||
model.provider === "opencode-go") &&
|
||||
isToolNonceProbeMiss(message)
|
||||
) {
|
||||
skippedCount += 1;
|
||||
logProgress(`${progressLabel}: skip (anthropic tool probe nonce miss)`);
|
||||
logProgress(`${progressLabel}: skip (${model.provider} tool probe nonce miss)`);
|
||||
break;
|
||||
}
|
||||
if (isMissingProfileError(message)) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearRuntimeAuthProfileStoreSnapshots } from "../agents/auth-profiles.js";
|
||||
import { NON_ENV_SECRETREF_MARKER } from "../agents/model-auth-markers.js";
|
||||
import { clearConfigCache, type OpenClawConfig } from "../config/config.js";
|
||||
|
||||
const resolveProviderUsageAuthWithPluginMock = vi.fn(async (..._args: unknown[]) => null);
|
||||
|
||||
@@ -10,6 +12,12 @@ vi.mock("../plugins/provider-runtime.js", () => ({
|
||||
resolveProviderUsageAuthWithPlugin: resolveProviderUsageAuthWithPluginMock,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/cli-credentials.js", () => ({
|
||||
readCodexCliCredentialsCached: () => null,
|
||||
readMiniMaxCliCredentialsCached: () => null,
|
||||
readQwenCliCredentialsCached: () => null,
|
||||
}));
|
||||
|
||||
let resolveProviderAuths: typeof import("./provider-usage.auth.js").resolveProviderAuths;
|
||||
type ProviderAuth = import("./provider-usage.auth.js").ProviderAuth;
|
||||
|
||||
@@ -36,65 +44,59 @@ describe("resolveProviderAuths key normalization", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
clearConfigCache();
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
resolveProviderUsageAuthWithPluginMock.mockReset();
|
||||
resolveProviderUsageAuthWithPluginMock.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
async function withSuiteHome<T>(
|
||||
fn: (home: string) => Promise<T>,
|
||||
env: Record<string, string | undefined>,
|
||||
): Promise<T> {
|
||||
afterEach(() => {
|
||||
clearConfigCache();
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
});
|
||||
|
||||
async function withSuiteHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
const base = path.join(suiteRoot, `case-${++suiteCase}`);
|
||||
await fs.mkdir(base, { recursive: true });
|
||||
await fs.mkdir(path.join(base, ".openclaw", "agents", "main", "sessions"), { recursive: true });
|
||||
return await fn(base);
|
||||
}
|
||||
|
||||
const keysToRestore = new Set<string>([
|
||||
"HOME",
|
||||
"USERPROFILE",
|
||||
"HOMEDRIVE",
|
||||
"HOMEPATH",
|
||||
"OPENCLAW_HOME",
|
||||
"OPENCLAW_STATE_DIR",
|
||||
...Object.keys(env),
|
||||
]);
|
||||
const snapshot: Record<string, string | undefined> = {};
|
||||
for (const key of keysToRestore) {
|
||||
snapshot[key] = process.env[key];
|
||||
}
|
||||
function agentDirForHome(home: string): string {
|
||||
return path.join(home, ".openclaw", "agents", "main", "agent");
|
||||
}
|
||||
|
||||
process.env.HOME = base;
|
||||
process.env.USERPROFILE = base;
|
||||
if (process.platform === "win32") {
|
||||
const match = base.match(/^([A-Za-z]:)(.*)$/);
|
||||
if (match) {
|
||||
process.env.HOMEDRIVE = match[1];
|
||||
process.env.HOMEPATH = match[2] || "\\";
|
||||
}
|
||||
}
|
||||
delete process.env.OPENCLAW_HOME;
|
||||
process.env.OPENCLAW_STATE_DIR = path.join(base, ".openclaw");
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
function buildSuiteEnv(
|
||||
home: string,
|
||||
env: Record<string, string | undefined> = {},
|
||||
): NodeJS.ProcessEnv {
|
||||
const suiteEnv: NodeJS.ProcessEnv = {
|
||||
...EMPTY_PROVIDER_ENV,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
OPENCLAW_STATE_DIR: path.join(home, ".openclaw"),
|
||||
...env,
|
||||
};
|
||||
const match = home.match(/^([A-Za-z]:)(.*)$/);
|
||||
if (match) {
|
||||
suiteEnv.HOMEDRIVE = match[1];
|
||||
suiteEnv.HOMEPATH = match[2] || "\\";
|
||||
}
|
||||
return suiteEnv;
|
||||
}
|
||||
|
||||
async function readConfigForHome(home: string): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return await fn(base);
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(snapshot)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
return JSON.parse(
|
||||
await fs.readFile(path.join(home, ".openclaw", "openclaw.json"), "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAuthProfiles(home: string, profiles: Record<string, unknown>) {
|
||||
const agentDir = path.join(home, ".openclaw", "agents", "main", "agent");
|
||||
const agentDir = agentDirForHome(home);
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(agentDir, "auth-profiles.json"),
|
||||
@@ -114,7 +116,7 @@ describe("resolveProviderAuths key normalization", () => {
|
||||
}
|
||||
|
||||
async function writeProfileOrder(home: string, provider: string, profileIds: string[]) {
|
||||
const agentDir = path.join(home, ".openclaw", "agents", "main", "agent");
|
||||
const agentDir = agentDirForHome(home);
|
||||
const parsed = JSON.parse(
|
||||
await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
@@ -149,28 +151,26 @@ describe("resolveProviderAuths key normalization", () => {
|
||||
}
|
||||
|
||||
async function resolveMinimaxAuthFromConfiguredKey(apiKey: string) {
|
||||
return await withSuiteHome(
|
||||
async (home) => {
|
||||
await writeConfig(home, {
|
||||
models: {
|
||||
providers: {
|
||||
minimax: {
|
||||
baseUrl: "https://api.minimaxi.com",
|
||||
models: [createTestModelDefinition()],
|
||||
apiKey,
|
||||
},
|
||||
return await withSuiteHome(async (home) => {
|
||||
await writeConfig(home, {
|
||||
models: {
|
||||
providers: {
|
||||
minimax: {
|
||||
baseUrl: "https://api.minimaxi.com",
|
||||
models: [createTestModelDefinition()],
|
||||
apiKey,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return await resolveProviderAuths({
|
||||
providers: ["minimax"],
|
||||
});
|
||||
},
|
||||
{
|
||||
...EMPTY_PROVIDER_ENV,
|
||||
},
|
||||
);
|
||||
return await resolveProviderAuths({
|
||||
providers: ["minimax"],
|
||||
agentDir: agentDirForHome(home),
|
||||
config: (await readConfigForHome(home)) as OpenClawConfig,
|
||||
env: buildSuiteEnv(home),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function expectResolvedAuthsFromSuiteHome(params: {
|
||||
@@ -179,19 +179,16 @@ describe("resolveProviderAuths key normalization", () => {
|
||||
env?: Record<string, string | undefined>;
|
||||
setup?: (home: string) => Promise<void>;
|
||||
}) {
|
||||
await withSuiteHome(
|
||||
async (home) => {
|
||||
await params.setup?.(home);
|
||||
const auths = await resolveProviderAuths({
|
||||
providers: params.providers,
|
||||
});
|
||||
expect(auths).toEqual(params.expected);
|
||||
},
|
||||
{
|
||||
...EMPTY_PROVIDER_ENV,
|
||||
...params.env,
|
||||
},
|
||||
);
|
||||
await withSuiteHome(async (home) => {
|
||||
await params.setup?.(home);
|
||||
const auths = await resolveProviderAuths({
|
||||
providers: params.providers,
|
||||
agentDir: agentDirForHome(home),
|
||||
config: (await readConfigForHome(home)) as OpenClawConfig,
|
||||
env: buildSuiteEnv(home, params.env),
|
||||
});
|
||||
expect(auths).toEqual(params.expected);
|
||||
});
|
||||
}
|
||||
|
||||
it.each([
|
||||
@@ -401,18 +398,24 @@ describe("resolveProviderAuths key normalization", () => {
|
||||
|
||||
const auths = await resolveProviderAuths({
|
||||
providers: ["anthropic"],
|
||||
agentDir: agentDirForHome(home),
|
||||
config: (await readConfigForHome(home)) as OpenClawConfig,
|
||||
env: buildSuiteEnv(home),
|
||||
});
|
||||
expect(auths).toEqual([]);
|
||||
}, {});
|
||||
});
|
||||
});
|
||||
|
||||
it("skips providers without oauth-compatible profiles", async () => {
|
||||
await withSuiteHome(async () => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const auths = await resolveProviderAuths({
|
||||
providers: ["anthropic"],
|
||||
agentDir: agentDirForHome(home),
|
||||
config: (await readConfigForHome(home)) as OpenClawConfig,
|
||||
env: buildSuiteEnv(home),
|
||||
});
|
||||
expect(auths).toEqual([]);
|
||||
}, {});
|
||||
});
|
||||
});
|
||||
|
||||
it("skips oauth profiles that resolve without an api key and uses later profiles", async () => {
|
||||
@@ -430,9 +433,12 @@ describe("resolveProviderAuths key normalization", () => {
|
||||
|
||||
const auths = await resolveProviderAuths({
|
||||
providers: ["anthropic"],
|
||||
agentDir: agentDirForHome(home),
|
||||
config: (await readConfigForHome(home)) as OpenClawConfig,
|
||||
env: buildSuiteEnv(home),
|
||||
});
|
||||
expect(auths).toEqual([{ provider: "anthropic", token: "anthropic-token" }]);
|
||||
}, {});
|
||||
});
|
||||
});
|
||||
|
||||
it("skips api_key entries in oauth token resolution order", async () => {
|
||||
@@ -445,9 +451,12 @@ describe("resolveProviderAuths key normalization", () => {
|
||||
|
||||
const auths = await resolveProviderAuths({
|
||||
providers: ["anthropic"],
|
||||
agentDir: agentDirForHome(home),
|
||||
config: (await readConfigForHome(home)) as OpenClawConfig,
|
||||
env: buildSuiteEnv(home),
|
||||
});
|
||||
expect(auths).toEqual([{ provider: "anthropic", token: "token-1" }]);
|
||||
}, {});
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores marker-backed config keys for provider usage auth resolution", async () => {
|
||||
|
||||
@@ -66,8 +66,16 @@ describe("memory search async sync", () => {
|
||||
const cfg = buildConfig();
|
||||
manager = await createMemoryManagerOrThrow(cfg);
|
||||
|
||||
const pending = new Promise<void>(() => {});
|
||||
const syncMock = vi.fn(async () => pending);
|
||||
let releaseSync = () => {};
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
releaseSync = () => resolve();
|
||||
}).finally(() => {
|
||||
(manager as unknown as { syncing: Promise<void> | null }).syncing = null;
|
||||
});
|
||||
const syncMock = vi.fn(async () => {
|
||||
(manager as unknown as { syncing: Promise<void> | null }).syncing = pending;
|
||||
return pending;
|
||||
});
|
||||
(manager as unknown as { sync: () => Promise<void> }).sync = syncMock;
|
||||
|
||||
const activeManager = manager;
|
||||
@@ -76,6 +84,10 @@ describe("memory search async sync", () => {
|
||||
}
|
||||
await activeManager.search("hello");
|
||||
expect(syncMock).toHaveBeenCalledTimes(1);
|
||||
releaseSync();
|
||||
await vi.waitFor(() => {
|
||||
expect((manager as unknown as { syncing: Promise<void> | null }).syncing).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for in-flight search sync during close", async () => {
|
||||
|
||||
@@ -37,6 +37,7 @@ function createToolHandlerCtx(params: {
|
||||
sessionId: params.sessionId,
|
||||
onBlockReplyFlush: params.onBlockReplyFlush,
|
||||
},
|
||||
hookRunner: hookMocks.runner,
|
||||
state: {
|
||||
toolMetaById: new Map<string, string | undefined>(),
|
||||
...createBaseToolHandlerState(),
|
||||
|
||||
Reference in New Issue
Block a user