Re-Running a Fine-Tune Job on a New Provider's API
10 min read · updated August 11, 2026
Both major hosted fine-tuning APIs take JSONL, one training example per line. They disagree about what goes on the line, where the system instruction lives, and what the assistant’s role is called. The conversion is about forty lines of code, and the interesting part is the three fields that do not convert.
The two line shapes
The OpenAI supervised format wraps a conversation in a messages array, using the same role names as the Chat Completions request body. OpenAI’s fine-tuning documentation defines it:
{"messages":[
{"role":"system","content":"You classify support tickets."},
{"role":"user","content":"My card was declined twice."},
{"role":"assistant","content":"billing"}
]}Google’s supervised tuning format for Gemini models, documented in the Vertex AI tuning reference, uses contents rather than messages, splits each turn into a parts array, calls the model’s turn model rather than assistant, and lifts the system instruction out of the conversation entirely into a sibling systemInstruction object:
{"systemInstruction":{"parts":[{"text":"You classify support tickets."}]},
"contents":[
{"role":"user","parts":[{"text":"My card was declined twice."}]},
{"role":"model","parts":[{"text":"billing"}]}
]}A third shape exists on several open-weights hosts, which accept either an OpenAI-style messages line or a single pre-templated text field containing the whole example with the chat template already applied. If you are targeting one of those, check which of the two it wants before converting, because the pre-templated form pushes responsibility for the special tokens onto you.
Field by field
messages[]maps tocontents[], minus any system turn.role: "user"maps torole: "user". This is the only role name shared by both.role: "assistant"maps torole: "model". Getting this wrong is the single most common cause of a validation failure on upload, because the file is still valid JSONL and the error surfaces as a rejected role rather than as a parse problem.content, a string, maps toparts: [{ text }], an array of objects. The array exists because a part can be an inline image or a file reference on the multimodal path; for text-only tuning it always has one element.role: "system"has no position incontents. It moves to the line-levelsystemInstruction. If you leave it in the array as a third role, it is rejected.
Going the other direction, a systemInstruction becomes the first element of messages with role system. Note the asymmetry: on the Google side every line carries its own system instruction, so a dataset can vary it per example. On the OpenAI side it is a message like any other, and can also vary per example. Both are per-line; it is the placement, not the granularity, that differs.
What has no counterpart
Three things do not survive the conversion, and dropping them silently is how a converted dataset trains a subtly different model from the one you had.
Assistant-message weighting. OpenAI’s format accepts a weight field on an assistant message, set to 0 or 1, to include a turn as context but exclude it from the loss. This matters for multi-turn examples where only the final assistant turn is the thing you want learned. There is no equivalent flag in the Vertex supervised format. If your dataset uses it, you have a real decision: drop the unweighted turns from the example entirely, changing the conversational context the model sees, or keep them and accept that they are now training targets. Neither is a conversion — both are a change to the dataset, and it should be recorded as one.
Tool-calling examples. Both platforms support tuning on tool use, and both express it differently: an OpenAI line can carry a line-level tools array alongside messages, with assistant turns containing tool_calls and results coming back as role: "tool" messages. The Gemini side expresses function calls and responses as parts within a turn rather than as a distinct role. These map in principle and the mapping is fiddly in practice; verify a converted tool example round-trips before converting ten thousand of them.
Token budgets. Each platform truncates or rejects examples over a maximum token length, and the maxima differ — as does the tokenizer that measures them. An example that fitted comfortably before conversion can be over the limit after, without a character having changed. This is the same mechanism described in why your token count changed, showing up in an unexpected place.
The conversion
Converting OpenAI-shaped lines to Vertex-shaped lines, with the weighting decision made explicit rather than implicit:
import json, sys
DROP_UNWEIGHTED = True # the decision from the section above
for line in sys.stdin:
ex = json.loads(line)
system, contents = None, []
for m in ex["messages"]:
if m["role"] == "system":
system = m["content"]
continue
if m["role"] == "assistant" and m.get("weight") == 0 and DROP_UNWEIGHTED:
continue
role = "model" if m["role"] == "assistant" else m["role"]
contents.append({"role": role, "parts": [{"text": m["content"]}]})
out = {"contents": contents}
if system:
out["systemInstruction"] = {"parts": [{"text": system}]}
print(json.dumps(out, ensure_ascii=False))ensure_ascii=False is not cosmetic. Escaping non-ASCII to \\u sequences is valid JSON and both platforms accept it, but it makes every subsequent inspection of the file unreadable for any dataset that is not English, and it hides encoding damage that you want to see.
Submitting the job
- Convert a ten-line slice first and eyeball every line. Role names and system placement are the two things to check by reading, not by trusting the script.
- Validate the whole file locally: every line parses as JSON, every line has at least one user turn and one model turn, the last turn is the model’s, and no turn has empty text. Most upload rejections are one of those four.
- Hold out a validation split before upload rather than asking the platform to split for you, so that the same held-out examples score both the old and the new model. A platform-chosen split makes the two scores incomparable.
- Upload and start the job. On the OpenAI side that is a Files API upload with
purposeset tofine-tune, then a fine-tuning job referencing the returned file id. On Vertex it is a Cloud Storage URI passed to a tuning job. Both are asynchronous and both give you a job you poll. - Start with the platform default hyperparameters on the first run, not with the numbers you tuned on the old platform. The defaults encode what that platform thinks is reasonable for your dataset size, and they are the right baseline against which to judge your carried-over values. See what epochs and batch size actually control.
- Score the new model on the held-out split and compare to the incumbent’s score on the same split. Only then cut traffic.