Created
November 22, 2025 20:24
-
-
Save jnaskali/87d35a3b62200f02b54670a004000ec6 to your computer and use it in GitHub Desktop.
OpenRouter ZDR Model Pricing Checker - bash script for querying Zero-Data-Retention models on OpenRouter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # | |
| # OOOOOOOOO RRRRRRRRRRRRRRRRR ZZZZZZZZZZZZZZZZZZZDDDDDDDDDDDDD RRRRRRRRRRRRRRRRR | |
| # OO:::::::::OO R::::::::::::::::R Z:::::::::::::::::ZD::::::::::::DDD R::::::::::::::::R | |
| # OO:::::::::::::OO R::::::RRRRRR:::::R Z:::::::::::::::::ZD:::::::::::::::DD R::::::RRRRRR:::::R | |
| # O:::::::OOO:::::::ORR:::::R R:::::R Z:::ZZZZZZZZ:::::Z DDD:::::DDDDD:::::DRR:::::R R:::::R | |
| # O::::::O O::::::O R::::R R:::::R ZZZZZ Z:::::Z D:::::D D:::::D R::::R R:::::R | |
| # O:::::O O:::::O R::::R R:::::R Z:::::Z D:::::D D:::::DR::::R R:::::R | |
| # O:::::O O:::::O R::::RRRRRR:::::R Z:::::Z D:::::D D:::::DR::::RRRRRR:::::R | |
| # O:::::O O:::::O R:::::::::::::RR -------- Z:::::Z D:::::D D:::::DR:::::::::::::RR | |
| # O:::::O O:::::O R::::RRRRRR:::::R -::::::- Z:::::Z D:::::D D:::::DR::::RRRRRR:::::R | |
| # O:::::O O:::::O R::::R R:::::R -------- Z:::::Z D:::::D D:::::DR::::R R:::::R | |
| # O:::::O O:::::O R::::R R:::::R Z:::::Z D:::::D D:::::DR::::R R:::::R | |
| # O::::::O O::::::O R::::R R:::::R ZZZ:::::Z ZZZZZ D:::::D D:::::D R::::R R:::::R | |
| # O:::::::OOO:::::::ORR:::::R R:::::R Z::::::ZZZZZZZZ:::ZDDD:::::DDDDD:::::DRR:::::R R:::::R | |
| # OO:::::::::::::OO R::::::R R:::::R Z:::::::::::::::::ZD:::::::::::::::DD R::::::R R:::::R | |
| # OO:::::::::OO R::::::R R:::::R Z:::::::::::::::::ZD::::::::::::DDD R::::::R R:::::R | |
| # OOOOOOOOO RRRRRRRR RRRRRRR ZZZZZZZZZZZZZZZZZZZDDDDDDDDDDDDD RRRRRRRR RRRRRRR | |
| # | |
| # OpenRouter ZDR Model Pricing Checker | |
| # | |
| # Maintainer: Juhani Naskali | |
| # License: MIT <https://opensource.org/licenses/MIT> | |
| set -euo pipefail | |
| # --- Configuration --- | |
| readonly API_URL="https://openrouter.ai/api/v1/endpoints/zdr" | |
| readonly CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/openrouter-zdr" | |
| readonly CACHE_FILE="$CACHE_DIR/models.json" | |
| readonly CACHE_OLD_FILE="$CACHE_DIR/models.old.json" | |
| readonly CACHE_TTL=3600 | |
| # Default Curated List (Regex) | |
| # Default regex to return, along with all Free models. | |
| readonly CURATED_PATTERNS="claude.*4\.|gemini-2\.5-flash$|gemini-3|grok|perplexity-sonar|(?<!nous)research" | |
| # Colors | |
| readonly BOLD='\033[1m' | |
| readonly GREEN='\033[0;32m' | |
| readonly BLUE='\033[0;34m' | |
| readonly YELLOW='\033[0;33m' | |
| readonly CYAN='\033[0;36m' | |
| readonly RED='\033[0;31m' | |
| readonly NC='\033[0m' | |
| # Defaults | |
| SHOW_ALL=false | |
| SHOW_ID=false | |
| SHOW_FREE=false | |
| WIDE_MODE=false | |
| FORCE_REFRESH=false | |
| LIMIT=0 | |
| SORT_BY="default" | |
| USE_CURATED=true # default mode | |
| # Filter Defaults (Values that result in 'true' in logic if unset) | |
| MIN_CTX=0 | |
| FILTER_PROV="" | |
| FILTER_TOOLS=false | |
| CUSTOM_REGEX="" | |
| # Functions | |
| check_deps() { | |
| for cmd in jq curl; do | |
| command -v "$cmd" >/dev/null 2>&1 || { echo -e "${RED}Error: '$cmd' is required.${NC}"; exit 1; } | |
| done | |
| if [ -z "${OPENROUTER_API_KEY:-}" ]; then | |
| echo -e "${RED}Error: OPENROUTER_API_KEY is not set.${NC}"; exit 1 | |
| fi | |
| } | |
| fetch_data() { | |
| mkdir -p "$CACHE_DIR" | |
| local fetch=true | |
| if [[ "$FORCE_REFRESH" == "false" && -f "$CACHE_FILE" ]]; then | |
| local age | |
| age=$(($(date +%s) - $(stat -c %Y "$CACHE_FILE"))) | |
| if (( age < CACHE_TTL )); then | |
| fetch=false | |
| echo -e "${GREEN}Using cached data (Updated $((age / 60)) min ago)${NC}" | |
| fi | |
| fi | |
| if [[ "$fetch" == "true" ]]; then | |
| echo -e "${YELLOW}Fetching ZDR models...${NC}" | |
| local response | |
| response=$(curl -s "$API_URL") | |
| if echo "$response" | jq -e .data >/dev/null 2>&1; then | |
| [[ -f "$CACHE_FILE" ]] && cp "$CACHE_FILE" "$CACHE_OLD_FILE" | |
| echo "$response" > "$CACHE_FILE" | |
| echo -e "${GREEN}Cache updated.${NC}" | |
| else | |
| echo -e "${RED}API Error. Using cache if available.${NC}" | |
| [[ ! -f "$CACHE_FILE" ]] && exit 1 | |
| fi | |
| fi | |
| } | |
| get_new_models() { | |
| if [[ -f "$CACHE_OLD_FILE" && -f "$CACHE_FILE" ]]; then | |
| jq -n \ | |
| --slurpfile old "$CACHE_OLD_FILE" \ | |
| --slurpfile new "$CACHE_FILE" \ | |
| '($old[0].data | map(.model_name)) as $o | ($new[0].data | map(.model_name)) as $n | ($n - $o) | unique' | |
| else | |
| echo "[]" | |
| fi | |
| } | |
| render_table() { | |
| local new_models_json="$1" | |
| # 1. Define Formatting String based on Wide Mode | |
| local name_fmt="%-48.48s" | |
| local table_width="118" | |
| if [[ "$WIDE_MODE" == "true" ]]; then | |
| name_fmt="%-60s" # Flexible width | |
| table_width="130" | |
| fi | |
| # 2. Header | |
| echo -e "${BLUE}$(printf '=%.0s' $(seq 1 "$table_width"))${NC}" | |
| printf "${BOLD}${name_fmt} %-14s %-9s %-9s %-4s %-12s %s${NC}\n" "Model" "Provider" "Prompt" "Output" "TRW" "Ctx" "Uptime" | |
| echo -e "${BLUE}$(printf '=%.0s' $(seq 1 "$table_width"))${NC}" | |
| # 3. Construct JQ Filter | |
| local jq_filter=' | |
| .data | |
| | map(select( | |
| # A. Scope Logic (Curated vs All vs Search) | |
| ( | |
| if ($use_curated == "true") then | |
| # Match Curated Regex OR be a Free model | |
| (.model_name | test($curated_pattern; "i")) or | |
| (.name | test($curated_pattern; "i")) or | |
| ((.pricing.prompt|tonumber) == 0 and (.pricing.completion|tonumber) == 0) | |
| else | |
| true # If any filter is set, we start with ALL models | |
| end | |
| ) | |
| and | |
| # B. Custom Regex Search (Matches ID or Name based on display mode) | |
| ( | |
| if ($regex != "") then | |
| if ($show_id == "true") then | |
| (.name | test($regex; "i")) | |
| else | |
| (.model_name | test($regex; "i")) | |
| end | |
| else true end | |
| ) | |
| and | |
| # C. Hard Filters | |
| (if ($show_free == "true") then ((.pricing.prompt|tonumber) == 0 and (.pricing.completion|tonumber) == 0) else true end) | |
| and | |
| (if ($tools_only == "true") then (.supported_parameters | index("tools")) else true end) | |
| and | |
| (.context_length >= ($min_ctx|tonumber)) | |
| and | |
| (if ($prov_filter != "") then (.provider_name | test($prov_filter; "i")) else true end) | |
| )) | |
| # Deduplication (Group by model name, take cheapest provider) | |
| | group_by(.model_name) | |
| | map(sort_by(.pricing.completion|tonumber) | .[0]) | |
| | flatten | |
| ' | |
| # 4. Append Sorting Logic | |
| case "$SORT_BY" in | |
| price) jq_filter+=" | sort_by(.pricing.completion | tonumber)" ;; | |
| prompt) jq_filter+=" | sort_by(.pricing.prompt | tonumber)" ;; | |
| context) jq_filter+=" | sort_by(.context_length | tonumber)" ;; | |
| uptime) jq_filter+=" | sort_by(.uptime_last_30m // 0 | tonumber) | reverse" ;; # Highest uptime first | |
| name) jq_filter+=" | sort_by(.model_name)" ;; | |
| default) jq_filter+=" | sort_by(.model_name, (.pricing.completion | tonumber))" ;; | |
| esac | |
| # 5. Append Limit Logic | |
| if [[ "$LIMIT" -gt 0 ]]; then | |
| jq_filter+=" | .[0:$LIMIT]" | |
| fi | |
| # 6. Final Output Formatting | |
| jq_filter+=' | |
| | .[] | |
| | . as $row | |
| | (if (.pricing.prompt|tonumber) == 0 then "FREE" else "$" + ((.pricing.prompt|tonumber * 1000000 * 100 | round / 100)|tostring) + "/M" end) as $p | |
| | (if (.pricing.completion|tonumber) == 0 then "FREE" else "$" + ((.pricing.completion|tonumber * 1000000 * 100 | round / 100)|tostring) + "/M" end) as $c | |
| | (if .context_length >= 1000000 then (.context_length/1000000|tostring)+"M" elif .context_length >= 1000 then (.context_length/1000|floor|tostring)+"K" else (.context_length|tostring) end) as $ctx | |
| # Uptime Logic: Round to 2 decimals if NOT wide mode | |
| | (if .uptime_last_30m then | |
| (if $wide_mode == "true" then | |
| (.uptime_last_30m|tostring) | |
| else | |
| ((.uptime_last_30m * 100 | round) / 100 | tostring) | |
| end) + "%" | |
| else "N/A" end) as $up | |
| | (if ($show_id == "true") then ($row.name | split(" | ") | last) else ($row.model_name + (if ($row.quantization and $row.quantization != "unknown") then " ["+$row.quantization+"]" else "" end)) end) as $name | |
| | ( | |
| (if (.supported_parameters | index("tools")) then "T" else "-" end) + | |
| (if (.pricing.internal_reasoning != "0" or (.supported_parameters | any(. == "reasoning" or . == "thinking"))) then "R" else "-" end) + | |
| (if (.pricing.web_search != "0" or (.model_name | test("sonar|search";"i"))) then "W" else "-" end) | |
| ) as $caps | |
| | (if ($new_ids | index($row.model_name)) then "NEW" else "OLD" end) as $status | |
| | [$name, $row.provider_name[0:14], $p, $c, $caps, $ctx, $up, $status] | @tsv | |
| ' | |
| # Execute JQ | |
| jq -r --arg show_id "$SHOW_ID" \ | |
| --arg show_free "$SHOW_FREE" \ | |
| --arg tools_only "$FILTER_TOOLS" \ | |
| --arg prov_filter "$FILTER_PROV" \ | |
| --arg min_ctx "$MIN_CTX" \ | |
| --arg use_curated "$USE_CURATED" \ | |
| --arg wide_mode "$WIDE_MODE" \ | |
| --arg curated_pattern "$CURATED_PATTERNS" \ | |
| --arg regex "$CUSTOM_REGEX" \ | |
| --argjson new_ids "$new_models_json" \ | |
| "$jq_filter" "$CACHE_FILE" | \ | |
| while IFS=$'\t' read -r name prov prompt comp caps ctx uptime status; do | |
| if [[ "$status" == "NEW" ]]; then | |
| # If New: Truncate name slightly less to fit the * | |
| if [[ "$WIDE_MODE" == "true" ]]; then | |
| printf "${name_fmt} ${GREEN}*${NC} " "$name" | |
| else | |
| printf "%-46.46s ${GREEN}*${NC} " "$name" | |
| fi | |
| else | |
| printf "${name_fmt} " "$name" | |
| fi | |
| printf "%-14s %-9s %-9s %-4s %-12s %s\n" "$prov" "$prompt" "$comp" "$caps" "$ctx" "$uptime" | |
| done | |
| echo -e "${BLUE}$(printf '=%.0s' $(seq 1 "$table_width"))${NC}" | |
| } | |
| # --- Argument Parsing --- | |
| while [[ $# -gt 0 ]]; do | |
| case $1 in | |
| # Display Modes | |
| -a|--all) SHOW_ALL=true; USE_CURATED=false; shift ;; | |
| -i|--id) SHOW_ID=true; shift ;; | |
| -w|--wide) WIDE_MODE=true; shift ;; | |
| -r|--refresh) FORCE_REFRESH=true; shift ;; | |
| # Filters (All filters disable USE_CURATED) | |
| -f|--free) SHOW_FREE=true; USE_CURATED=false; shift ;; | |
| -t|--tools) FILTER_TOOLS=true; USE_CURATED=false; shift ;; | |
| -P|--provider) FILTER_PROV="$2"; USE_CURATED=false; shift 2 ;; | |
| -c|--context) MIN_CTX="$2"; USE_CURATED=false; shift 2 ;; | |
| # Sorting & Limits | |
| -n|--limit) LIMIT="$2"; shift 2 ;; | |
| -s|--sort) SORT_BY="$2"; shift 2 ;; | |
| -h|--help) | |
| echo "Usage: $0 [OPTIONS] [REGEX]" | |
| echo "Arguments:" | |
| echo " REGEX Custom regex search (matches Name, or ID if -i is used)" | |
| echo "" | |
| echo "Display Options:" | |
| echo " -a, --all Show all ZDR models (overrides curated list)" | |
| echo " -i, --id Show Model IDs instead of display names" | |
| echo " -w, --wide Don't truncate model names (for wide screens). Shows raw Uptime." | |
| echo " -n, --limit N Show only top N results" | |
| echo "" | |
| echo "Filtering (Automatically searches ALL models, ignoring curated list):" | |
| echo " -f, --free Show only free models" | |
| echo " -t, --tools Show only models supporting Tools/Function Calling" | |
| echo " -P, --provider Filter by Provider name (regex)" | |
| echo " -c, --context Minimum context length (e.g. 128000)" | |
| echo "" | |
| echo "Sorting:" | |
| echo " -s, --sort KEY Sort by: price, context, uptime, prompt, name" | |
| echo "" | |
| echo "System:" | |
| echo " -r, --refresh Force API cache refresh" | |
| exit 0 | |
| ;; | |
| *) | |
| # Handle positional argument as Custom Regex | |
| if [[ "$1" =~ ^- ]]; then | |
| echo "Unknown option: $1"; exit 1 | |
| else | |
| CUSTOM_REGEX="$1" | |
| USE_CURATED=false | |
| shift | |
| fi | |
| ;; | |
| esac | |
| done | |
| # Execution | |
| check_deps | |
| echo -e "${BOLD}${BLUE}OpenRouter ZDR Model Pricing${NC}" | |
| # Info Line showing active filters | |
| INFO_MSG="${CYAN}Mode:${NC} " | |
| if [[ "$USE_CURATED" == "true" ]]; then INFO_MSG+="Curated List"; else INFO_MSG+="All/Filtered"; fi | |
| [[ "$SHOW_FREE" == "true" ]] && INFO_MSG+=", Free Only" | |
| [[ "$FILTER_TOOLS" == "true" ]] && INFO_MSG+=", Tools Support" | |
| [[ "$MIN_CTX" -gt 0 ]] && INFO_MSG+=", Ctx > $MIN_CTX" | |
| [[ -n "$CUSTOM_REGEX" ]] && INFO_MSG+=", Search: '$CUSTOM_REGEX'" | |
| echo -e "$INFO_MSG" | |
| fetch_data | |
| NEW_MODELS=$(get_new_models) | |
| NEW_COUNT=$(echo "$NEW_MODELS" | jq length) | |
| if (( NEW_COUNT > 0 )); then | |
| echo -e "${GREEN}Found $NEW_COUNT new model(s)${NC}" | |
| fi | |
| render_table "$NEW_MODELS" | |
| # Final Legend | |
| if (( NEW_COUNT > 0 )); then | |
| echo -e "${CYAN}Legend:${NC} TRW (T=Tools, R=Reasoning, W=Web) | ${GREEN}* New Model${NC}" | |
| else | |
| echo -e "${CYAN}Legend:${NC} TRW (T=Tools, R=Reasoning, W=Web)" | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment