#!/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