ci: free model for AI jobs

This commit is contained in:
zhom committed 2026-10-20 17:41:10 +04:00
1 parent e14e97963d
commit 4388f6ae74
12 files changed
+195 -301

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# Sends one prompt to a free OpenRouter model and writes the answer to a file.
#
# bash .github/scripts/ai-infer.sh SYSTEM_PROMPT_FILE USER_PROMPT_FILE OUTPUT_FILE
#
# Needs the OPENROUTER_API_KEY secret in AI_API_KEY. GitHub Models was retired
# on 2026-07-30, and Copilot through the workflow token needs an org policy
# that bills the org.
#
# Free models allow 20 requests a minute and 1000 a day (the account has
# bought credits), and the free list changes: when a model goes, pick another
# `:free` id from https://openrouter.ai/models (i18n-review.yml has the same
# list). A free model is often rate-limited upstream, so OpenRouter tries the
# fallbacks in order. AI_MODEL and AI_FALLBACK_MODELS override them.
#
# The user prompt is cut to AI_PROMPT_MAX_BYTES, so put the least important
# context at its end.
#
# Exits 0 with a non-empty answer in OUTPUT_FILE, 1 otherwise. The caller
# decides how to degrade. Nothing the model or the prompt says is printed to
# the log without `::stop-commands::` around it, because a log line that
# starts with `::` is a workflow command.
set -euo pipefail
if [ "$#" -ne 3 ]; then
echo "usage: $0 SYSTEM_PROMPT_FILE USER_PROMPT_FILE OUTPUT_FILE" >&2
exit 2
fi
system_file=$1
user_file=$2
out_file=$3
api_url=${AI_API_URL:-https://openrouter.ai/api/v1/chat/completions}
model=${AI_MODEL:-nvidia/nemotron-3-super-120b-a12b:free}
fallbacks=${AI_FALLBACK_MODELS:-minimax/minimax-m3.1-flash:free,google/gemma-4-31b-it:free}
max_bytes=${AI_PROMPT_MAX_BYTES:-120000}
timeout_seconds=${AI_TIMEOUT_SECONDS:-300}
: > "$out_file"
if [ -z "${AI_API_KEY:-}" ]; then
echo "::error::AI_API_KEY is not set (the OPENROUTER_API_KEY secret)"
exit 1
fi
scratch=$(mktemp -d)
trap 'rm -rf "$scratch"' EXIT
# Prints untrusted text without letting a line act as a workflow command.
quote_log() {
local stop_token
stop_token="ai-infer-$(od -An -N12 -tx1 /dev/urandom | tr -d ' \n')"
echo "::stop-commands::$stop_token"
printf '%s\n' "$1"
echo "::$stop_token::"
}
user_bytes=$(wc -c < "$user_file")
if [ "$user_bytes" -gt "$max_bytes" ]; then
echo "::warning::User prompt cut from $user_bytes to $max_bytes bytes"
fi
# A cut can split a UTF-8 sequence; iconv -c drops the broken tail (and exits
# 1 when it does).
head -c "$max_bytes" "$user_file" | tr -d '\000' |
{ iconv -f UTF-8 -t UTF-8 -c 2> /dev/null || true; } > "$scratch/user.txt"
jq -n \
--rawfile system "$system_file" \
--rawfile user "$scratch/user.txt" \
--arg model "$model" \
--arg fallbacks "$fallbacks" \
'{
model: $model,
models: ([$model] + ($fallbacks | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0)))),
messages: [
{role: "system", content: $system},
{role: "user", content: $user}
],
temperature: 0,
max_tokens: 8192
}' > "$scratch/request.json"
# The key goes to curl on stdin, so it is not in the process list. curl
# retries 429 and 5xx answers and honours Retry-After.
status=0
http_code=$(
printf 'header = "Authorization: Bearer %s"\n' "$AI_API_KEY" |
curl -sS --config - \
--max-time "$timeout_seconds" \
--retry 2 --retry-max-time 120 \
-H "Content-Type: application/json" \
--data-binary "@$scratch/request.json" \
-o "$scratch/response.json" \
-w '%{http_code}' \
"$api_url"
) || status=$?
if [ "$status" -ne 0 ]; then
if [ "$status" -eq 28 ]; then
echo "::error::The model API did not answer in ${timeout_seconds}s"
else
echo "::error::The model API could not be reached (curl exit $status)"
fi
exit 1
fi
error=$(jq -r '.error.message // empty' "$scratch/response.json" 2> /dev/null || true)
if [ "$http_code" != "200" ] || [ -n "$error" ]; then
echo "::error::The model API answered HTTP $http_code"
if [ -n "$error" ]; then
quote_log "${error:0:500}"
fi
exit 1
fi
if ! jq -j '.choices[0].message.content // empty' "$scratch/response.json" > "$out_file" 2> /dev/null; then
echo "::error::The model API returned a body that is not JSON"
exit 1
fi
if [ -z "$(tr -d '[:space:]' < "$out_file")" ]; then
echo "::error::The model returned an empty answer"
exit 1
fi
answered_by=$(jq -r '.model // "unknown"' "$scratch/response.json")
echo "Model $answered_by answered with $(wc -c < "$out_file") bytes"
-104
View File
@@ -1,104 +0,0 @@
#!/usr/bin/env bash
# Sends one prompt to GitHub Copilot and writes the answer to a file.
#
# bash .github/scripts/copilot-infer.sh SYSTEM_PROMPT_FILE USER_PROMPT_FILE OUTPUT_FILE
#
# Needs .github/actions/setup-copilot-cli (for `copilot-locked`), the job
# permission `copilot-requests: write`, and the workflow token in
# COPILOT_GITHUB_TOKEN. COPILOT_MODEL picks the model (default `auto`, the
# only value every Copilot plan accepts).
#
# GitHub Models (models.github.ai) was retired on 2026-07-30. It answered 410
# after that, and later 200 with a body that is not JSON, so every workflow
# that still called it degraded on every run. Copilot is what the workflow
# token can reach now.
#
# The prompt goes to the CLI as one argument, and Linux caps one argument at
# 128 KiB. The user prompt is cut to fit COPILOT_PROMPT_MAX_BYTES, so put the
# least important context at its end.
#
# Exits 0 with a non-empty answer in OUTPUT_FILE, 1 otherwise. The caller
# decides how to degrade. Nothing the model or the prompt says is printed to
# the log without `::stop-commands::` around it, because a log line that
# starts with `::` is a workflow command.
set -euo pipefail
if [ "$#" -ne 3 ]; then
echo "usage: $0 SYSTEM_PROMPT_FILE USER_PROMPT_FILE OUTPUT_FILE" >&2
exit 2
fi
system_file=$1
user_file=$2
out_file=$3
model=${COPILOT_MODEL:-auto}
max_bytes=${COPILOT_PROMPT_MAX_BYTES:-120000}
timeout_seconds=${COPILOT_TIMEOUT_SECONDS:-300}
: > "$out_file"
if [ -z "${COPILOT_GITHUB_TOKEN:-}" ]; then
echo "::error::COPILOT_GITHUB_TOKEN is not set"
exit 1
fi
if ! command -v copilot-locked > /dev/null 2>&1; then
echo "::error::copilot-locked is not on PATH; run .github/actions/setup-copilot-cli first"
exit 1
fi
system_bytes=$(wc -c < "$system_file")
budget=$((max_bytes - system_bytes - 2))
if [ "$budget" -lt 1024 ]; then
echo "::error::The system prompt ($system_bytes bytes) leaves no room for the user prompt"
exit 1
fi
user_bytes=$(wc -c < "$user_file")
if [ "$user_bytes" -gt "$budget" ]; then
echo "::warning::User prompt cut from $user_bytes to $budget bytes to fit one argument"
fi
scratch=$(mktemp -d)
workdir=$(mktemp -d)
trap 'rm -rf "$scratch" "$workdir"' EXIT
# NUL bytes cannot travel in an argument, and a cut can split a UTF-8
# sequence; iconv -c drops the broken tail (and exits 1 when it does).
{
cat "$system_file"
printf '\n\n'
head -c "$budget" "$user_file" | tr -d '\000' | { iconv -f UTF-8 -t UTF-8 -c 2> /dev/null || true; }
} > "$scratch/prompt.txt"
prompt=$(cat "$scratch/prompt.txt")
# An empty working directory: the file tools that need no approval have
# nothing to read.
status=0
(
cd "$workdir"
timeout --kill-after=15 "$timeout_seconds" \
copilot-locked -p "$prompt" -s --no-ask-user --model "$model"
) > "$out_file" 2> "$scratch/stderr.txt" || status=$?
if [ "$status" -ne 0 ]; then
if [ "$status" -eq 124 ]; then
echo "::error::Copilot CLI timed out after ${timeout_seconds}s"
else
echo "::error::Copilot CLI exited with status $status"
fi
if [ -s "$scratch/stderr.txt" ]; then
stop_token="copilot-stderr-$(od -An -N12 -tx1 /dev/urandom | tr -d ' \n')"
echo "::stop-commands::$stop_token"
echo "Copilot CLI stderr (last 2000 bytes):"
tail -c 2000 "$scratch/stderr.txt"
echo
echo "::$stop_token::"
fi
exit 1
fi
if [ -z "$(tr -d '[:space:]' < "$out_file")" ]; then
echo "::error::Copilot CLI returned an empty answer"
exit 1
fi
echo "Copilot answered with $(wc -c < "$out_file") bytes"
+2 -2
View File
@@ -3,8 +3,8 @@
python3 .github/scripts/extract-json.py ANSWER_FILE OUTPUT_FILE
Copilot answers in text. Even when told to return only JSON, a model can wrap
it in a markdown fence or put a sentence before it. This skips anything around
A model answers in text. Even when told to return only JSON, it can wrap the
object in a markdown fence or put a sentence before it. This skips anything around
the first object that parses. Exits 0 when it wrote one, 1 when there is none.
"""
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Turns a .github/prompts/*.prompt.yml file into the two prompt files
# ai-infer.sh takes, with {{version}} and {{commits}} filled in.
#
# bash .github/scripts/render-prompt.sh PROMPT_FILE VERSION COMMITS_FILE SYSTEM_OUT USER_OUT
set -euo pipefail
if [ "$#" -ne 5 ]; then
echo "usage: $0 PROMPT_FILE VERSION COMMITS_FILE SYSTEM_OUT USER_OUT" >&2
exit 2
fi
prompt_file=$1
version=$2
commits_file=$3
system_out=$4
user_out=$5
yq '.messages[] | select(.role == "system") | .content' "$prompt_file" > "$system_out"
# Python, not bash ${//}: commit subjects may hold `&`, which bash 5.2
# replaces with the matched text.
yq '.messages[] | select(.role == "user") | .content' "$prompt_file" |
python3 -c '
import sys
version = sys.argv[1]
with open(sys.argv[2], encoding="utf-8", errors="replace") as fh:
commits = fh.read()
sys.stdout.write(sys.stdin.read().replace("{{version}}", version).replace("{{commits}}", commits))
' "$version" "$commits_file" > "$user_out"