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.

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- openai-fine-tuning

Installs to ~/.agents/skills/openai-fine-tuning/ for every project on this machine.

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

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.

~/.agents/skills/openai-fine-tuning/SKILL.md

--- name: openai-fine-tuning description: Runs supervised (SFT) and preference (DPO) fine-tuning jobs on the OpenAI API for GPT-4.1-family models - eligibility and deprecation checks,… license: MIT ---

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.

SituationRoute
Org has run fine-tuning before, base model still live, job finishes before the cutoffContinue with this skill
Org never fine-tuned on OpenAINot available. Improve prompts and few-shot examples ($prompt-engineering) or fine-tune open weights ($lora-fine-tuning)
Existing fine-tuned model in productionPlan 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

GoalFine-tune?
Consistent output format, tone or label set that prompting gets 90% rightSFT, good fit
Pick between two acceptable styles (“concise and warm”, not “long”)DPO on preference pairs
Teach new facts or private documentsNo. Use retrieval ($rag-pipelines)
Cut prompt length/cost of a long system prompt at high volumeSFT can help; measure total cost including higher per-token fine-tuned prices
Task fails badly with the best promptFix 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 validationChange
Output ignores the format, loss still falling at endMore epochs (+1 or +2) or more data
Repeats training phrasing, worse on unusual inputsFewer epochs; pick an earlier checkpoint
Loss noisy and no improvementLower learning_rate_multiplier; check label consistency
Small dataset (< 100) with diverse outputsLeave 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_checks events 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.yamlgenerated from this page

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.py125 lines, 5.2 KB
#!/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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem