Fine-Tune GPT-4o Mini for Emails

Train a custom AI model to rewrite casual messages as formal emails.

Introduction

30 Second Summary

You have probably asked ChatGPT to rewrite an email before. It works, but every time you ask, you get a slightly different tone, a different format, and a different level of formality. You end up tweaking the prompt over and over to get what you actually want.

In this project, you will fine-tune your own version of GPT-4o Mini that transforms casual, messy messages into polished professional emails. Every single time. Same tone, same format, no long prompts needed.

What You'll Build

You will create a custom fine-tuned AI model using OpenAI's API, train it on 50 hand-crafted examples, and build a Python script that compares your custom model's output against the base model side by side.

By the end of this project, you'll have:

  • A JSONL training dataset with 50 examples of casual-to-formal email transformations that you built from scratch.
  • A fine-tuned GPT-4o Mini model hosted on OpenAI that rewrites emails exactly how you trained it to.
  • A Python comparison script that queries both the base model and your custom model, so you can see the difference fine-tuning makes.
  • A Secret Mission where you expand your model to also generate professional subject lines for each email.

Are there any prerequisites?

You need an OpenAI API account with at least $5 in credits loaded. You also need Python installed on your Mac and basic comfort with the terminal.

The total cost of this project is roughly $0.50 or less in OpenAI API credits for training and testing.

Before We Start

Before We Start...

Set Up Your Environment

Before you can fine-tune a model, you need a clean workspace and the right tools installed.

In this step, you will create a project folder, set up a Python virtual environment, install the OpenAI Python SDK, and confirm everything works by sending your first API request to gpt-4o-mini.

In this step, get ready to:
  • Create a project folder and Python virtual environment.
  • Install the OpenAI Python SDK and set your API key.
  • Run a test script to confirm your setup works.
Create your project folder and virtual environment

A virtual environment gives your project its own isolated set of Python packages. This prevents conflicts with other Python projects on your machine.

  • Press Cmd+Space (macOS) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir email-fine-tuner
cd email-fine-tuner
  • Confirm the folder exists by running:
ls

You should see the email-fine-tuner folder listed in the output.

  • Create a virtual environment by running this command:
python3 -m venv venv
  • Activate the virtual environment by running:
source venv/bin/activate

Your terminal prompt should now start with (venv). This confirms the virtual environment is active.

What is a virtual environment?

A virtual environment is a self-contained folder that holds its own copy of Python and any packages you install. When activated, pip install puts packages into this folder instead of your system-wide Python. This keeps your project's dependencies separate from everything else on your machine.

Don't see (venv) in your prompt?

Make sure you ran source venv/bin/activate from inside the email-fine-tuner folder. If you see an error about venv/bin/activate not existing, re-run python3 -m venv venv first.

Still stuck? Help me activate my Python virtual environment on macOS.

Install the OpenAI Python SDK and set your API key

The OpenAI Python SDK is the official library for interacting with OpenAI's API from Python. You will use it throughout this project to send prompts, upload training data, and launch fine-tuning jobs.

  • Install the OpenAI SDK by running this command:
pip install openai

What does this command do?

  • pip install openai downloads and installs the official OpenAI Python library into your virtual environment.
  • Because your virtual environment is active, the package is installed only inside venv/ and does not affect the rest of your system.
  • Verify the installation succeeded by running:
pip show openai

You should see output showing the package name, version (2.32.0 or similar), and install location inside your venv folder.

Seeing 'pip: command not found'?

Make sure your virtual environment is active. You should see (venv) at the start of your terminal prompt. If not, run source venv/bin/activate again from inside the email-fine-tuner folder.

Need more help? Help me install the OpenAI Python SDK.

Next, you need to tell the SDK where to find your OpenAI API key. The SDK reads this automatically from an environment variable called OPENAI_API_KEY.

  • Set your API key as an environment variable by running this command, replacing the placeholder with your actual key:
export OPENAI_API_KEY="your-api-key-here"

Where do I find my API key?

  • Go to platform.openai.com and sign in.
  • Click Create new secret key.
  • Copy the key immediately. You will not be able to see it again after closing the dialog.

Keep your API key private

Never share your API key or commit it to version control. The export command sets the key only for your current terminal session. If you close the terminal, you will need to run the export command again.

Write a test script to confirm everything works

The best way to confirm your setup is to send a real request to the OpenAI API. You will write a short Python script that sends a prompt to gpt-4o-mini and prints the response.

  • Create a file called test_setup.py inside your email-fine-tuner folder by running:
touch test_setup.py
  • Confirm the file was created by running:
ls

You should see test_setup.py and venv listed in the output.

  • Open test_setup.py in your preferred text editor and paste the following code:
from openai import OpenAI

# Create the OpenAI client (reads OPENAI_API_KEY from your environment)
client = OpenAI()

# Send a simple prompt to gpt-4o-mini
response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": "Say hello in exactly 5 words."}
    ],
    temperature=0
)

# Print the model's response
print(response.choices[0].message.content)

What does this code do?

  • from openai import OpenAI imports the OpenAI client class from the SDK.
  • client = OpenAI() creates a client that automatically reads your OPENAI_API_KEY environment variable.
  • client.chat.completions.create() sends a chat request to the model you specify.
  • The messages array contains a system message (setting the assistant's behavior) and a user message (your prompt).
  • temperature=0 makes the output deterministic, so you get the same response each time.
  • response.choices[0].message.content extracts the text of the model's reply.
  • Save test_setup.py.
  • Run the script by executing this command:
python3 test_setup.py

You should see a short five-word greeting printed in your terminal, something like "Hello, how are you today?" or similar.

Seeing an authentication error?

If you see AuthenticationError or Incorrect API key provided, double-check that you ran the export OPENAI_API_KEY command in the same terminal session where you are running the script. The key must not have extra spaces or quotes around it.

If you see InsufficientQuoteError or a billing-related message, make sure your OpenAI account has at least $5 in credits. You can add credits at platform.openai.com/settings/organization/billing.

Still stuck? Help me debug my OpenAI API authentication error.

Your environment is ready. Next up, you will test the base gpt-4o-mini model with some casual messages and see exactly where it falls short.

Test the Base Model and See Its Limitations

In Step 1, you got your environment ready and confirmed the OpenAI API is working. Now it's time to see what the base GPT-4o Mini model can do on its own.

Before you train a custom model, you need a baseline. You'll send the same casual messages to the base model and ask it to rewrite them as formal emails. Pay close attention to how the formatting, tone, and structure vary across responses.

In this step, get ready to:
  • Write a Python script that sends 5 casual messages to the base model for formal rewriting.
  • Run the script and observe inconsistencies in the base model's output.
  • Save the base model responses to a file for later comparison.
Create the base model test script

To understand what fine-tuning actually improves, you first need to see how the base model handles your task without any custom training.

  • Create a new file called test_base_model.py inside your fine-tune-emails project folder by running:
touch test_base_model.py
  • Confirm the file exists by running:
ls

You should see test_base_model.py listed in the output.

  • Open test_base_model.py in your text editor and add the following code to define your test messages and system prompt:
from openai import OpenAI

# Initialize the OpenAI client (uses OPENAI_API_KEY env var automatically)
client = OpenAI()

# System prompt telling the model what to do
system_prompt = "You are a professional email writer. Rewrite the following casual message as a formal business email."

# Five casual messages to test the base model
casual_messages = [
    "hey can u send me the report asap thx",
    "yo the meeting got moved to 3pm tmrw heads up",
    "sorry i totally forgot about the deadline can we push it back a week?",
    "just wanted to say great job on the presentation u crushed it",
    "fyi the client is super unhappy about the delay we need to fix this asap"
]

What does this code do?

  • OpenAI() creates a client that automatically reads your OPENAI_API_KEY environment variable.
  • system_prompt tells the model its role. Every message gets this same instruction.
  • casual_messages is a list of 5 messy, real-world messages that need professional rewrites.
  • Now add the loop that sends each message to the base model and collects the responses. Copy this code below the casual_messages list:
# Send each casual message to the base model and print the result
print("=" * 60)
print("BASE MODEL RESPONSES")
print("=" * 60)

# Store responses for saving to a file later
all_responses = []

for i, message in enumerate(casual_messages, 1):
    print(f"\n--- Message {i} ---")
    print(f"Original: {message}")
    print()

    # Call the base gpt-4o-mini model
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": message}
        ],
        temperature=1,
        max_completion_tokens=500
    )

    # Extract the model's response text
    result = response.choices[0].message.content
    print(f"Rewritten:\n{result}")

    # Save for later
    all_responses.append({
        "original": message,
        "rewritten": result
    })

What does this code do?

  • The loop sends each casual message to gpt-4o-mini along with the system prompt.
  • temperature=1 uses the default creativity level, which means the model will produce varied outputs each time.
  • max_completion_tokens=500 caps each response at 500 tokens, which is plenty for a short email.
  • response.choices[0].message.content extracts the actual text from the API response.
  • Save test_base_model.py.

Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

I'd like to double check the full code

from openai import OpenAI

# Initialize the OpenAI client (uses OPENAI_API_KEY env var automatically)
client = OpenAI()

# System prompt telling the model what to do
system_prompt = "You are a professional email writer. Rewrite the following casual message as a formal business email."

# Five casual messages to test the base model
casual_messages = [
    "hey can u send me the report asap thx",
    "yo the meeting got moved to 3pm tmrw heads up",
    "sorry i totally forgot about the deadline can we push it back a week?",
    "just wanted to say great job on the presentation u crushed it",
    "fyi the client is super unhappy about the delay we need to fix this asap"
]

# Send each casual message to the base model and print the result
print("=" * 60)
print("BASE MODEL RESPONSES")
print("=" * 60)

# Store responses for saving to a file later
all_responses = []

for i, message in enumerate(casual_messages, 1):
    print(f"\n--- Message {i} ---")
    print(f"Original: {message}")
    print()

    # Call the base gpt-4o-mini model
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": message}
        ],
        temperature=1,
        max_completion_tokens=500
    )

    # Extract the model's response text
    result = response.choices[0].message.content
    print(f"Rewritten:\n{result}")

    # Save for later
    all_responses.append({
        "original": message,
        "rewritten": result
    })
Run the script and observe the inconsistencies

Now you'll see the base model in action. The key thing to watch for is how differently it handles each message.

  • Run the script by entering this command:
python test_base_model.py

You should see 5 rewritten emails printed to your terminal, one for each casual message.

What should I look for?

Read through all 5 responses carefully. You'll likely notice these inconsistencies:

  • Some responses include a subject line, others don't.
  • The greeting varies. One might say "Dear Colleague" while another says "Hi Team".
  • Some emails are long and wordy. Others are short and punchy.
  • The sign-off changes between "Best regards", "Sincerely", "Thank you", and others.

This inconsistency is exactly what fine-tuning fixes. Your custom model will learn to produce the same format and tone every time.

Seeing an error when you run the script?

  • If you see AuthenticationError, your API key is not set. Run export OPENAI_API_KEY="your-key-here" in your terminal again.
  • If you see ModuleNotFoundError: No module named 'openai', make sure your virtual environment is activated. Run source venv/bin/activate first.
  • If you see RateLimitError, wait a few seconds and try again. Free-tier accounts have lower rate limits.

Help me debug my test_base_model.py script.

Save the base model responses to a file

You need to save these responses so you can compare them against your fine-tuned model later. This comparison is how you'll prove that fine-tuning actually works.

  • Open test_base_model.py and add this code at the very bottom of the file, after the loop:
# Save all responses to a text file for later comparison
with open("base_model_responses.txt", "w") as f:
    for i, item in enumerate(all_responses, 1):
        f.write(f"--- Message {i} ---\n")
        f.write(f"Original: {item['original']}\n\n")
        f.write(f"Rewritten:\n{item['rewritten']}\n\n")

print("\nResponses saved to base_model_responses.txt")

What does this code do?

  • open("base_model_responses.txt", "w") creates a new text file (or overwrites it if it already exists).
  • The loop writes each original message and its rewritten version into the file with clear labels.
  • You'll use this file in Step 5 to compare base model output against your fine-tuned model's output.
  • Save test_base_model.py.
  • Run the script again to generate and save the responses:
python test_base_model.py
  • Confirm the file was created by running:
ls

You should see base_model_responses.txt listed alongside test_base_model.py.

Don't see the file?

  • Make sure you added the file-saving code after the for loop, not inside it. The indentation matters.
  • Check that you saved test_base_model.py before running it again.

Help me figure out why my file isn't being created.

Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

I'd like to double check the full code

from openai import OpenAI

# Initialize the OpenAI client (uses OPENAI_API_KEY env var automatically)
client = OpenAI()

# System prompt telling the model what to do
system_prompt = "You are a professional email writer. Rewrite the following casual message as a formal business email."

# Five casual messages to test the base model
casual_messages = [
    "hey can u send me the report asap thx",
    "yo the meeting got moved to 3pm tmrw heads up",
    "sorry i totally forgot about the deadline can we push it back a week?",
    "just wanted to say great job on the presentation u crushed it",
    "fyi the client is super unhappy about the delay we need to fix this asap"
]

# Send each casual message to the base model and print the result
print("=" * 60)
print("BASE MODEL RESPONSES")
print("=" * 60)

# Store responses for saving to a file later
all_responses = []

for i, message in enumerate(casual_messages, 1):
    print(f"\n--- Message {i} ---")
    print(f"Original: {message}")
    print()

    # Call the base gpt-4o-mini model
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": message}
        ],
        temperature=1,
        max_completion_tokens=500
    )

    # Extract the model's response text
    result = response.choices[0].message.content
    print(f"Rewritten:\n{result}")

    # Save for later
    all_responses.append({
        "original": message,
        "rewritten": result
    })

# Save all responses to a text file for later comparison
with open("base_model_responses.txt", "w") as f:
    for i, item in enumerate(all_responses, 1):
        f.write(f"--- Message {i} ---\n")
        f.write(f"Original: {item['original']}\n\n")
        f.write(f"Rewritten:\n{item['rewritten']}\n\n")

print("\nResponses saved to base_model_responses.txt")

You now have a clear baseline of how the base model handles email rewriting. Next up, you'll build a training dataset that teaches the model exactly how you want every email to look.

Build Your Training Dataset

In Step 2, you saw the base model struggle with consistency. It produced different formats, varying levels of formality, and unpredictable email structures every time.

Now you will build the training dataset that teaches your model exactly how you want emails to look. This is the most important part of fine-tuning. The quality and variety of your examples directly determines how well your custom model performs.

In this step, get ready to:
  • Create a JSONL training file with your first 10 hand-crafted examples.
  • Expand the dataset to 50 examples covering diverse tones and scenarios.
  • Validate the file with a Python script to catch formatting errors before uploading.
Write your first 10 training examples

OpenAI's fine-tuning API expects training data in JSONL format. Each line of the file is a complete JSON object containing a conversation with three messages: a system message, a user message, and an assistant message.

What is JSONL?

JSONL stands for JSON Lines. Each line in the file is a standalone, valid JSON object. There are no commas between lines and no wrapping array. This format makes it easy to add or remove examples without breaking the whole file.

  • Create a new file called training_data.jsonl inside your email-rewriter project folder.
  • Copy the following 10 training examples into the file:
{"messages": [{"role": "system", "content": "You are a professional email rewriter. Rewrite the user's casual message as a polished, formal business email. Use a professional greeting, clear body paragraphs, and a courteous closing."}, {"role": "user", "content": "hey can u send me the report asap thx"}, {"role": "assistant", "content": "Dear colleague,\n\nI hope this message finds you well. Could you please send me the report at your earliest convenience? I would greatly appreciate a prompt response.\n\nThank you for your time.\n\nBest regards"}]}
{"messages": [{"role": "system", "content": "You are a professional email rewriter. Rewrite the user's casual message as a polished, formal business email. Use a professional greeting, clear body paragraphs, and a courteous closing."}, {"role": "user", "content": "yo the server is down again can someone fix it"}, {"role": "assistant", "content": "Dear IT Support Team,\n\nI am writing to inform you that the server appears to be experiencing downtime once again. Could someone please investigate and resolve this issue as soon as possible?\n\nThank you for your prompt attention to this matter.\n\nBest regards"}]}
{"messages": [{"role": "system", "content": "You are a professional email rewriter. Rewrite the user's casual message as a polished, formal business email. Use a professional greeting, clear body paragraphs, and a courteous closing."}, {"role": "user", "content": "cant make it to the meeting tmrw something came up"}, {"role": "assistant", "content": "Dear team,\n\nI regret to inform you that I will be unable to attend tomorrow's meeting due to an unforeseen scheduling conflict. I apologize for any inconvenience this may cause.\n\nPlease let me know if there is anything I can contribute in advance, or if the meeting notes can be shared afterward.\n\nBest regards"}]}
{"messages": [{"role": "system", "content": "You are a professional email rewriter. Rewrite the user's casual message as a polished, formal business email. Use a professional greeting, clear body paragraphs, and a courteous closing."}, {"role": "user", "content": "thanks for helping out yesterday u really saved us"}, {"role": "assistant", "content": "Dear colleague,\n\nI wanted to take a moment to express my sincere gratitude for your assistance yesterday. Your support was invaluable, and it made a significant difference for the team.\n\nThank you once again for going above and beyond.\n\nBest regards"}]}
{"messages": [{"role": "system", "content": "You are a professional email rewriter. Rewrite the user's casual message as a polished, formal business email. Use a professional greeting, clear body paragraphs, and a courteous closing."}, {"role": "user", "content": "where r we on the budget thing? need numbers by friday"}, {"role": "assistant", "content": "Dear team,\n\nI would like to request an update on the current status of the budget proposal. Could you please share the finalized figures by end of day Friday?\n\nYour timely response would be greatly appreciated.\n\nBest regards"}]}
{"messages": [{"role": "system", "content": "You are a professional email rewriter. Rewrite the user's casual message as a polished, formal business email. Use a professional greeting, clear body paragraphs, and a courteous closing."}, {"role": "user", "content": "just wanted to let u know i finished the design mockups lmk what u think"}, {"role": "assistant", "content": "Dear team,\n\nI am pleased to inform you that I have completed the design mockups. I would welcome your feedback at your earliest convenience.\n\nPlease do not hesitate to reach out if you have any questions or suggestions.\n\nBest regards"}]}
{"messages": [{"role": "system", "content": "You are a professional email rewriter. Rewrite the user's casual message as a polished, formal business email. Use a professional greeting, clear body paragraphs, and a courteous closing."}, {"role": "user", "content": "this is the third time the invoice is wrong pls fix it"}, {"role": "assistant", "content": "Dear Accounts Payable Team,\n\nI am writing to bring to your attention a recurring issue with the invoice. This is the third instance in which the invoice has contained errors. I kindly request that the matter be reviewed and corrected promptly.\n\nPlease let me know once the revised invoice is available.\n\nBest regards"}]}
{"messages": [{"role": "system", "content": "You are a professional email rewriter. Rewrite the user's casual message as a polished, formal business email. Use a professional greeting, clear body paragraphs, and a courteous closing."}, {"role": "user", "content": "hey im new here who do i talk to about getting my laptop set up"}, {"role": "assistant", "content": "Dear HR Team,\n\nI recently joined the organization and would appreciate guidance on the process for setting up my company laptop. Could you please direct me to the appropriate contact or department?\n\nThank you for your assistance.\n\nBest regards"}]}
{"messages": [{"role": "system", "content": "You are a professional email rewriter. Rewrite the user's casual message as a polished, formal business email. Use a professional greeting, clear body paragraphs, and a courteous closing."}, {"role": "user", "content": "great news we landed the client!! celebration time"}, {"role": "assistant", "content": "Dear team,\n\nI am delighted to share that we have successfully secured the new client. This is a wonderful achievement that reflects the hard work and dedication of the entire team.\n\nCongratulations to everyone involved. I look forward to celebrating this milestone together.\n\nBest regards"}]}
{"messages": [{"role": "system", "content": "You are a professional email rewriter. Rewrite the user's casual message as a polished, formal business email. Use a professional greeting, clear body paragraphs, and a courteous closing."}, {"role": "user", "content": "can we push the deadline back a week? were not gonna make it at this rate"}, {"role": "assistant", "content": "Dear project lead,\n\nI would like to discuss the possibility of extending the current deadline by one week. Based on our progress, I believe additional time would allow us to deliver a higher quality result.\n\nI am happy to discuss this further at your convenience.\n\nBest regards"}]}

What does each training example contain?

Every line follows the same three-message structure:

  • system message: tells the model its role and the output format you expect. This stays identical across every example.
  • user message: the casual, messy input text the model receives.
  • assistant message: your ideal formal email output. This is what the model learns to produce.
  • Save training_data.jsonl.
  • Confirm the file has exactly 10 lines by running:
wc -l training_data.jsonl

You should see 10 in the output.

Seeing a different number?

Make sure each JSON object is on its own single line with no line breaks inside it. A common mistake is letting your text editor wrap long lines, which splits one example across multiple lines.

Help me fix my JSONL file line count.

Why is the system message the same in every example?

Keeping the system message identical across all examples teaches the model to associate that exact instruction with your desired output style. When you use the fine-tuned model later, you will send the same system message and the model will know exactly what to do.

Expand to 50 examples with variety

Ten examples meet OpenAI's minimum, but the model needs more variety to learn the general pattern. OpenAI recommends 50 to 100 examples for meaningful improvements.

  • Open training_data.jsonl and add 40 more examples, one per line, following the exact same JSON structure.

Cover as many different situations as you can. Here are categories to aim for:

  • Different tones: angry, confused, excited, lazy, apologetic, grateful.
  • Different scenarios: meeting requests, status updates, complaints, thank-you notes, introductions, deadline extensions, project updates, resource requests.
  • Different levels of messiness: abbreviations ("u", "thx", "tmrw"), all lowercase, missing punctuation, slang, typos, emoji-style text.

Pro tip: teach the pattern, not specific phrases

If all your casual inputs start with "hey" and all your formal outputs start with "Dear colleague," the model might memorize those specific words instead of learning the transformation pattern. Vary your greetings, closings, and sentence structures across examples.

What makes a good training example?

Each assistant response should follow a consistent structure: a professional greeting, one or two clear body paragraphs, and a courteous closing. Keep the formatting identical across all 50 examples. The model learns from the pattern in your outputs, so consistency is key.

  • Save training_data.jsonl after adding all 40 new examples.
  • Confirm the file now has 50 lines by running:
wc -l training_data.jsonl

You should see 50 in the output.

Validate your JSONL file with a Python script

A single typo in your JSONL file (a missing comma, a broken quote, a wrong role name) will cause the fine-tuning job to fail. You can catch these errors now with a quick validation script.

  • Create a new file called validate_data.py in your project folder and paste the following code:
import json

# Define the roles every training example must have
required_roles = {"system", "user", "assistant"}

error_count = 0

with open("training_data.jsonl", "r") as f:
    for i, line in enumerate(f, 1):
        try:
            # Parse the JSON on this line
            data = json.loads(line)

            # Check that a "messages" key exists
            if "messages" not in data:
                print(f"Line {i}: Missing 'messages' key.")
                error_count += 1
                continue

            # Collect the roles present in this example
            roles = {msg["role"] for msg in data["messages"]}

            # Check that all three required roles are present
            missing = required_roles - roles
            if missing:
                print(f"Line {i}: Missing roles: {missing}")
                error_count += 1

        except json.JSONDecodeError as e:
            print(f"Line {i}: Invalid JSON - {e}")
            error_count += 1

if error_count == 0:
    print(f"All {i} examples are valid!")
else:
    print(f"\nFound {error_count} error(s). Fix them before uploading.")

What does this script do?

  • It reads each line of training_data.jsonl and tries to parse it as JSON. Any broken syntax gets caught here.
  • It checks that every example has a messages key containing all three required roles: system, user, and assistant.
  • It prints the exact line number of any error so you can find and fix it quickly.
  • Save validate_data.py.
  • Run the validation script by entering this command:
python validate_data.py

You should see All 50 examples are valid! in the output.

Seeing errors in the output?

The script tells you the exact line number. Open training_data.jsonl and jump to that line. Common issues include:

  • Unescaped quotes inside the email text. Use \" for any quotation marks inside a JSON string.
  • A trailing comma after the last message in the messages array. JSON does not allow trailing commas.
  • A misspelled role name (for example, "assitant" instead of "assistant").

Help me fix JSON errors in my training data file.

Awesome, I've got everything!

Great. Double check you have saved both training_data.jsonl and validate_data.py.

I'd like to double check the full code

Here is the complete validate_data.py for reference:

import json

# Define the roles every training example must have
required_roles = {"system", "user", "assistant"}

error_count = 0

with open("training_data.jsonl", "r") as f:
    for i, line in enumerate(f, 1):
        try:
            # Parse the JSON on this line
            data = json.loads(line)

            # Check that a "messages" key exists
            if "messages" not in data:
                print(f"Line {i}: Missing 'messages' key.")
                error_count += 1
                continue

            # Collect the roles present in this example
            roles = {msg["role"] for msg in data["messages"]}

            # Check that all three required roles are present
            missing = required_roles - roles
            if missing:
                print(f"Line {i}: Missing roles: {missing}")
                error_count += 1

        except json.JSONDecodeError as e:
            print(f"Line {i}: Invalid JSON - {e}")
            error_count += 1

if error_count == 0:
    print(f"All {i} examples are valid!")
else:
    print(f"\nFound {error_count} error(s). Fix them before uploading.")

Your training dataset is ready to go. Next up, you will upload this file to OpenAI and kick off the fine-tuning job that creates your custom model.

Upload Your Data and Start Fine-Tuning

In the last step, you built a training dataset of 50 examples and validated its structure. Now it is time to send that data to OpenAI and kick off the actual fine-tuning process.

Fine-tuning happens on OpenAI's servers. You upload your JSONL file, create a training job, and wait for it to finish. When it completes, you get a brand new model ID that you can use just like gpt-4o-mini in any API call.

In this step, get ready to:
  • Upload your training data to OpenAI using the Files API.
  • Create a fine-tuning job that trains a custom version of gpt-4o-mini.
  • Monitor the job until it completes and retrieve your new model ID.
Upload your training data to OpenAI

Before OpenAI can train a model on your data, you need to upload the .jsonl file to their servers. The Files API gives you back a file ID that you pass to the fine-tuning job.

  • Create a new file called start_fine_tuning.py in your project folder.
  • Add the file upload code by copying the following into start_fine_tuning.py:
from openai import OpenAI

# Initialize the OpenAI client (reads OPENAI_API_KEY from environment)
client = OpenAI()

# Upload the training data file to OpenAI
# purpose="fine-tune" tells OpenAI this file is for fine-tuning
training_file = client.files.create(
    file=open("training_data.jsonl", "rb"),
    purpose="fine-tune"
)

print(f"File uploaded successfully!")
print(f"File ID: {training_file.id}")

What does this code do?

  • client.files.create() uploads your training_data.jsonl file to OpenAI's servers.
  • purpose="fine-tune" tells OpenAI this file is training data for a fine-tuning job. OpenAI will validate the file format before accepting it.
  • The returned training_file.id is a unique identifier (like file-abc123) that you will use in the next chunk to start the training job.
  • Save start_fine_tuning.py.
  • Run the script to test the upload by running:
python start_fine_tuning.py

You should see output like File uploaded successfully! followed by a file ID starting with file-.

Seeing a file not found error?

  • Make sure training_data.jsonl is in the same folder as start_fine_tuning.py. The script opens the file from the current directory.
  • Check that your terminal is in the project folder. Run ls to confirm you can see both files listed.
  • If you see an authentication error, confirm your OPENAI_API_KEY environment variable is still set by running echo $OPENAI_API_KEY.

Still stuck? Ask for help.

Create the fine-tuning job

Now that your data is on OpenAI's servers, you can tell OpenAI to start training. The fine-tuning job takes your uploaded file and uses it to create a customized version of gpt-4o-mini.

  • In start_fine_tuning.py, add the following code after the file upload section:
# Create a fine-tuning job using the uploaded file
# This tells OpenAI to train a custom version of gpt-4o-mini
job = client.fine_tuning.jobs.create(
    training_file=training_file.id,
    model="gpt-4o-mini",
    suffix="email-rewriter"
)

print(f"\nFine-tuning job created!")
print(f"Job ID: {job.id}")
print(f"Status: {job.status}")

What does this code do?

  • client.fine_tuning.jobs.create() starts a new fine-tuning job on OpenAI's servers.
  • training_file=training_file.id points the job to the file you just uploaded.
  • model="gpt-4o-mini" sets the base model that OpenAI will customize with your training data.
  • suffix="email-rewriter" adds a human-readable label to your model's name. Your final model ID will look something like ft:gpt-4o-mini:your-org:email-rewriter:abc123.
  • Save start_fine_tuning.py.

You won't run this yet. The next chunk adds a polling loop so you can watch the job's progress in real time.

Add a polling loop to monitor the job

Fine-tuning takes a few minutes. Your script needs to check the job's status periodically until it finishes. A polling loop does this by asking OpenAI for the latest status every 30 seconds.

  • In start_fine_tuning.py, add the following polling loop at the bottom of the file:
import time

# Poll the job status every 30 seconds until it finishes
print("\nWaiting for fine-tuning to complete...")
while True:
    # Retrieve the latest job status from OpenAI
    updated_job = client.fine_tuning.jobs.retrieve(job.id)
    status = updated_job.status

    print(f"Status: {status}")

    # Check if the job has reached a final state
    if status == "succeeded":
        print(f"\nFine-tuning complete!")
        print(f"Your fine-tuned model ID: {updated_job.fine_tuned_model}")
        break
    elif status == "failed":
        print(f"\nFine-tuning failed.")
        print(f"Error: {updated_job.error}")
        break
    elif status == "cancelled":
        print(f"\nFine-tuning was cancelled.")
        break

    # Wait 30 seconds before checking again
    time.sleep(30)

What does this code do?

  • client.fine_tuning.jobs.retrieve(job.id) fetches the latest information about your fine-tuning job from OpenAI.
  • The status field cycles through validating_files, queued, running, and finally succeeded (or failed).
  • When the status reaches succeeded, the fine_tuned_model field contains your new model ID. This is the ID you will use in API calls instead of gpt-4o-mini.
  • time.sleep(30) pauses the script for 30 seconds between each check to avoid flooding the API.

What is supervised fine-tuning?

Supervised fine-tuning means you provide labeled examples of inputs and desired outputs. OpenAI trains the model to reproduce those patterns. Each of your 50 JSONL examples is a labeled pair: the casual message (input) and the formal email (desired output).

By default, OpenAI runs multiple passes (epochs) through your dataset. With 50 examples, training typically takes 5 to 10 minutes.

Awesome, I've got everything!

Double check you have saved start_fine_tuning.py with all three sections: the file upload, the job creation, and the polling loop.

I'd like to double check the full code

Here is the complete start_fine_tuning.py file for reference:

from openai import OpenAI
import time

# Initialize the OpenAI client (reads OPENAI_API_KEY from environment)
client = OpenAI()

# Upload the training data file to OpenAI
# purpose="fine-tune" tells OpenAI this file is for fine-tuning
training_file = client.files.create(
    file=open("training_data.jsonl", "rb"),
    purpose="fine-tune"
)

print(f"File uploaded successfully!")
print(f"File ID: {training_file.id}")

# Create a fine-tuning job using the uploaded file
# This tells OpenAI to train a custom version of gpt-4o-mini
job = client.fine_tuning.jobs.create(
    training_file=training_file.id,
    model="gpt-4o-mini",
    suffix="email-rewriter"
)

print(f"\nFine-tuning job created!")
print(f"Job ID: {job.id}")
print(f"Status: {job.status}")

# Poll the job status every 30 seconds until it finishes
print("\nWaiting for fine-tuning to complete...")
while True:
    # Retrieve the latest job status from OpenAI
    updated_job = client.fine_tuning.jobs.retrieve(job.id)
    status = updated_job.status

    print(f"Status: {status}")

    # Check if the job has reached a final state
    if status == "succeeded":
        print(f"\nFine-tuning complete!")
        print(f"Your fine-tuned model ID: {updated_job.fine_tuned_model}")
        break
    elif status == "failed":
        print(f"\nFine-tuning failed.")
        print(f"Error: {updated_job.error}")
        break
    elif status == "cancelled":
        print(f"\nFine-tuning was cancelled.")
        break

    # Wait 30 seconds before checking again
    time.sleep(30)
  • Save start_fine_tuning.py and run the complete script by running:
python start_fine_tuning.py

You should see the file upload confirmation, then the job ID, followed by status updates every 30 seconds. The job typically takes 5 to 10 minutes to complete.

Pro tip: grab a coffee

The script will keep printing status updates until the job finishes. You do not need to watch it the entire time. Just leave the terminal open and come back when it prints your fine-tuned model ID.

When the job succeeds, you should see output like:

Fine-tuning complete!
Your fine-tuned model ID: ft:gpt-4o-mini:your-org:email-rewriter:abc123
  • Copy the fine-tuned model ID from your terminal output. You will need it in the next step.

Save your model ID

Your fine-tuned model ID (starting with ft:gpt-4o-mini:) is unique to your account. Copy it somewhere safe. You will use this exact ID in the next step to query your custom model.

Job failed or stuck?

  • If the status shows failed, check the error message printed by the script. The most common cause is a formatting issue in your training_data.jsonl file. Go back and run your validation script from Step 3 to catch any problems.
  • If the job stays in queued for more than 15 minutes, this is normal during peak usage. OpenAI queues jobs when their training servers are busy.
  • If you see an insufficient credits error, check your OpenAI account balance. Fine-tuning 50 examples costs roughly $0.50 or less.

Need help debugging?

You now have your very own custom AI model hosted on OpenAI. Next up, you will put it to the test by sending the same prompts to both the base model and your fine-tuned model to see the difference side by side.

Compare Base Model vs. Your Custom Model

Your fine-tuned model is live. Now it is time to find out whether the training actually made a difference.

In this step, you will send the exact same casual messages to both the base gpt-4o-mini model and your custom fine-tuned model. You will print the results side by side so you can see exactly where fine-tuning shines. Then you will push your model further with brand-new messages it has never seen before.

In this step, get ready to:
  • Build a comparison script that queries both models with the same prompts.
  • Print base model and fine-tuned model outputs side by side.
  • Test your fine-tuned model's generalization with completely new messages.
Create the comparison script

You saved 5 base model responses back in Step 2. Now you will run those same 5 casual messages through both models in a single script so the outputs appear right next to each other.

  • Create a new file called compare_models.py in your fine-tune-emails folder by running:
touch compare_models.py
  • Confirm the file exists by running:
ls

You should see compare_models.py listed alongside your other project files.

  • Open compare_models.py in your text editor and paste the following code:
from openai import OpenAI

# Initialize the OpenAI client (uses OPENAI_API_KEY from environment)
client = OpenAI()

# Your fine-tuned model ID from Step 4
FINE_TUNED_MODEL = "[[FINE_TUNED_MODEL_ID=\"your fine-tuned model ID from Step 4\"]]"

# The same 5 casual messages from Step 2
casual_messages = [
    "hey can u send me the report asap thx",
    "yo the meeting got moved to 3pm tmrw heads up",
    "sorry i missed the deadline things got crazy will send tonight",
    "hi can someone fix the bug on the login page its been broken for days",
    "just wanted to say great job on the presentation u killed it"
]

# System prompt for the base model
system_prompt = "Rewrite the following casual message as a formal, professional business email."

What does this code do?

  • FINE_TUNED_MODEL stores your custom model ID. Replace this with the model ID that was printed at the end of Step 4.
  • casual_messages holds the same 5 test messages you used in Step 2. Using identical inputs is the only way to make a fair comparison.
  • system_prompt gives the base model instructions. Your fine-tuned model already learned this behavior from training, so it does not need the same prompting.
  • Add the comparison function below the code you just pasted:
def get_response(model, messages):
    """Send a prompt to a model and return the response text."""
    response = client.chat.completions.create(
        model=model,
        messages=messages,
        temperature=0,
        max_completion_tokens=500
    )
    return response.choices[0].message.content

# Loop through each casual message and compare outputs
for i, casual_msg in enumerate(casual_messages, 1):
    print(f"\n{'='*60}")
    print(f"PROMPT {i}: {casual_msg}")
    print(f"{'='*60}")

    # Get the base model response (needs a system prompt)
    base_response = get_response("gpt-4o-mini", [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": casual_msg}
    ])

    # Get the fine-tuned model response (no system prompt needed)
    ft_response = get_response(FINE_TUNED_MODEL, [
        {"role": "user", "content": casual_msg}
    ])

    print(f"\n--- BASE MODEL ---")
    print(base_response)
    print(f"\n--- FINE-TUNED MODEL ---")
    print(ft_response)

What does this code do?

  • get_response() is a helper that sends messages to any model and returns the text. Setting temperature=0 makes the output as consistent as possible.
  • The base model gets a system_prompt telling it to rewrite formally. The fine-tuned model only gets the user message because it already learned the task during training.
  • max_completion_tokens=500 caps the response length so you do not burn through tokens on runaway outputs.
  • Save compare_models.py.

Not sure where your fine-tuned model ID is?

Check the terminal output from Step 4. Your model ID looks something like ft:gpt-4o-mini:your-org:suffix:abc123. You can also find it in the OpenAI dashboard under Fine-tuning.

Help me find my fine-tuned model ID.

Run the comparison and see the difference
  • Run the comparison script by running this command:
python compare_models.py

You should see 5 prompts printed, each with a BASE MODEL response and a FINE-TUNED MODEL response directly below it.

What should I look for?

Pay attention to the consistency of your fine-tuned model compared to the base model. Here is what you will typically notice:

  • The base model varies its formatting, tone, and structure across the 5 prompts. Some responses might include subject lines, others might not. Some might be overly verbose.
  • Your fine-tuned model produces emails in the same consistent format every time, matching the style you taught it through your 50 training examples.

That consistency is the whole point of fine-tuning. You traded a long, detailed prompt for a model that just knows what to do.

Getting an error about the model not being found?

  • Double-check that you pasted your full fine-tuned model ID into the FINE_TUNED_MODEL variable. It should start with ft:gpt-4o-mini:.
  • Make sure your fine-tuning job from Step 4 completed successfully. A job that is still running or failed will not produce a usable model.
  • Confirm that your OPENAI_API_KEY environment variable is still set in this terminal session.

Help me debug why my fine-tuned model is not found.

Test generalization with new messages

Your model performed well on messages similar to its training data. The real test is whether it can handle messages it has never seen before. This is called generalization.

  • Create a new file called test_generalization.py and paste the following code:
from openai import OpenAI

# Initialize the client
client = OpenAI()

# Your fine-tuned model ID
FINE_TUNED_MODEL = "[[FINE_TUNED_MODEL_ID=\"your fine-tuned model ID from Step 4\"]]"

# 3 brand-new messages NOT in the training data
new_messages = [
    "ugh the client changed the requirements AGAIN can we hop on a call to figure this out",
    "fyi im gonna be out next week for vacation, jamie is covering for me",
    "hey quick q - do we need approval from legal before sending the contract or can i just send it"
]

# System prompt for the base model comparison
system_prompt = "Rewrite the following casual message as a formal, professional business email."

Why new messages?

These 3 messages cover scenarios your training data likely did not include word for word. If the fine-tuned model still produces clean, consistent emails, it means the model learned the pattern of formal rewriting. It did not just memorize your examples.

  • Add the comparison loop below the code you just pasted:
def get_response(model, messages):
    """Send a prompt to a model and return the response text."""
    response = client.chat.completions.create(
        model=model,
        messages=messages,
        temperature=0,
        max_completion_tokens=500
    )
    return response.choices[0].message.content

print("\n" + "="*60)
print("GENERALIZATION TEST: Messages the model has never seen")
print("="*60)

for i, msg in enumerate(new_messages, 1):
    print(f"\n{'='*60}")
    print(f"NEW PROMPT {i}: {msg}")
    print(f"{'='*60}")

    # Base model with system prompt
    base_response = get_response("gpt-4o-mini", [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": msg}
    ])

    # Fine-tuned model without system prompt
    ft_response = get_response(FINE_TUNED_MODEL, [
        {"role": "user", "content": msg}
    ])

    print(f"\n--- BASE MODEL ---")
    print(base_response)
    print(f"\n--- FINE-TUNED MODEL ---")
    print(ft_response)

What does this code do?

This script follows the same pattern as compare_models.py but uses 3 completely new casual messages. The comparison logic is identical. Only the inputs have changed.

  • Save test_generalization.py.
  • Run the generalization test by running this command:
python test_generalization.py

You should see 3 new prompts, each with side-by-side responses from both models.

What should I see?

Your fine-tuned model should still produce consistent, well-formatted emails even for messages it never saw during training. The tone, structure, and level of formality should match what you taught it. If it does, your model has successfully generalized the pattern.

Fine-tuned model output looks inconsistent?

  • This can happen if your training data had inconsistent formatting across examples. The model learns whatever patterns you gave it.
  • If the outputs are wildly different from your training style, check that your 50 training examples all follow the same email format.

Why is my fine-tuned model not consistent?

Awesome, I've got everything!

Double-check that you have saved both compare_models.py and test_generalization.py.

I'd like to double check the full code

Here is the complete compare_models.py file:

from openai import OpenAI

# Initialize the OpenAI client (uses OPENAI_API_KEY from environment)
client = OpenAI()

# Your fine-tuned model ID from Step 4
FINE_TUNED_MODEL = "[[FINE_TUNED_MODEL_ID=\"your fine-tuned model ID from Step 4\"]]"

# The same 5 casual messages from Step 2
casual_messages = [
    "hey can u send me the report asap thx",
    "yo the meeting got moved to 3pm tmrw heads up",
    "sorry i missed the deadline things got crazy will send tonight",
    "hi can someone fix the bug on the login page its been broken for days",
    "just wanted to say great job on the presentation u killed it"
]

# System prompt for the base model
system_prompt = "Rewrite the following casual message as a formal, professional business email."

def get_response(model, messages):
    """Send a prompt to a model and return the response text."""
    response = client.chat.completions.create(
        model=model,
        messages=messages,
        temperature=0,
        max_completion_tokens=500
    )
    return response.choices[0].message.content

# Loop through each casual message and compare outputs
for i, casual_msg in enumerate(casual_messages, 1):
    print(f"\n{'='*60}")
    print(f"PROMPT {i}: {casual_msg}")
    print(f"{'='*60}")

    # Get the base model response (needs a system prompt)
    base_response = get_response("gpt-4o-mini", [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": casual_msg}
    ])

    # Get the fine-tuned model response (no system prompt needed)
    ft_response = get_response(FINE_TUNED_MODEL, [
        {"role": "user", "content": casual_msg}
    ])

    print(f"\n--- BASE MODEL ---")
    print(base_response)
    print(f"\n--- FINE-TUNED MODEL ---")
    print(ft_response)

Here is the complete test_generalization.py file:

from openai import OpenAI

# Initialize the client
client = OpenAI()

# Your fine-tuned model ID
FINE_TUNED_MODEL = "[[FINE_TUNED_MODEL_ID=\"your fine-tuned model ID from Step 4\"]]"

# 3 brand-new messages NOT in the training data
new_messages = [
    "ugh the client changed the requirements AGAIN can we hop on a call to figure this out",
    "fyi im gonna be out next week for vacation, jamie is covering for me",
    "hey quick q - do we need approval from legal before sending the contract or can i just send it"
]

# System prompt for the base model comparison
system_prompt = "Rewrite the following casual message as a formal, professional business email."

def get_response(model, messages):
    """Send a prompt to a model and return the response text."""
    response = client.chat.completions.create(
        model=model,
        messages=messages,
        temperature=0,
        max_completion_tokens=500
    )
    return response.choices[0].message.content

print("\n" + "="*60)
print("GENERALIZATION TEST: Messages the model has never seen")
print("="*60)

for i, msg in enumerate(new_messages, 1):
    print(f"\n{'='*60}")
    print(f"NEW PROMPT {i}: {msg}")
    print(f"{'='*60}")

    # Base model with system prompt
    base_response = get_response("gpt-4o-mini", [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": msg}
    ])

    # Fine-tuned model without system prompt
    ft_response = get_response(FINE_TUNED_MODEL, [
        {"role": "user", "content": msg}
    ])

    print(f"\n--- BASE MODEL ---")
    print(base_response)
    print(f"\n--- FINE-TUNED MODEL ---")
    print(ft_response)

You now have concrete proof that your fine-tuned model outperforms the base model on your specific task. Next up, you will push your model even further in the Secret Mission by teaching it to generate subject lines too.

Secret mission

Add Subject Line Generation

Your fine-tuned model rewrites email bodies consistently. But real professional emails need subject lines too. Can you teach your model to generate both at once, without losing the formatting quality you already trained into it?

Before you go

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. Your fine-tuned model is hosted on OpenAI at no ongoing cost. You only pay when you send it inference requests (fractions of a cent per request at gpt-4o-mini rates).

Resources you used:

  • Fine-tuned GPT-4o Mini model hosted on OpenAI (no ongoing hosting cost).
  • OpenAI API key stored as an environment variable in your terminal session.
  • Python virtual environment and project folder on your local machine.
  • Local Python scripts: test_base_model.py, start_fine_tuning.py, compare_models.py.
  • Training dataset file: training_data.jsonl.
  • Uploaded training file stored in your OpenAI account.

Keep everything running

No action needed. Choose this if you plan to keep experimenting with your fine-tuned model or want to reuse your training data for future projects.

  • Your fine-tuned model stays available on OpenAI indefinitely at no hosting cost. You only pay per inference request.
  • Your local project folder, scripts, and virtual environment remain on your machine.
  • Your API key environment variable will expire when you close your terminal session. Re-export it next time you want to use the scripts.

Pause - I'll come back to this later

Free up local resources while keeping everything recoverable.

  • Deactivate your Python virtual environment by running deactivate in your terminal.
  • Your fine-tuned model remains on OpenAI at no cost. No action needed there.
  • Keep your project folder intact so you can reactivate the virtual environment and pick up where you left off.
  • Save your fine-tuned model ID somewhere safe (it looks like ft:gpt-4o-mini:your-org:suffix:id). You will need it to use the model again.

Delete - I don't want to use this again

Remove all project resources and clean up completely.

  • Delete your fine-tuned model using the OpenAI Python SDK. Run the following command in a Python script or interactive session:
from openai import OpenAI
client = OpenAI()
client.models.delete("ft:gpt-4o-mini:your-org:suffix:id")

What does this command do?

This sends a DELETE request to the OpenAI API to permanently remove your fine-tuned model. Replace the model ID string with your actual fine-tuned model ID from Step 4. This action is irreversible.

  • Delete your local project folder and all its contents (scripts, training data, virtual environment).
  • Your uploaded training file will remain in your OpenAI account. You can delete it from the OpenAI dashboard under Storage if you want a full cleanup.

Mission accomplished

That's a Wrap!

Congratulations! You just trained your first custom AI model. You took a general-purpose language model and taught it to do one specific thing really well: transform messy, casual messages into polished professional emails.

Here's what you learned:

  • Built a JSONL training dataset of 50 hand-crafted examples covering different tones, scenarios, and levels of messiness.
  • Fine-tuned GPT-4o Mini through the OpenAI API, uploading your data, launching a training job, and polling for completion.
  • Compared base model vs. fine-tuned model outputs side by side and tested generalization on messages the model never saw during training.
  • Expanded your model to generate professional subject lines by fine-tuning on top of your already fine-tuned model, learning how to iteratively improve a custom model.

Ready to quiz yourself?