OpenAI Fine-Tuning skill for Codex
Fine-tune GPT-4.1 only when evals prove it pays. Codex checks whether your organisation can still fine-tune and when the base snapshot shuts down before preparing a single example. It builds a held-out eval first, validates chat or preference JSONL offline with a bundled script, runs SFT or DPO through fine_tuning.jobs, and compares every kept checkpoint against the base model with your best prompt.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- openai-fine-tuning Installs to ~/.agents/skills/openai-fine-tuning/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install openai-fine-tuning The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project openai-fine-tuning Installs to .agents/skills/openai-fine-tuning/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/openai-fine-tuning.zipunzip -o openai-fine-tuning.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) openai-fine-tuning PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $openai-fine-tuning, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Runs supervised (SFT) and preference (DPO) fine-tuning jobs on the OpenAI API for GPT-4.1-family models - eligibility and deprecation checks, chat-format JSONL preparation and validation, train/validation splits, job creation with the fine_tuning.jobs API, checkpoint selection and evals against the base model. Use when asked to fine-tune an OpenAI model, prepare a JSONL training file, debug a failed fine-tuning job, compare a fine-tuned model to prompting, or plan a move off OpenAI fine-tuning.
Use it when
- An existing OpenAI fine-tuning customer wants a new SFT or DPO run
- A training JSONL file fails upload or the job errors on data format
- You must decide whether a fine-tune actually beats prompting
- A production fine-tuned model faces a base-model shutdown date
Use something else when
- You want to train open-weights models on your own GPUs: use lora-fine-tuning
- The model needs your private documents or fresh facts: use rag-pipelines
- Prompt wording and examples have not been tried yet: use prompt-engineering
How to check the result
The validator exits 0 on every file, and the chosen checkpoint beats the base model with the production prompt on a held-out eval scored by the same graders.
The mistake it prevents
Training with a different system prompt than production uses, so the fine-tuned behaviour never triggers at inference time.
Done well means: fine-tuning was chosen because prompting plus evals hit a ceiling, the account and base model are confirmed eligible, every JSONL line validates, and the fine-tuned checkpoint beats the base model with the best prompt on a held-out eval.
Check eligibility first (the platform is winding down)
OpenAI’s docs state the fine-tuning platform is being wound down: it is closed to new
users, eligible existing customers can create jobs until January 6, 2027 (or until
the base model shuts down, if earlier), and fine-tuned models serve inference until their
base model shuts down. Published shutdowns: gpt-4.1-nano-2025-04-14 and its fine-tunes
on October 23, 2026; gpt-4.1-2025-04-14, gpt-4.1-mini-2025-04-14 and their fine-tunes
on January 14, 2027. Re-check the deprecations page before every plan; dates move.
| Situation | Route |
|---|---|
| Org has run fine-tuning before, base model still live, job finishes before the cutoff | Continue with this skill |
| Org never fine-tuned on OpenAI | Not available. Improve prompts and few-shot examples ($prompt-engineering) or fine-tune open weights ($lora-fine-tuning) |
| Existing fine-tuned model in production | Plan migration now: rebuild the eval, then test prompting on a current model or an open-weights LoRA against it |
| Want reinforcement fine-tuning (RFT) | It targeted o4-mini, which is on the shutdown list; treat as unavailable unless the dashboard shows otherwise |
Network is required for every API call; in a Codex sandbox with network off, prepare and validate data locally and ask before making API calls.
When fine-tuning is the right tool
| Goal | Fine-tune? |
|---|---|
| Consistent output format, tone or label set that prompting gets 90% right | SFT, good fit |
| Pick between two acceptable styles (“concise and warm”, not “long”) | DPO on preference pairs |
| Teach new facts or private documents | No. Use retrieval ($rag-pipelines) |
| Cut prompt length/cost of a long system prompt at high volume | SFT can help; measure total cost including higher per-token fine-tuned prices |
| Task fails badly with the best prompt | Fix the task definition first; 50 good examples should show movement or the task is wrong |
Workflow
- [ ] 1. Confirm eligibility, base model snapshot and its shutdown date
- [ ] 2. Build an eval set (held out, never trained on) and score base model + best prompt
- [ ] 3. Assemble training examples; dedupe; split train/validation
- [ ] 4. Validate JSONL with scripts/validate_jsonl.py
- [ ] 5. Upload files, create job, monitor events
- [ ] 6. Evaluate final model and earlier checkpoints on the eval set
- [ ] 7. Report win/loss vs baseline, cost, and the model's shutdown date
Read AGENTS.md for where datasets and secrets live. Never commit training data that
contains customer PII; ask the user how it was consented and scrubbed.
Data format
SFT: one JSON object per line with a messages array in chat format. Include the same
system prompt you will use in production; the model learns the mapping from that prompt.
{"messages": [{"role": "system", "content": "Classify the ticket. Reply with one label: billing, bug, how_to, other."}, {"role": "user", "content": "I was charged twice this month"}, {"role": "assistant", "content": "billing"}]}
Tool-calling examples may also carry tools, parallel_tool_calls and assistant
tool_calls. Multi-turn examples train on every assistant turn; add "weight": 0 to an
assistant message to exclude it from loss.
DPO: one-turn examples with a shared input and two outputs (text only):
{"input": {"messages": [{"role": "user", "content": "Summarise this release note: ..."}]}, "preferred_output": [{"role": "assistant", "content": "Adds SSO; fixes export timeout."}], "non_preferred_output": [{"role": "assistant", "content": "This release contains several exciting improvements..."}]}
Data rules:
- Minimum 10 examples; start with 50-100 high-quality ones, then scale if the eval moves.
- Hold out the eval set before anything else; dedupe train against it (exact and near-duplicate).
- Cover the real distribution: edge cases, refusals, long inputs, every label in useful proportion.
- One assistant voice: contradictory examples teach averaging, not the policy you want.
- DPO pairs should differ in the property you care about, not in unrelated length or format.
Validate before upload (stdlib only, works offline):
python3 scripts/validate_jsonl.py train.jsonl --mode sft
python3 scripts/validate_jsonl.py prefs.jsonl --mode dpo --max-tokens 65536
It checks JSON per line, roles, empty content, final assistant turn, DPO structure, duplicates and an approximate token count, and exits non-zero on any error.
Create and monitor the job
from openai import OpenAI
client = OpenAI() # OPENAI_API_KEY from env; never hard-code it
train = client.files.create(file=open("train.jsonl", "rb"), purpose="fine-tune")
valid = client.files.create(file=open("valid.jsonl", "rb"), purpose="fine-tune")
job = client.fine_tuning.jobs.create(
model="gpt-4.1-mini-2025-04-14",
training_file=train.id,
validation_file=valid.id,
suffix="ticket-router-v3",
seed=42,
method={
"type": "supervised",
"supervised": {"hyperparameters": {"n_epochs": "auto"}},
},
)
print(job.id, job.status)
DPO uses method={"type": "dpo", "dpo": {"hyperparameters": {"beta": 0.1}}}; beta
ranges 0-2, higher stays closer to the reference model. A common pattern is SFT first,
then DPO starting from the SFT model.
for ev in client.fine_tuning.jobs.list_events(job.id, limit=20):
print(ev.created_at, ev.level, ev.message)
for cp in client.fine_tuning.jobs.checkpoints.list(job.id):
print(cp.step_number, cp.fine_tuned_model_checkpoint, cp.metrics)
Creating a job costs money: state the estimated training tokens x epochs and get the
user’s go-ahead before calling jobs.create. Cancel with client.fine_tuning.jobs.cancel(id).
Hyperparameters
| Symptom on validation | Change |
|---|---|
| Output ignores the format, loss still falling at end | More epochs (+1 or +2) or more data |
| Repeats training phrasing, worse on unusual inputs | Fewer epochs; pick an earlier checkpoint |
| Loss noisy and no improvement | Lower learning_rate_multiplier; check label consistency |
| Small dataset (< 100) with diverse outputs | Leave auto; it already raises epochs for small sets |
Only the last three epoch checkpoints are kept; each is a usable model ID.
Evaluate
Score base model with the production prompt and every kept checkpoint on the same held-out
set, with the same graders ($llm-evals). Call fine-tuned models like any other model:
resp = client.responses.create(
model="ft:gpt-4.1-mini-2025-04-14:acme:ticket-router-v3:abc123",
input=[{"role": "system", "content": SYSTEM}, {"role": "user", "content": text}],
)
Decide with numbers: accuracy or rubric pass rate, failure categories, latency and cost per 1k requests. A fine-tune that ties the base model is a loss (extra cost, shutdown risk).
Gotchas
- Training without the production system prompt, then calling with it (or vice versa), silently degrades results.
moderation_checksevents can block deployment of a finished model; read the events before assuming success.- A fine-tuned model dies with its base snapshot. Record the shutdown date in the deployment config and in the report.
- Validation loss going down while eval pass rate stays flat means the loss measures wording, not correctness; trust the eval.
- Token counts in the validator are approximate (chars / 4); the API’s own count governs billing.
- Do not train on outputs from another provider’s model in violation of its terms; ask where generated examples came from.
Report template
**Base:** gpt-4.1-mini-2025-04-14 (shuts down 2027-01-14) | **Method:** SFT, 3 epochs (auto)
**Data:** 420 train / 80 valid / 150 held-out eval; deduped; validator clean
**Result:** eval pass 93.3% vs 86.0% base+prompt; billing/bug confusion down from 14 to 3
**Cost:** training ~1.1M tokens; inference price per 1k requests: $X vs $Y
**Model ID:** ft:gpt-4.1-mini-2025-04-14:acme:ticket-router-v3:abc123 (checkpoint step 1260)
**Risk:** platform wind-down; migration plan: open-weights LoRA evaluated by 2026-12 What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "OpenAI Fine-Tuning"
short_description: "Fine-tune GPT-4.1 only when evals prove it pays"
default_prompt: "$openai-fine-tuning Turn these 600 labelled support tickets into a fine-tuning JSONL and validate it" scripts/validate_jsonl.py
#!/usr/bin/env python3
"""Validate an OpenAI fine-tuning JSONL file (SFT chat format or DPO preference format).
Usage:
python3 validate_jsonl.py train.jsonl --mode sft [--max-tokens 65536]
python3 validate_jsonl.py prefs.jsonl --mode dpo
Checks every line, prints a summary, and exits 1 if any error is found.
Token counts are approximate (characters / 4); the API's own count is authoritative.
"""
import argparse
import hashlib
import json
import sys
ROLES = {"system", "developer", "user", "assistant", "tool"}
def text_of(msg):
content = msg.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
return " ".join(p.get("text", "") for p in content if isinstance(p, dict))
return ""
def check_messages(msgs, where, errors, require_final_assistant=True):
if not isinstance(msgs, list) or not msgs:
errors.append(f"{where}: messages must be a non-empty list")
return 0
chars = 0
for i, m in enumerate(msgs):
if not isinstance(m, dict):
errors.append(f"{where}: message {i} is not an object")
continue
role = m.get("role")
if role not in ROLES:
errors.append(f"{where}: message {i} has invalid role {role!r}")
has_tool_calls = bool(m.get("tool_calls"))
body = text_of(m)
if not body.strip() and not has_tool_calls:
errors.append(f"{where}: message {i} ({role}) has empty content")
if "weight" in m and m["weight"] not in (0, 1):
errors.append(f"{where}: message {i} weight must be 0 or 1")
chars += len(body) + len(json.dumps(m.get("tool_calls", "")))
if require_final_assistant and msgs[-1].get("role") != "assistant":
errors.append(f"{where}: last message must be from the assistant")
return chars
def validate(path, mode, max_tokens):
errors, warnings = [], []
seen = {}
count = 0
token_total = 0
longest = 0
with open(path, encoding="utf-8") as fh:
for n, line in enumerate(fh, start=1):
where = f"line {n}"
if not line.strip():
errors.append(f"{where}: blank line")
continue
try:
ex = json.loads(line)
except json.JSONDecodeError as exc:
errors.append(f"{where}: invalid JSON ({exc.msg})")
continue
count += 1
if mode == "sft":
chars = check_messages(ex.get("messages"), where, errors)
if "tools" in ex and not isinstance(ex["tools"], list):
errors.append(f"{where}: tools must be a list")
else:
inp = ex.get("input", {})
chars = check_messages(inp.get("messages"), where + " input", errors, require_final_assistant=False)
if inp.get("messages") and inp["messages"][-1].get("role") == "assistant":
errors.append(f"{where}: input must not end with an assistant message")
for key in ("preferred_output", "non_preferred_output"):
out = ex.get(key)
if not isinstance(out, list) or len(out) != 1 or out[0].get("role") != "assistant":
errors.append(f"{where}: {key} must be a list with one assistant message")
else:
chars += len(text_of(out[0]))
if ex.get("preferred_output") == ex.get("non_preferred_output"):
errors.append(f"{where}: preferred and non-preferred outputs are identical")
tokens = chars // 4 + 1
token_total += tokens
longest = max(longest, tokens)
if tokens > max_tokens:
errors.append(f"{where}: ~{tokens} tokens exceeds --max-tokens {max_tokens}")
digest = hashlib.sha256(json.dumps(ex, sort_keys=True).encode()).hexdigest()
if digest in seen:
warnings.append(f"{where}: duplicate of line {seen[digest]}")
else:
seen[digest] = n
if count < 10:
errors.append(f"only {count} examples; the API requires at least 10")
elif count < 50:
warnings.append(f"{count} examples; 50-100 well-chosen examples is a better starting point")
return count, token_total, longest, errors, warnings
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("path")
ap.add_argument("--mode", choices=["sft", "dpo"], default="sft")
ap.add_argument("--max-tokens", type=int, default=65536, help="per-example limit (check the model's docs)")
args = ap.parse_args()
try:
count, total, longest, errors, warnings = validate(args.path, args.mode, args.max_tokens)
except OSError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
for w in warnings[:50]:
print(f"warn: {w}")
for e in errors[:100]:
print(f"error: {e}")
print(f"\n{args.path}: {count} examples, ~{total} tokens total, longest ~{longest} tokens")
print(f"{len(errors)} error(s), {len(warnings)} warning(s)")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.