OpenClaw-Setup/openclaw-setup-copilot/scripts/model_budget_guard.sh

203 lines
5.8 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
CONFIG_PATH="${MODEL_GUARD_CONFIG:-$ROOT_DIR/config/model-budget-guard.config.json}"
if ! command -v jq >/dev/null 2>&1; then
echo "[model-guard] jq is required" >&2
exit 1
fi
if ! command -v openclaw >/dev/null 2>&1; then
echo "[model-guard] openclaw CLI is required" >&2
exit 1
fi
expand_tilde() {
local p="$1"
if [[ "$p" == "~" ]]; then
echo "$HOME"
elif [[ "$p" == "~/"* ]]; then
echo "$HOME/${p#~/}"
else
echo "$p"
fi
}
safe_now_ms() {
python3 - <<'PY'
import time
print(int(time.time()*1000))
PY
}
send_notice() {
local text="$1"
local last_channel="$2"
local last_to="$3"
if [[ -z "$last_channel" || -z "$last_to" ]]; then
echo "[model-guard] notice skipped (missing channel/target): $text" >&2
return 0
fi
local target="$last_to"
if [[ "$target" == *:* ]]; then
target="${target#*:}"
fi
openclaw message send \
--channel "$last_channel" \
--target "$target" \
--message "$text" \
>/dev/null 2>&1 || true
}
if [[ ! -f "$CONFIG_PATH" ]]; then
echo "[model-guard] missing config: $CONFIG_PATH" >&2
exit 1
fi
enabled="$(jq -r '.enabled // true' "$CONFIG_PATH")"
if [[ "$enabled" != "true" ]]; then
exit 0
fi
session_key="$(jq -r '.sessionKey // "agent:main:main"' "$CONFIG_PATH")"
low_model="$(jq -r '.lowModel // empty' "$CONFIG_PATH")"
warn_after_min="$(jq -r '.warnAfterMinutes // 2' "$CONFIG_PATH")"
revert_after_min="$(jq -r '.revertAfterMinutes // 45' "$CONFIG_PATH")"
min_warn_interval_min="$(jq -r '.minWarnIntervalMinutes // 20' "$CONFIG_PATH")"
state_file_raw="$(jq -r '.stateFile // "~/.openclaw/model-budget-guard-state.json"' "$CONFIG_PATH")"
state_file="$(expand_tilde "$state_file_raw")"
if [[ -z "$low_model" ]]; then
echo "[model-guard] lowModel must be set" >&2
exit 1
fi
state_dir="${OPENCLAW_STATE_DIR:-$HOME/.openclaw}"
sessions_file="$state_dir/agents/main/sessions/sessions.json"
if [[ ! -r "$sessions_file" ]]; then
echo "[model-guard] sessions file not found: $sessions_file" >&2
exit 0
fi
session_json="$(jq -c --arg key "$session_key" '.[$key] // {}' "$sessions_file" 2>/dev/null || true)"
if [[ -z "$session_json" || "$session_json" == "{}" ]]; then
exit 0
fi
session_id="$(jq -r '.sessionId // empty' <<<"$session_json")"
updated_at="$(jq -r '.updatedAt // 0' <<<"$session_json")"
model_raw="$(jq -r '.model // empty' <<<"$session_json")"
provider_raw="$(jq -r '.provider // empty' <<<"$session_json")"
last_channel="$(jq -r '.lastChannel // empty' <<<"$session_json")"
last_to="$(jq -r '.lastTo // empty' <<<"$session_json")"
if [[ -z "$model_raw" ]]; then
exit 0
fi
model_full="$model_raw"
if [[ "$model_raw" != */* && -n "$provider_raw" && "$provider_raw" != "null" ]]; then
model_full="$provider_raw/$model_raw"
fi
high_match="$(jq -r --arg m "$model_full" '.highModels // [] | index($m) != null' "$CONFIG_PATH")"
if [[ "$high_match" != "true" ]]; then
exit 0
fi
if [[ ! "$updated_at" =~ ^[0-9]+$ ]]; then
exit 0
fi
now_ms="$(safe_now_ms)"
age_ms=$((now_ms - updated_at))
if (( age_ms < 0 )); then age_ms=0; fi
warn_after_ms=$((warn_after_min * 60 * 1000))
revert_after_ms=$((revert_after_min * 60 * 1000))
min_warn_interval_ms=$((min_warn_interval_min * 60 * 1000))
mkdir -p "$(dirname "$state_file")"
if [[ ! -f "$state_file" ]]; then
cat > "$state_file" <<'JSON'
{}
JSON
fi
state_json="$(cat "$state_file")"
last_warned_model="$(jq -r --arg key "$session_key" '.[$key].lastWarnedModel // empty' <<<"$state_json")"
last_warned_session="$(jq -r --arg key "$session_key" '.[$key].lastWarnedSessionId // empty' <<<"$state_json")"
last_warn_at="$(jq -r --arg key "$session_key" '.[$key].lastWarnAt // 0' <<<"$state_json")"
last_revert_at="$(jq -r --arg key "$session_key" '.[$key].lastRevertAt // 0' <<<"$state_json")"
should_warn="false"
if (( age_ms >= warn_after_ms )); then
if [[ "$last_warned_model" != "$model_full" || "$last_warned_session" != "$session_id" ]]; then
should_warn="true"
elif [[ "$last_warn_at" =~ ^[0-9]+$ ]] && (( now_ms - last_warn_at >= min_warn_interval_ms )); then
should_warn="true"
fi
fi
if [[ "$should_warn" == "true" ]]; then
send_notice "Heads up: high-cost model active ($model_full). Switch back after heavy work: /model $low_model" "$last_channel" "$last_to"
state_json="$(jq \
--arg key "$session_key" \
--arg model "$model_full" \
--arg sid "$session_id" \
--argjson now "$now_ms" \
'.[$key].lastWarnedModel=$model | .[$key].lastWarnedSessionId=$sid | .[$key].lastWarnAt=$now' \
<<<"$state_json")"
fi
if (( age_ms >= revert_after_ms )); then
can_revert="true"
if [[ "$last_revert_at" =~ ^[0-9]+$ ]] && (( now_ms - last_revert_at < min_warn_interval_ms )); then
can_revert="false"
fi
if [[ "$can_revert" == "true" && -n "$session_id" ]]; then
python3 - "$session_id" "$low_model" <<'PY'
import subprocess
import sys
session_id = sys.argv[1]
low_model = sys.argv[2]
cmd = [
"openclaw",
"agent",
"--session-id", session_id,
"--channel", "last",
"--message", f"/model {low_model}",
]
try:
subprocess.run(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
timeout=20,
)
except subprocess.TimeoutExpired:
pass
PY
send_notice "Auto-switched back to $low_model to avoid high-model quota burn. Use high model only for deep tasks." "$last_channel" "$last_to"
state_json="$(jq \
--arg key "$session_key" \
--arg model "$low_model" \
--arg sid "$session_id" \
--argjson now "$now_ms" \
'.[$key].lastRevertAt=$now | .[$key].lastWarnedModel=$model | .[$key].lastWarnedSessionId=$sid | .[$key].lastWarnAt=$now' \
<<<"$state_json")"
fi
fi
printf '%s\n' "$state_json" > "$state_file"