July 14, 2026
Fine-Tuning GPT-4 with OpenAI's API: A Current Guide
The fine-tuning window is closing, and the flagship model was never the real target. Here's the current state, verified against OpenAI's own documentation as of July 14, 2026.
OpenAI is retiring its self-serve fine-tuning platform on a fixed schedule. As of May 7, 2026, organizations with no prior fine-tuning history can no longer start a job. On July 2, 2026, organizations that haven't run inference on a fine-tuned model in the last 60 days lose the ability to create new jobs. On January 6, 2027, every remaining customer loses job-creation access, full stop. Inference on models you've already fine-tuned keeps working until the underlying base model is deprecated. Source: OpenAI's deprecations page.
Separately, if you have a fine-tuned gpt-3.5-turbo, gpt-4, or gpt-4.1-nano-2025-04-14 model in production, it's on the deprecation list and gets removed by October 23, 2026. More on migration below.
What "fine-tune GPT-4" actually means today
Flagship gpt-4 and gpt-4-0613 were never generally available for fine-tuning. OpenAI ran invite-only experimental access starting in 2023. gpt-4-0613 and its fine-tuned variant are now on the deprecation list, scheduled for removal by October 23, 2026, migrating to gpt-5.5.
The GPT-4-class models you can fine-tune today are the GPT-4.1 family: gpt-4.1-2025-04-14, gpt-4.1-mini-2025-04-14, and gpt-4.1-nano-2025-04-14. If you started this article expecting to fine-tune "GPT-4" itself, the honest answer is that you can't, and never really could. GPT-4.1 is the closest thing that exists.
The four fine-tuning methods and what each is for
OpenAI supports four fine-tuning methods, each scoped to a different set of models.
| Method | Eligible models | Use case |
|---|---|---|
| Supervised fine-tuning (SFT) | gpt-4.1-2025-04-14, gpt-4.1-mini-2025-04-14, gpt-4.1-nano-2025-04-14 | Teaching a consistent format, tone, or task behavior from labeled examples |
| Direct Preference Optimization (DPO) | Same three GPT-4.1 models | Steering output toward preferred responses using paired comparisons |
| Vision fine-tuning | gpt-4o-2024-08-06 only | Image classification or extraction tasks where the base model's visual judgment needs correction |
| Reinforcement fine-tuning (RFT) | o4-mini-2025-04-16 only | Reasoning tasks with a programmatic grader, where you can score correctness automatically |
Source: OpenAI's supervised fine-tuning guide, model optimization guide, and reinforcement fine-tuning guide.
Should you fine-tune at all?
Fine-tuning should follow prompting and retrieval. Those approaches solve the large majority of customization problems. Fine-tuning earns its cost after they hit a measurable ceiling. The operating sequence in Building Your AI Team starts on a hosted model, adds retrieval when the model needs missing context, and reaches for fine-tuning when a specific, persistent gap remains.
OpenAI's own platform decision reinforces this. Winding down self-serve fine-tuning while investing in longer context windows and cheaper inference is a bet that prompt-based approaches are now faster and cheaper for most teams than maintaining a custom model.
Fine-tuning still earns its place for narrow, high-volume tasks: matching a specific brand voice at scale, enforcing an exact output format, or teaching classification categories that don't map cleanly onto a prompt. If that's your case, and your organization still has access, here's how to do it.
Preparing your data for text SFT
This section, and the validator below it, cover supervised fine-tuning (SFT) on text-only data. DPO and vision fine-tuning use different file formats: DPO examples use top-level input, preferred_output, and non_preferred_output keys instead of a single messages list (DPO guide), and vision fine-tuning examples use messages whose content is an array of parts, including image_url parts, rather than a plain string (vision fine-tuning guide). Don't run the validator below against those formats — it expects text SFT's simpler shape and will flag valid DPO or vision examples as errors.
Training data for text SFT is JSONL, one JSON object per line. Each line has a messages array, and each message is a dictionary with a role key of system, user, assistant, or tool. The required keys depend on the role: system and user messages need a non-empty string content; assistant messages can omit content, or set it to null, as long as they carry a valid tool_calls list instead; each function's arguments must be a non-empty string containing a JSON object. Every declared call must then receive exactly one matching tool response immediately after that assistant message, before another user, system, or assistant message advances the conversation. Parallel calls can be answered in any order. Examples that teach function calling also need a top-level tools list defining every called function and a Boolean parallel_tool_calls setting.
Here's a minimal example, using a neutral support-ticket classification task:
{"messages": [{"role": "system", "content": "You classify support tickets into billing, technical, or account categories and respond with only the category name."}, {"role": "user", "content": "I was charged twice for my subscription this month."}, {"role": "assistant", "content": "billing"}]}
{"messages": [{"role": "system", "content": "You classify support tickets into billing, technical, or account categories and respond with only the category name."}, {"role": "user", "content": "The app crashes every time I try to export a report."}, {"role": "assistant", "content": "technical"}]}
{"messages": [{"role": "system", "content": "You classify support tickets into billing, technical, or account categories and respond with only the category name."}, {"role": "user", "content": "I can't remember which email I used to sign up."}, {"role": "assistant", "content": "account"}]}
The API requires a minimum of 10 examples. OpenAI recommends starting around 50 well-crafted examples, and quality improvements scale roughly with doubling the dataset size from there. The current per-example token limit is 65,536 tokens.
Before uploading, check your dataset for structural errors. This script mirrors the validation pattern in OpenAI's own fine-tuning cookbook:
import json
from collections import defaultdict
dataset = []
with open("training_data.jsonl") as f:
for line_number, line in enumerate(f, start=1):
if not line.strip():
continue
try:
dataset.append(json.loads(line))
except json.JSONDecodeError as error:
raise ValueError(f"Invalid JSON on line {line_number}") from error
format_errors = defaultdict(int)
def has_valid_function_tool_call_shape(tool_call):
if not isinstance(tool_call, dict):
return False
function = tool_call.get("function")
return (
isinstance(tool_call.get("id"), str)
and bool(tool_call["id"])
and tool_call.get("type") == "function"
and isinstance(function, dict)
and isinstance(function.get("name"), str)
and bool(function["name"])
)
def has_valid_function_arguments(tool_call):
arguments = tool_call["function"].get("arguments")
if not isinstance(arguments, str) or not arguments.strip():
return False
try:
parsed_arguments = json.loads(arguments)
except json.JSONDecodeError:
return False
return isinstance(parsed_arguments, dict)
def is_valid_function_tool_call(tool_call):
return has_valid_function_tool_call_shape(
tool_call
) and has_valid_function_arguments(tool_call)
def is_valid_function_tool_definition(tool):
if not isinstance(tool, dict) or tool.get("type") != "function":
return False
function = tool.get("function")
return (
isinstance(function, dict)
and isinstance(function.get("name"), str)
and bool(function["name"])
and isinstance(function.get("parameters"), dict)
)
for ex in dataset:
if not isinstance(ex, dict):
format_errors["data_type"] += 1
continue
messages = ex.get("messages", None)
if not isinstance(messages, list) or not messages:
format_errors["missing_messages_list"] += 1
continue
tools = ex.get("tools")
tools_are_valid = (
isinstance(tools, list)
and bool(tools)
and all(is_valid_function_tool_definition(tool) for tool in tools)
)
if "tools" in ex and not tools_are_valid:
format_errors["invalid_tools"] += 1
parallel_tool_calls = ex.get("parallel_tool_calls")
if "parallel_tool_calls" in ex and not isinstance(parallel_tool_calls, bool):
format_errors["invalid_parallel_tool_calls"] += 1
called_function_names = []
assistant_tool_call_ids = set()
ambiguous_tool_call_ids = set()
responded_tool_call_ids = set()
pending_tool_call_ids = set()
for message in messages:
if not isinstance(message, dict):
if pending_tool_call_ids:
format_errors["interleaved_tool_responses"] += 1
format_errors["missing_tool_responses"] += len(
pending_tool_call_ids
)
pending_tool_call_ids.clear()
format_errors["message_type"] += 1
continue
role = message.get("role")
if pending_tool_call_ids and role != "tool":
format_errors["interleaved_tool_responses"] += 1
format_errors["missing_tool_responses"] += len(pending_tool_call_ids)
pending_tool_call_ids.clear()
if role not in ("system", "user", "assistant", "tool"):
format_errors["unrecognized_role"] += 1
continue
allowed_keys = {
"system": {"role", "content", "name"},
"user": {"role", "content", "name"},
"assistant": {"role", "content", "name", "tool_calls"},
"tool": {"role", "content", "tool_call_id"},
}
if any(key not in allowed_keys[role] for key in message):
format_errors["message_unrecognized_key"] += 1
if "name" in message and (
not isinstance(message["name"], str) or not message["name"]
):
format_errors["invalid_name"] += 1
content = message.get("content")
if role in ("system", "user", "tool"):
if not isinstance(content, str) or not content:
format_errors["missing_content"] += 1
elif content is not None and (not isinstance(content, str) or not content):
format_errors["missing_content"] += 1
if role == "assistant":
has_tool_calls = "tool_calls" in message
tool_calls = message.get("tool_calls")
if has_tool_calls and (
not isinstance(tool_calls, list) or not tool_calls
):
format_errors["invalid_tool_calls"] += 1
elif has_tool_calls:
invalid_shapes = [
tool_call
for tool_call in tool_calls
if not has_valid_function_tool_call_shape(tool_call)
]
invalid_arguments = [
tool_call
for tool_call in tool_calls
if has_valid_function_tool_call_shape(tool_call)
and not has_valid_function_arguments(tool_call)
]
if invalid_shapes:
format_errors["invalid_tool_calls"] += len(invalid_shapes)
if invalid_arguments:
format_errors["invalid_tool_call_arguments"] += len(
invalid_arguments
)
valid_tool_calls = [
tool_call
for tool_call in tool_calls
if is_valid_function_tool_call(tool_call)
]
called_function_names.extend(
tool_call["function"]["name"] for tool_call in valid_tool_calls
)
for tool_call in valid_tool_calls:
tool_call_id = tool_call["id"]
if tool_call_id in assistant_tool_call_ids:
format_errors["duplicate_tool_call_id"] += 1
ambiguous_tool_call_ids.add(tool_call_id)
pending_tool_call_ids.discard(tool_call_id)
else:
assistant_tool_call_ids.add(tool_call_id)
pending_tool_call_ids.add(tool_call_id)
if parallel_tool_calls is False and len(tool_calls) > 1:
format_errors["parallel_tool_calls_disabled"] += 1
elif content is None and not has_tool_calls:
format_errors["message_missing_key"] += 1
if role == "tool":
tool_call_id = message.get("tool_call_id")
if not isinstance(tool_call_id, str) or not tool_call_id:
format_errors["missing_tool_call_id"] += 1
elif tool_call_id in ambiguous_tool_call_ids:
format_errors["ambiguous_tool_call_id"] += 1
elif tool_call_id not in assistant_tool_call_ids:
format_errors["unmatched_tool_call_id"] += 1
elif tool_call_id in responded_tool_call_ids:
format_errors["reused_tool_call_id"] += 1
elif tool_call_id not in pending_tool_call_ids:
format_errors["unmatched_tool_call_id"] += 1
else:
responded_tool_call_ids.add(tool_call_id)
pending_tool_call_ids.remove(tool_call_id)
if pending_tool_call_ids:
format_errors["missing_tool_responses"] += len(pending_tool_call_ids)
if not any(
isinstance(message, dict) and message.get("role") == "assistant"
for message in messages
):
format_errors["example_missing_assistant_message"] += 1
if called_function_names:
if "tools" not in ex:
format_errors["missing_tools_for_tool_calls"] += 1
elif tools_are_valid:
defined_function_names = {
tool["function"]["name"] for tool in tools
}
if any(name not in defined_function_names for name in called_function_names):
format_errors["undefined_tool_call"] += 1
if "parallel_tool_calls" not in ex:
format_errors["missing_parallel_tool_calls"] += 1
if format_errors:
print("Found errors:")
for k, v in format_errors.items():
print(f"{k}: {v}")
else:
print("No errors found")
The validated training_data.jsonl file is ready to upload for text SFT.
A worked example: from dataset to job
Upload the training file:
curl https://api.openai.com/v1/files \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-F purpose="fine-tune" \
-F file="@training_data.jsonl"
Create the fine-tuning job, using a real, currently supported GPT-4.1-family model ID:
curl https://api.openai.com/v1/fine_tuning/jobs \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-d '{
"training_file": "file-RCnFCYRhFDcq1aHxiYkBHw",
"model": "gpt-4.1-nano-2025-04-14"
}'
The equivalent in the current Python SDK (openai>=1.0):
from openai import OpenAI
client = OpenAI()
file = client.files.create(
file=open("training_data.jsonl", "rb"),
purpose="fine-tune",
)
job = client.fine_tuning.jobs.create(
training_file=file.id,
model="gpt-4.1-nano-2025-04-14",
method={"type": "supervised", "supervised": {"hyperparameters": {"n_epochs": 2}}},
)
print(job.id)
Source: OpenAI's supervised fine-tuning guide.
Evaluating the result
Once a job completes, client.fine_tuning.jobs.retrieve(job.id) returns a fine_tuned_model field with an ID like ft:gpt-4.1-nano-2025-04-14:your-org:custom-suffix:id. Test it the same way you'd test any model, against a fixed set of cases you understand well.
The evaluation loop we recommend, described in more depth in Building Your AI Team, starts with real traces, reads failures manually before automating anything, and grows a durable golden dataset over time. Deterministic checks handle format and required fields. Model-based judges, validated against human judgment first, handle subjective quality.
Note that OpenAI's hosted Evals dashboard is being wound down alongside the fine-tuning platform. It becomes read-only for existing users on October 31, 2026, and shuts down entirely on November 30, 2026, with OpenAI steering new users toward its Datasets product instead. The open-source openai/evals repository remains active and separate from the dashboard.
Using and deploying the fine-tuned model
Reference the fine-tuned model directly by its ft:-prefixed ID at inference time. There's no separate deployment step.
completion = client.chat.completions.create(
model="ft:gpt-4.1-nano-2025-04-14:your-org:custom-suffix:id",
messages=[{"role": "user", "content": "I was double charged this month."}],
)
print(completion.choices[0].message.content)
Inference on a fine-tuned model keeps working until OpenAI deprecates the underlying base model, independent of the job-creation cutoffs above.
What it costs
Archived GPT-4.1 text-SFT pricing (July 14, 2026 repository snapshot)
A repository revision committed on July 14, 2026 recorded the following GPT-4.1 supervised fine-tuning (SFT) rates, with training and inference priced per 1M tokens. The live OpenAI pricing page no longer publishes these rows, so preserve them as historical pricing rather than using them for a current budget.
| Model | Training | Input | Cached input | Output |
|---|---|---|---|---|
gpt-4.1-2025-04-14 | $25.00 | $3.00 | $0.75 | $12.00 |
gpt-4.1-mini-2025-04-14 | $5.00 | $0.80 | $0.20 | $3.20 |
gpt-4.1-nano-2025-04-14 | $1.50 | $0.20 | $0.05 | $0.80 |
Current o4-mini reinforcement fine-tuning pricing
The live pricing page lists reinforcement fine-tuning (RFT) for o4-mini-2025-04-16 with hourly training billing. Its inference prices are per 1M tokens.
| Model | Training | Input | Cached input | Output |
|---|---|---|---|---|
o4-mini-2025-04-16 | $100.00 per hour | $4.00 | $1.00 | $16.00 |
OpenAI is winding down the fine-tuning platform, and pricing or availability can change. Use the live OpenAI pricing page for budgeting rather than relying on the archived GPT-4.1 table.
If your access is already gone or your model is being retired
If your organization has lost fine-tuning job access, use prompting, retrieval, or a provider or open-weight tuning path outside OpenAI's self-serve fine-tuning platform. Organizations that still have job-creation access can consider reinforcement fine-tuning on o4-mini-2025-04-16 when the task fits a programmatic grader. Reinforcement fine-tuning on o4-mini-2025-04-16 retires on October 23, 2026.
If you have a fine-tuned model on the deprecation list, here's where it migrates:
| Deprecated model | Migrates to |
|---|---|
ft-gpt-3.5-turbo | gpt-5.4-mini |
ft-gpt-4 | gpt-5.5 |
ft-gpt-4.1-nano-2025-04-14 | gpt-5.4-nano |
All three are removed by October 23, 2026. Test the migration target against your existing evaluation set before that date. A newer base model doesn't guarantee your fine-tuned behavior carries over, and you may need to re-run the fine-tuning job against the new base model if the gap is large enough.
Reinforcement fine-tuning on o4-mini-2025-04-16 and inference on fine-tuned ft-o4-mini-2025-04-16 models shut down on October 23, 2026. Plan to migrate these workloads to a currently supported reasoning model or alternative tuning path before that date; there is no guaranteed drop-in replacement.
More articles
Continue exploring the Klu blog.
July 14, 2026
Fine-Tuning OpenAI Models: What Still Works
It's time to build
Collaborate with your team on reliable Generative AI features.
Want expert guidance? Book a 1:1 onboarding session from your dashboard.