Files
homelab/scripts/ci/fetch-models.sh
claude-botandClaude Opus 4.8 44a429685c
ci/woodpecker/pr/pr-review Pipeline was successful
fix(ci): round-3 pr-review hardening — REVIEW_TOKEN, no-die selection, tests
Addresses the a7890e0a review.

- pr-review.yml: strip REVIEW_TOKEN from the valid-model.sh call too (it runs a
  script from $HOMELAB, branch-supplied for a homelab PR), and guard it with
  `|| true` so a missing script can't kill the step -- restoring the stated "a
  run never dies on selection" invariant. For a complex/security-sensitive
  review, ignore the (injectable) triage pick and run on the CLI default
  (strongest configured) model.
- valid-model.sh: reject a leading dash at the shape check (making the header
  accurate) so an option-like value is refused even without a catalog.
- fetch-models.sh: --retry 2 --retry-connrefused so a transient blip doesn't
  drop the run to the default; add a FETCH_MODELS_BODY_FILE test seam.
- pick-model.sh: pass --settings (new ci/agents/model-picker/settings.json,
  Read deny-list) as belt-and-suspenders behind --allowedTools "".
- test-model-selection.sh: add fetch-models.sh mapping tests (well-formed body
  -> non-null capability columns; degraded body -> loud warning) via the seam,
  and wire the whole suite into .woodpecker/e2e.yml so it runs in CI.
- Condense the duplicated model-selection comment in the four fixed jobs to a
  one-line pointer to pick-model.sh.
- PR description: state the cost / no-fixed-ceiling property change.

Deferred with rationale (in the PR thread): selected-model in the four crons'
posted artifacts (already logged per run), pagination past limit=1000 (API max;
has_more warns), and unit-testing pick-model.sh's JSON extraction (its output
always passes through the tested valid-model.sh gate, and the picker prompt is
developer-controlled, not attacker input).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-25 07:43:08 +02:00

114 lines
5.0 KiB
Bash
Executable File

#!/bin/sh
# Fetch the list of Claude models currently available to this account.
#
# The `claude` CLI has no "list models" subcommand, so the authoritative
# source is the same catalog the CLI itself uses: the Anthropic Models API
# (GET /v1/models), authenticated with the very CLAUDE_CODE_OAUTH_TOKEN the CI
# agents already run under. This lets the pipelines pick a model from what is
# live *today*, judged on the API's own capability metadata — no hardcoded
# model ids and no baked-in assumptions about model families. A model released
# tomorrow shows up here and becomes selectable the same day.
#
# Run with `sh` (POSIX; intentionally not `bash`, unlike the older
# scripts/ci/*.sh helpers invoked as `bash`).
#
# Usage:
# fetch-models.sh -> compact JSON array (newest first). No pipeline
# consumes this; it is a human/debugging affordance.
# fetch-models.sh --table -> the same data as a tab-separated table, one
# model per line: the single format every
# selector consumes (id is column 1).
#
# JSON fields / table columns (objective signals a selector can weigh):
# id, name, released=YYYY-MM-DD, context=N, max_output=N,
# effort=bool, thinking=bool, code_exec=bool
#
# Diagnostics go to stderr so `$(...)` capture stays clean.
# Exit status: 0 with non-empty output on success; non-zero on any failure
# (missing token, HTTP error, empty/garbage body) — the caller decides how
# to fall back.
set -eu
MODE="${1:-json}" # json (default) | --table
log() { echo "fetch-models: $*" >&2; }
BODY="$(mktemp)"
trap 'rm -f "$BODY"' EXIT
if [ -n "${FETCH_MODELS_BODY_FILE:-}" ]; then
# Test seam: run the exact parsing/jq below over a recorded /v1/models body,
# no token and no network. Used by scripts/ci/test-model-selection.sh to pin
# the response shape; never set in CI.
cp "$FETCH_MODELS_BODY_FILE" "$BODY"
else
TOKEN="${CLAUDE_CODE_OAUTH_TOKEN:-}"
if [ -z "$TOKEN" ]; then
log "CLAUDE_CODE_OAUTH_TOKEN is not set"
exit 1
fi
# limit=1000 (the API max): the default page size is 20 and the order is
# newest-first, so without this the OLDEST/cheapest models — the ones trivial
# work should route to — would be silently dropped once the account lists >20.
# --retry: a single transient blip should not drop the job to the CLI default;
# a hard failure still lands on the fallback. oauth-2025-04-20 is the beta
# header the subscription OAuth token is minted for; without it the API rejects
# a Bearer (as opposed to x-api-key) caller.
HTTP="$(curl -sS -o "$BODY" -w '%{http_code}' \
--max-time 20 --retry 2 --retry-connrefused \
'https://api.anthropic.com/v1/models?limit=1000' \
-H "Authorization: Bearer $TOKEN" \
-H "anthropic-version: 2023-06-01" \
-H "anthropic-beta: oauth-2025-04-20")" || HTTP=000
if [ "$HTTP" != "200" ]; then
log "GET /v1/models returned HTTP $HTTP"
exit 1
fi
fi
# A truncated first page would mean the pipelines silently ignore older models;
# surface it rather than fail (limit=1000 makes it practically impossible).
if [ "$(jq -r '.has_more' "$BODY" 2>/dev/null)" = "true" ]; then
log "WARNING: /v1/models reported has_more=true — catalog is larger than one page"
fi
# Keep only objective signals a selector can reason from; preserve API order
# (newest first). No id patterns, no family names — just the facts the API
# reports about each model. jq stderr is surfaced so a response-schema change
# shows up as a real error, not just the generic "no usable model list".
OUT="$(jq -c '[.data[] | {
id,
name: .display_name,
released: (.created_at[0:10]),
context: .max_input_tokens,
max_output: .max_tokens,
effort: (.capabilities.effort.supported // false),
thinking: (.capabilities.thinking.supported // false),
code_exec: (.capabilities.code_execution.supported // false)
}]' "$BODY")" || { log "could not parse /v1/models response"; exit 1; }
if [ -z "$OUT" ] || [ "$OUT" = "[]" ]; then
log "response held no usable model list"
exit 1
fi
# jq does not error when a capability field simply goes missing — .max_input_tokens
# becomes null and the `// false` defaults swallow the rest. If EVERY row lost its
# context window the API shape has changed and both selectors are left with only
# name + date to reason from. Degrade rather than fail, but say so loudly.
if [ "$(printf '%s' "$OUT" | jq -r 'all(.[]; .context == null)')" = "true" ]; then
log "WARNING: no model reports a context window — /v1/models may have changed shape; capability signals are unavailable this run"
fi
case "$MODE" in
--table)
printf '%s' "$OUT" | jq -r '.[]
| "\(.id)\t\(.name)\treleased=\(.released)\tcontext=\(.context)\tmax_output=\(.max_output)\teffort=\(.effort)\tthinking=\(.thinking)\tcode_exec=\(.code_exec)"'
;;
json)
printf '%s\n' "$OUT"
;;
*)
log "unknown mode '$MODE' (use --table or nothing)"; exit 2
;;
esac