What you are doing
You are migrating this repository's LLM API calls to GPUniq, an OpenAI-compatible gateway. Two things change at every call site:
- The client points at GPUniq instead of the vendor. Same wire protocol, same SDK, lower price.
- The model and the system prompt move out of the code into a GPUniq
event. The code then sends an opaque tag as
model, and GPUniq resolves it server-side to the real model and injects the system prompt.
The second part is the one that pays off later: after the migration, changing a model or a prompt is a dashboard edit, not a deploy — and A/B testing either one needs no code change at all.
Work through the steps in order. Do not batch-edit the whole repository in one pass; migrate one call site at a time so every change stays reviewable.
Before you start
- Ask the user for their GPUniq API key if it is not already available. Keys
start with
gpuniq_and are created at https://gpuniq.com/workspace?tab=api-keys - Export it for the shell session — never write it into a source file, and never commit it:
export GPUNIQ_API_KEY="gpuniq_..."
- Confirm the working tree is clean (
git status). If it is not, ask the user whether to continue. - Verify the key works. This must print a balance:
curl -s https://api.gpuniq.com/v1/llm/balance \
-H "X-API-Key: $GPUNIQ_API_KEY" | jq '.data'
Step 1 — Find every call site
Search the repository for calls to LLM vendors. These patterns cover the SDKs and the hand-rolled requests that appear in practice:
rg -n --glob '!node_modules' --glob '!venv' --glob '!.venv' --glob '!dist' \
-e '\.chat\.completions\.create' \
-e '\.responses\.create' \
-e '\.messages\.create' \
-e '\.generate_content|\.generateContent' \
-e 'ChatOpenAI|ChatAnthropic|ChatGoogleGenerativeAI' \
-e 'api\.openai\.com|api\.anthropic\.com|api\.deepseek\.com|generativelanguage\.googleapis\.com|openrouter\.ai' \
.
The trailing . is not optional. Without an explicit path, ripgrep searches
standard input, and in a non-interactive shell — which is how you are running —
it will hang waiting for input that never comes.
If rg is not installed, the same sweep with plain grep:
grep -rEn --exclude-dir=node_modules --exclude-dir=venv --exclude-dir=.venv \
--exclude-dir=dist --exclude-dir=build --exclude-dir=.git \
-e '\.chat\.completions\.create' \
-e '\.responses\.create' \
-e '\.messages\.create' \
-e '\.generate_content|\.generateContent' \
-e 'ChatOpenAI|ChatAnthropic|ChatGoogleGenerativeAI' \
-e 'api\.openai\.com|api\.anthropic\.com|api\.deepseek\.com|generativelanguage\.googleapis\.com|openrouter\.ai' \
.
Also find where the clients are constructed, since that is where the base URL and the key live:
rg -n --glob '!node_modules' \
-e 'OpenAI\(|AsyncOpenAI\(|Anthropic\(|AsyncAnthropic\(' \
-e 'OPENAI_API_KEY|ANTHROPIC_API_KEY' \
.
Build a list of call sites. For each one, note three things:
- the model string (
model="gpt-4o"); - the system prompt, but only if it is a plain string literal;
- whether the prompt is computed (f-string, template literal, variable, assembled at runtime).
A computed prompt stays in the code. Only a literal can move server-side.
Step 2 — Create one event per call site
An event owns the model and the prompt. Create it with the API — the response
carries the tag the code will use as its model:
curl -s -X POST https://api.gpuniq.com/v1/events \
-H "X-API-Key: $GPUNIQ_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "checkout summariser (src/summarise.py:42)",
"test_type": "prompt",
"primary_model": "gpt-4o",
"primary_prompt": "You are a terse assistant. Answer in one sentence."
}' | jq -r '.data.tag'
That prints a 16-character hex tag, for example a3f01c9d55e27b84.
Two rules for this call:
- With a literal system prompt →
"test_type": "prompt"and passprimary_prompt. GPUniq will useprimary_modelfor the request and inject the prompt. - Without one →
"test_type": "model"and omitprimary_prompt. GPUniq resolves the model only and leaves the messages untouched.
Give each event a name that says where it came from — the file and line. The
user will read this list in the dashboard months from now.
Step 3 — Rewrite the call site
Three edits, and nothing else. Do not reformat, rename, or restructure surrounding code.
- Point the client at
https://api.gpuniq.com/v1/openaiwith the key read from theGPUNIQ_API_KEYenvironment variable. - Replace the model string with the event tag.
- Delete the hardcoded system message.
Python — OpenAI SDK
# before
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": "You are a terse assistant. Answer in one sentence."},
{"role": "user", "content": question},
],
)
# after
client = OpenAI(
api_key=os.environ["GPUNIQ_API_KEY"],
base_url="https://api.gpuniq.com/v1/openai",
)
response = client.chat.completions.create(
model="a3f01c9d55e27b84",
messages=[{"role": "user", "content": question}],
)
TypeScript — OpenAI SDK
const client = new OpenAI({
apiKey: process.env.GPUNIQ_API_KEY,
baseURL: 'https://api.gpuniq.com/v1/openai',
});
const response = await client.chat.completions.create({
model: 'a3f01c9d55e27b84',
messages: [{ role: 'user', content: question }],
});
Anthropic SDK
The Anthropic SDK speaks a different wire format, so the migration also swaps the client. Behaviour is identical — GPUniq routes to the same model.
# before
client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
message = client.messages.create(
model="claude-sonnet-4",
system="Answer in French.",
max_tokens=1024,
messages=[{"role": "user", "content": question}],
)
text = message.content[0].text
# after
client = OpenAI(
api_key=os.environ["GPUNIQ_API_KEY"],
base_url="https://api.gpuniq.com/v1/openai",
)
message = client.chat.completions.create(
model="a3f01c9d55e27b84",
max_tokens=1024,
messages=[{"role": "user", "content": question}],
)
text = message.choices[0].message.content
If the repository depends on Anthropic-specific features (tool blocks, thinking
blocks, prompt caching), keep the Anthropic SDK and point it at
https://api.gpuniq.com instead — GPUniq serves a native /v1/messages
endpoint. Say so in your summary either way.
LangChain
llm = ChatOpenAI(
model="a3f01c9d55e27b84",
api_key=os.environ["GPUNIQ_API_KEY"],
base_url="https://api.gpuniq.com/v1/openai",
)
Remove the system message from the prompt template, since the event now owns it.
Hand-rolled HTTP
Replace the vendor host with https://api.gpuniq.com/v1/openai, send the key
as Authorization: Bearer $GPUNIQ_API_KEY, and set model to the tag. The
request and response bodies are unchanged — the endpoint is OpenAI-compatible.
If the code identifies its end users
If a user field is already being sent, keep it. GPUniq uses it to keep a
given end user on the same arm of an A/B test, so results stay consistent per
user rather than flipping per request.
Step 4 — Verify before moving on
After each call site:
- Run whatever the repository already has — tests, a type check, a lint.
- Exercise the path for real if that is cheap (a script, a single request).
- Confirm the response still has the shape the surrounding code expects.
A quick end-to-end check of the gateway itself:
curl -s https://api.gpuniq.com/v1/openai/chat/completions \
-H "Authorization: Bearer $GPUNIQ_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "a3f01c9d55e27b84", "messages": [{"role": "user", "content": "ping"}]}' \
| jq -r '.choices[0].message.content'
If a call site cannot be verified, say so plainly in your summary instead of implying it works.
Step 5 — Report what it saves
For each migrated model, compare the vendor's list price with GPUniq's:
curl -s https://api.gpuniq.com/v1/llm/models/catalog \
-H "X-API-Key: $GPUNIQ_API_KEY" \
| jq '.data.models[] | select(.slug == "gpt-4o") |
{slug, in: .retail_input_usd_per_mtok, out: .retail_output_usd_per_mtok}'
Authenticated requests return the user's own prices, discounts included. Blend input and output at the ratio this workload actually uses if you know it; assume 3:1 input:output if you do not, and say which you used.
Report per model and once for the repository. If you cannot find a published vendor price for a model, report that call site without a percentage. A guessed number is worse than a blank.
Rules
- Never write the API key into a tracked file. If the repo has
.env.example, addGPUNIQ_API_KEY=there; add the real value only to.env, and confirm.envis git-ignored. - Never delete a computed prompt. Only literals move into an event.
- One call site per commit, or one reviewable diff per call site. Do not rewrite twenty files in a single pass.
- Do not change model choice, temperature, token limits or retry logic. This migration is about routing, not behaviour.
- If a call site is ambiguous — a dynamic model name, a prompt assembled across files, a vendor-specific feature — stop and ask the user rather than guessing.
- Leaving the old system message in place is not fatal: GPUniq replaces the first system message with the event's prompt. Still delete it, so the code tells the truth about where the prompt lives.
Rolling back
Everything is reversible:
- Code —
git revertthe commits. - Routing — set the event's status to
pausedand 100% of traffic goes to the primary model, orarchivedand the tag stops resolving.
curl -s -X PATCH https://api.gpuniq.com/v1/events/<event_id> \
-H "X-API-Key: $GPUNIQ_API_KEY" \
-H "Content-Type: application/json" \
-d '{"status": "paused"}'
When you are done
Summarise for the user:
- how many call sites were migrated, and which files;
- the event tag and dashboard name for each;
- the estimated saving per model and overall, with the assumption you used;
- anything you skipped, and why.
Keep this as a reusable skill
To make it available in every future session rather than pasting the URL again, save this runbook as a Claude Code skill:
mkdir -p .claude/skills/gpuniq-migrate
curl -s https://gpuniq.com/skills/migrate-to-gpuniq/raw \
-o .claude/skills/gpuniq-migrate/SKILL.md
The downloaded file already carries the frontmatter Claude Code expects, so it
will appear as /gpuniq-migrate in that project from then on.