freeloader: turnError-driven free model cycling with blacklist

- scripts/x/freeloader: hook-driven script; intercepts rate_limit and
  tool_unsupported on :free models; auto-blacklists on tool_unsupported;
  cycles to next non-blacklisted free model; exits 1 for unhandled errors
- agents: add turnError hook to all agent configs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-28 17:17:56 +02:00
parent 9b6f40d106
commit 15df9eeb1a
9 changed files with 102 additions and 1 deletions

View File

@ -29,6 +29,9 @@
],
"postTurn": [
"task list 2>/dev/null || true"
],
"turnError": [
"$OLLIE/x/freeloader $OLLIE_SESSION_ID 2>/dev/null || true"
]
}
}

View File

@ -17,6 +17,9 @@
],
"postTurn": [
"task list 2>/dev/null || true"
],
"turnError": [
"$OLLIE/x/freeloader $OLLIE_SESSION_ID 2>/dev/null || true"
]
}
}

View File

@ -19,6 +19,9 @@
],
"postTurn": [
"task list 2>/dev/null || true"
],
"turnError": [
"$OLLIE/x/freeloader $OLLIE_SESSION_ID 2>/dev/null || true"
]
}
}

View File

@ -18,6 +18,9 @@
],
"postTurn": [
"task list 2>/dev/null || true"
],
"turnError": [
"$OLLIE/x/freeloader $OLLIE_SESSION_ID 2>/dev/null || true"
]
}
}

View File

@ -18,6 +18,9 @@
],
"postTurn": [
"task list 2>/dev/null || true"
],
"turnError": [
"$OLLIE/x/freeloader $OLLIE_SESSION_ID 2>/dev/null || true"
]
}
}

View File

@ -18,6 +18,9 @@
],
"postTurn": [
"task list 2>/dev/null || true"
],
"turnError": [
"$OLLIE/x/freeloader $OLLIE_SESSION_ID 2>/dev/null || true"
]
}
}

View File

@ -18,6 +18,9 @@
],
"postTurn": [
"task list 2>/dev/null || true"
],
"turnError": [
"$OLLIE/x/freeloader $OLLIE_SESSION_ID 2>/dev/null || true"
]
}
}

2
core

@ -1 +1 @@
Subproject commit 2c9f853a4aa6bce14b5788d97c1987edf35e9aaf
Subproject commit 78e54165500f2b6136d9fc2e7422b7ae984dca71

80
scripts/x/freeloader Executable file
View File

@ -0,0 +1,80 @@
#!/usr/bin/env bash
# x/freeloader: turnError hook handler for free-model sessions on OpenRouter.
#
# Intercepts rate_limit and tool_unsupported errors on :free models, switches
# to the next viable free model, and resubmits the previous prompt.
#
# Configure in an agent .json:
# { "hooks": { "turnError": "$OLLIE/x/freeloader $OLLIE_SESSION_ID" } }
#
# The hook runner sets OLLIE_ERROR_TYPE, OLLIE_MODEL, and OLLIE_SESSION_ID
# from the payload before executing this script.
#
# Exit codes:
# 0 — error was intercepted and handled (loop will not retry)
# 1 — error not handled; normal retry/error behaviour proceeds
set -euo pipefail
OLLIE="${OLLIE:-$HOME/mnt/ollie}"
BLACKLIST="${XDG_CONFIG_HOME:-$HOME/.config}/ollie/freeloader-blacklist"
cmd=${0##*/}
die() { printf '%s: %s\n' "$cmd" "$*" >&2; exit 1; }
[ $# -ne 1 ] && die "usage: freeloader <session-id>"
SID="$1"
B="$OLLIE/s/$SID"
[ -d "$B" ] || die "session not found: $SID"
# Read context injected by the hook runner.
error_type="${OLLIE_ERROR_TYPE:-}"
current_model="${OLLIE_MODEL:-$(grep '^model=' "$B/cfg" 2>/dev/null | cut -d= -f2-)}"
# Only handle rate_limit and tool_unsupported.
case "$error_type" in
rate_limit|tool_unsupported) ;;
*) exit 1 ;;
esac
# Only act on :free models.
case "$current_model" in
*:free) ;;
*) exit 1 ;;
esac
# For tool_unsupported: blacklist the current model so future sessions skip it.
if [ "$error_type" = "tool_unsupported" ]; then
mkdir -p "$(dirname "$BLACKLIST")"
if ! grep -qxF "$current_model" "$BLACKLIST" 2>/dev/null; then
printf '%s\n' "$current_model" >> "$BLACKLIST"
printf '%s: blacklisted %s (tool_unsupported)\n' "$cmd" "$current_model" >&2
fi
fi
# Build candidate list: :free models not in the blacklist, excluding current.
mapfile -t candidates < <(
cat "$B/models" 2>/dev/null \
| grep ':free' \
| sort \
| grep -vxF "$current_model" \
| { [ -f "$BLACKLIST" ] && grep -vxFf "$BLACKLIST" || cat; }
)
if [ ${#candidates[@]} -eq 0 ]; then
printf '%s: no viable free models remaining\n' "$cmd" >&2
exit 1
fi
next_model="${candidates[0]}"
# Switch model and resubmit the previous prompt.
printf 'model %s\n' "$next_model" > "$B/ctl" || die "failed to switch model"
prev=$(cat "$B/prompt.prev" 2>/dev/null) || die "prompt.prev not available"
[ -z "$prev" ] && die "prompt.prev is empty"
printf '%s\n' "$prev" > "$B/prompt" || die "failed to resubmit prompt"
printf '%s: %s → %s\n' "$cmd" "$current_model" "$next_model" >&2
exit 0