If you've ever wired up multi-turn chat with the Gemini API, there's a good chance you've stared at this line in your logs: google.api_core.exceptions.InvalidArgument: 400 Please ensure that multiturn requests alternate between user and model. I once spent the better part of a day on it while migrating a chatbot to Vertex AI, only to find the literal cause was nothing close to what the message implied.
The frustrating part is that the same error gets thrown for several very different mistakes — wrong role strings, malformed Function Calling history, even a stray system message — so reading the message literally sends you down the wrong rabbit hole. This article walks through the cases I've actually hit in production, with the smallest fix for each.
What Gemini Actually Requires
Two rules are non-negotiable on the generateContent endpoint. First, every entry in contents must use either role: "user" or role: "model", and the two must strictly alternate. Second, the array must end with a user turn — Gemini will not accept a trailing model message and ask itself to continue. Anything that isn't user or model (most commonly assistant carried over from OpenAI code) gets rejected on the spot.
It helps to remember that this contract is much stricter than what you might be used to from OpenAI-compatible APIs. Gemini does not silently coalesce same-role messages, and it does not accept a system role inside the conversation array. Once you internalize the user-led, strictly-alternating, user-trailing shape, every error in this family becomes a one-minute fix.
Here's the smallest valid example to keep as a mental anchor:
# Minimal working example (Python / google-genai SDK)
from google import genai
client = genai.Client(api_key="YOUR_GEMINI_API_KEY")
contents = [
{"role": "user", "parts": [{"text": "What's the weather in Tokyo?"}]},
{"role": "model", "parts": [{"text": "Tokyo is sunny today."}]},
{"role": "user", "parts": [{"text": "How about tomorrow?"}]}, # must end with user
]
response = client.models.generate_content(
model="gemini-2.5-pro",
contents=contents,
)
print(response.text)Anchor on that shape, and the cases below get much easier to spot.
Case 1: Two Same-Role Turns in a Row (the Most Common One)
The single most frequent cause I see is hard-coding a "greeting" message as a model turn at the start of history, then pushing the first real user input. As soon as the model replies, you accidentally end up with model → user → model → model — and the request blows up.
# Broken: roles repeat
contents = [
{"role": "model", "parts": [{"text": "Hi! How can I help?"}]}, # starts with model
{"role": "user", "parts": [{"text": "Hello"}]},
{"role": "model", "parts": [{"text": "Sure"}]},
{"role": "model", "parts": [{"text": "What can I do for you?"}]}, # repeats
]The cleanest fix is to move that opening line into system_instruction and keep contents strictly user-led. I prefer this approach in every project I maintain — it's easier to evolve the persona later without rewriting history merging logic. As a bonus, the system instruction is reapplied on every turn automatically, so you never lose the persona partway through a long conversation.
# Fixed: the greeting lives in system_instruction
config = genai.types.GenerateContentConfig(
system_instruction="You are a friendly assistant. Greet the user in your first reply."
)
contents = [{"role": "user", "parts": [{"text": "Hello"}]}]
response = client.models.generate_content(
model="gemini-2.5-pro",
contents=contents,
config=config,
)If for some reason you really do need consecutive turns from the same speaker (for example, when reconstructing logs that arrived out of order), merge them into a single entry with multiple parts rather than sending separate items. That keeps the alternation rule intact while preserving the original message boundaries.
Case 2: You're Sending assistant (Common When Porting from OpenAI)
If you've ported code from OpenAI Chat Completions, it's easy to leave role: "assistant" in place. Gemini doesn't recognize it, and the resulting error often still says "alternate" — making the real cause hard to spot.
# Broken: OpenAI-style role
contents = [
{"role": "user", "parts": [{"text": "Hi"}]},
{"role": "assistant", "parts": [{"text": "Hello!"}]}, # not valid for Gemini
]When porting, add a small mapping helper at the boundary so the wrong role name never reaches the request body. The same idea applies if you're routing the same conversation between multiple providers — keep a single canonical schema in your domain, and translate at the edge.
ROLE_MAP = {"user": "user", "assistant": "model", "model": "model"}
def to_gemini_contents(messages):
out = []
for m in messages:
role = ROLE_MAP.get(m["role"])
if role is None:
continue # handle "system" separately
out.append({"role": role, "parts": [{"text": m["content"]}]})
return outA quick TypeScript equivalent for Node.js or Edge runtimes looks essentially the same:
type ChatMessage = { role: "user" | "assistant" | "system"; content: string };
type GeminiContent = { role: "user" | "model"; parts: { text: string }[] };
const ROLE_MAP: Record<string, "user" | "model" | undefined> = {
user: "user",
assistant: "model",
model: "model",
};
export function toGeminiContents(messages: ChatMessage[]): GeminiContent[] {
const out: GeminiContent[] = [];
for (const m of messages) {
const role = ROLE_MAP[m.role];
if (!role) continue; // pass system messages to systemInstruction instead
out.push({ role, parts: [{ text: m.content }] });
}
return out;
}Case 3: Function Calling Responses Sent with the Wrong Role
The first time you wire up tools, the role placement for functionResponse is genuinely surprising: Gemini expects the tool's result to live under role: "user", not under a function or tool role. Using either of the latter throws the same alternation error immediately, and because the message points to "user/model" rather than "tool", developers often spend a long time looking in the wrong place.
# Correct: functionResponse goes under role: "user"
from google.genai import types
contents = [
{"role": "user", "parts": [{"text": "Get the weather in Tokyo"}]},
{"role": "model", "parts": [
types.Part(function_call=types.FunctionCall(name="get_weather", args={"city": "Tokyo"}))
]},
{"role": "user", "parts": [
types.Part(function_response=types.FunctionResponse(
name="get_weather",
response={"weather": "sunny", "temp": 22},
))
]},
]The mental model that helped me: from Gemini's perspective, the tool result is "input from outside the model" — and inputs always live under user. This catches a lot of developers off guard, but once you flip your mental schema it's hard to unsee. For a broader checklist when tools won't fire, the Function Calling troubleshooting guide covers schema and config issues that often hide behind this same error.
Case 4: A system Message Smuggled into contents
If your codebase still treats system prompts as the first entry in contents, Gemini reads it as a non-user opening turn and rejects the request. Move it to GenerateContentConfig.system_instruction and the error disappears. This is a subtle one because some compatibility shims silently strip system messages on other providers, so the bug only surfaces once you point your code at Gemini.
# Broken
contents = [
{"role": "system", "parts": [{"text": "You are a polite support agent"}]}, # source of the error
{"role": "user", "parts": [{"text": "I'd like a refund"}]},
]
# Fixed
config = genai.types.GenerateContentConfig(
system_instruction="You are a polite support agent"
)
contents = [{"role": "user", "parts": [{"text": "I'd like a refund"}]}]If you're concatenating multiple system-style instructions (persona, safety policy, tool-use guidelines), join them with newlines and pass the merged string to system_instruction. Gemini accepts long instructions there without issue.
Keep a Validator Around for the Long Run
What pays off most over time is a tiny validator you call right before sending. I keep something like the snippet below in every project, with unit tests covering "two users in a row," "trailing model," and "leftover assistant role" — that combination has caught real regressions whenever I bumped the SDK or merged a teammate's PR.
def validate_contents(contents):
if not contents:
raise ValueError("contents is empty")
if contents[0]["role"] != "user":
raise ValueError("first entry must be 'user'")
if contents[-1]["role"] != "user":
raise ValueError("last entry must be 'user'")
for i in range(len(contents) - 1):
if contents[i]["role"] == contents[i + 1]["role"]:
raise ValueError(
f"roles repeat at index {i} and {i+1}: {contents[i]['role']}"
)
for c in contents:
if c["role"] not in ("user", "model"):
raise ValueError(f"unknown role: {c['role']}")
return TrueIn CI, run this validator against fixture conversations that mimic your worst real-world cases — interleaved tool calls, retries that re-append the same model turn, and edits that delete a user entry without removing its corresponding model reply. Most production regressions in this area happen because someone modifies history mid-flight and ships before catching the broken alternation.
If you'd like to dig deeper into history compression and conversation design, the companion piece Gemini API chat history management pitfalls goes into how to keep multi-turn dialogues stable as they grow.
What to Do Next
In practice this error stops being scary the moment you internalize "user-led, strictly alternating, ends on user, no assistant or system." The single most useful next step you can take today is to drop the validate_contents helper into your project and add unit tests for the four shapes above — repeat-roles, OpenAI's assistant, trailing model, and a stray system entry. Future-you will quietly thank present-you the next time an SDK upgrade rearranges the message format.
If you're running tools heavily, also add a fixture for functionResponse placement so the surprise of "tool results under user" is encoded as a passing test instead of a midnight stack trace.