Inflection AI developer on retainer: consecutive human turns in messages[] raise HTTP 400: Invalid conversation format — context injection sends 3 human turns before first assistant response — 100% of multi-turn sessions fail immediately for 3 weeks — Inflection AI developer on monthly retainer

requests 2.31.0 • Python 3.11 • Inflection AI API (Pi model) • context injection sends 3 consecutive human turns • Inflection requires strict alternating human/assistant format • HTTP 400 on every multi-turn session • 3 weeks • fix: merge consecutive human messages into one • 1.5h retainer engagement

Eastfield Consumer AI Ltd. integrated the Inflection AI API to power a Pi-driven product assistant chatbot in July 2026. Single-turn requests — one human message, no history — worked perfectly in QA. In production, every multi-turn conversation session failed immediately with HTTP 400 Bad Request. The error body was clear: Invalid conversation format: consecutive human turns not allowed. After three weeks of investigating API keys, session IDs, and message encoding — but never examining the messages array construction itself — they engaged an Inflection AI retainer developer.

The session initializer at the time of the engagement:

# chatbot_session.py
# Eastfield Consumer AI Ltd.
# requests 2.31.0, Python 3.11
# Inflection AI API, Pi model

import requests
import os

INFLECTION_API_URL = "https://layerapi.com/chat"
INFLECTION_API_KEY = os.environ["INFLECTION_API_KEY"]

def start_session(user_profile: dict, product_catalog: str, user_message: str) -> str:
    """
    Start a new Pi conversation session with context injection.
    Injects user profile and product catalog before the user's first message.
    """
    messages = [
        {
            "role": "human",
            "content": f"My profile: {user_profile['name']}, {user_profile['preferences']}",
        },
        # ↑ human turn 1: user profile context

        {
            "role": "human",
            "content": f"Available products: {product_catalog}",
        },
        # ↑ human turn 2: product catalog context
        # TWO consecutive human turns so far — already violates Inflection's rule

        {
            "role": "human",
            "content": user_message,
        },
        # ↑ human turn 3: the user's actual first message
        # THREE consecutive human turns total
        # Inflection API requires: human → assistant → human → assistant → ...
        # This messages array has: human → human → human
        # HTTP 400: Invalid conversation format: consecutive human turns not allowed
    ]

    response = requests.post(
        INFLECTION_API_URL,
        headers={
            "Authorization": f"Bearer {INFLECTION_API_KEY}",
            "Content-Type": "application/json",
        },
        json={"model": "inflection_3_pi", "messages": messages},
        timeout=30,
    )
    response.raise_for_status()
    # ↑ raise_for_status() raises HTTPError on HTTP 400
    # Every multi-turn session fails here. 0 Pi responses generated.
    return response.json()["choices"][0]["message"]["content"]

# Single-turn requests (1 human message, no history) succeed:
# messages=[{"role": "human", "content": user_message}]
# → HTTP 200, valid Pi response
# That is why QA passed — QA used single-turn test requests

# 3 weeks of failed debugging:
# Check 1: API key validity — key valid, single-turn calls succeed with it
# Check 2: Session ID handling — no session ID used, stateless API, not the issue
# Check 3: Message content encoding — UTF-8, no special characters, correct
# Check 4: Inflection account quota — not exceeded
# Check 5: Network / firewall — single-turn calls work, not a network issue
# Never checked: messages array structure for consecutive same-role turns

Why Inflection enforces strict alternation

The Inflection API for the Pi model is designed around natural dialogue: one speaker talks, the other responds. Pi is a conversational companion model, and its architecture and training assume that conversation turns alternate strictly between human and assistant. The API enforces this assumption at the request validation layer: it inspects the messages array before routing the request to the model and returns HTTP 400 immediately if consecutive messages share the same role.

This differs from the OpenAI and Anthropic APIs, which are more permissive about message ordering. Both accept consecutive user messages and consecutive assistant messages without error. A developer who builds against OpenAI first and then integrates Inflection can have a latent formatting bug that was silently accepted by OpenAI but causes 100% failure on Inflection.

# Diagnosing the consecutive-role violation

import requests
import os

INFLECTION_API_URL = "https://layerapi.com/chat"
INFLECTION_API_KEY = os.environ["INFLECTION_API_KEY"]

headers = {
    "Authorization": f"Bearer {INFLECTION_API_KEY}",
    "Content-Type": "application/json",
}

# Test 1: single human turn — succeeds
resp = requests.post(INFLECTION_API_URL, headers=headers, json={
    "model": "inflection_3_pi",
    "messages": [{"role": "human", "content": "Hello"}]
})
print(resp.status_code, resp.json()["choices"][0]["message"]["content"])
# 200  "Hello! How can I help you today?"

# Test 2: two consecutive human turns — fails
resp = requests.post(INFLECTION_API_URL, headers=headers, json={
    "model": "inflection_3_pi",
    "messages": [
        {"role": "human", "content": "Context: I prefer casual tone"},
        {"role": "human", "content": "Hello"},
    ]
})
print(resp.status_code, resp.json())
# 400  {'error': 'Invalid conversation format: consecutive human turns not allowed'}

# Test 3: alternating human/assistant — succeeds
resp = requests.post(INFLECTION_API_URL, headers=headers, json={
    "model": "inflection_3_pi",
    "messages": [
        {"role": "human", "content": "Hello"},
        {"role": "assistant", "content": "Hi there! How can I help?"},
        {"role": "human", "content": "What products do you have?"},
    ]
})
print(resp.status_code)
# 200

# The diagnosis: count consecutive same-role messages in the failing array
def find_consecutive_role_violations(messages):
    violations = []
    for i in range(1, len(messages)):
        if messages[i]["role"] == messages[i-1]["role"]:
            violations.append((i-1, i, messages[i]["role"]))
    return violations

# Applied to the production messages array:
violations = find_consecutive_role_violations([
    {"role": "human", "content": "My profile: Alice, prefers casual"},
    {"role": "human", "content": "Available products: Widget A, Widget B"},
    {"role": "human", "content": "What do you recommend?"},
])
print(violations)
# [(0, 1, 'human'), (1, 2, 'human')]
# Two violations — messages 0&1 and messages 1&2 are both consecutive human turns

The fix: merge consecutive human messages

# chatbot_session.py — FIXED
# requests 2.31.0, Python 3.11
# Inflection AI API, Pi model

import requests
import os

INFLECTION_API_URL = "https://layerapi.com/chat"
INFLECTION_API_KEY = os.environ["INFLECTION_API_KEY"]


def validate_conversation(messages: list[dict]) -> None:
    """Raise ValueError if messages array contains consecutive same-role turns."""
    for i in range(1, len(messages)):
        if messages[i]["role"] == messages[i - 1]["role"]:
            raise ValueError(
                f"Invalid conversation format: messages[{i-1}] and messages[{i}] "
                f"both have role='{messages[i]['role']}'. "
                f"Inflection API requires strict alternating human/assistant turns. "
                f"Merge consecutive '{messages[i]['role']}' messages into one."
            )


def merge_consecutive_same_role(messages: list[dict]) -> list[dict]:
    """Merge consecutive messages with the same role into a single message."""
    if not messages:
        return messages
    merged = [messages[0].copy()]
    for msg in messages[1:]:
        if msg["role"] == merged[-1]["role"]:
            # Same role as previous — merge content with section separator
            merged[-1] = {
                "role": merged[-1]["role"],
                "content": merged[-1]["content"] + "\n\n" + msg["content"],
            }
        else:
            merged.append(msg.copy())
    return merged


def start_session(user_profile: dict, product_catalog: str, user_message: str) -> str:
    """
    Start a new Pi conversation session with context injection.
    Context is merged into a single human message to satisfy Inflection's
    strict alternating turn requirement.
    """
    # Merge all context + user message into a single human turn
    combined_human_content = (
        f"[My profile]: {user_profile['name']}, {user_profile['preferences']}\n\n"
        f"[Available products]: {product_catalog}\n\n"
        f"[My question]: {user_message}"
    )
    # ↑ Three pieces of context that were previously three separate human turns
    # are now a single human turn with labeled sections.
    # Pi receives the same information — it just arrives as one structured message
    # instead of three consecutive messages.

    messages = [
        {"role": "human", "content": combined_human_content},
    ]

    # Validate before sending — catches regressions where consecutive turns
    # are accidentally re-introduced (e.g., from a history store that doesn't
    # enforce alternation)
    validate_conversation(messages)

    response = requests.post(
        INFLECTION_API_URL,
        headers={
            "Authorization": f"Bearer {INFLECTION_API_KEY}",
            "Content-Type": "application/json",
        },
        json={"model": "inflection_3_pi", "messages": messages},
        timeout=30,
    )
    response.raise_for_status()
    return response.json()["choices"][0]["message"]["content"]


def continue_session(history: list[dict], user_message: str) -> str:
    """
    Continue an existing Pi conversation.
    Normalizes history loaded from storage before sending to Inflection.
    """
    # history loaded from database may have consecutive roles if stored before
    # validation was added — normalize it
    normalized_history = merge_consecutive_same_role(history)
    normalized_history.append({"role": "human", "content": user_message})
    validate_conversation(normalized_history)

    response = requests.post(
        INFLECTION_API_URL,
        headers={
            "Authorization": f"Bearer {INFLECTION_API_KEY}",
            "Content-Type": "application/json",
        },
        json={"model": "inflection_3_pi", "messages": normalized_history},
        timeout=30,
    )
    response.raise_for_status()
    assistant_reply = response.json()["choices"][0]["message"]["content"]

    # Return updated history for storage
    normalized_history.append({"role": "assistant", "content": assistant_reply})
    return assistant_reply, normalized_history


# Alternative fix: placeholder assistant turn between context and user message
# Use this when the context sections must remain separate in the history
# (e.g., for audit or context attribution purposes)
def start_session_with_placeholder(
    user_profile: dict, product_catalog: str, user_message: str
) -> str:
    messages = [
        {
            "role": "human",
            "content": f"[User Profile]: {user_profile['name']}, {user_profile['preferences']}"
        },
        {
            "role": "assistant",
            "content": "Understood. I have your profile."
            # ↑ Minimal placeholder acknowledgment between context injections
            # Breaks the consecutive-human-turn violation
        },
        {
            "role": "human",
            "content": f"[Product Catalog]: {product_catalog}"
        },
        {
            "role": "assistant",
            "content": "Got it. I have the product catalog."
        },
        {
            "role": "human",
            "content": user_message,
        },
    ]
    validate_conversation(messages)  # should pass — alternating roles

    response = requests.post(
        INFLECTION_API_URL,
        headers={
            "Authorization": f"Bearer {INFLECTION_API_KEY}",
            "Content-Type": "application/json",
        },
        json={"model": "inflection_3_pi", "messages": messages},
        timeout=30,
    )
    response.raise_for_status()
    return response.json()["choices"][0]["message"]["content"]

Validation

# Pipeline validation (October 30 2026)
# 100 simulated multi-turn sessions

# Before fix (3 consecutive human turns at session start):
# HTTP status on first multi-turn call: 400 100% of the time
# Error: "Invalid conversation format: consecutive human turns not allowed"
# Single-turn QA tests: 100% success (no consecutive-role violation)
# Multi-turn production sessions: 0% success (100% HTTP 400)

# After fix (merged context into single human turn):
# HTTP status on first multi-turn call: 200 100% of the time (100/100)
# Pi response quality: equivalent to 3-turn context injection
#   (Pi correctly references user profile and product catalog in responses)
# Validation function: correctly raises ValueError on test arrays with
#   consecutive same-role messages before any API call is made
# History normalization: merge_consecutive_same_role() correctly merges
#   stored histories from pre-fix sessions

# Response quality comparison (domain evaluation, 50 sessions):
# Before (when single-turn worked): response correctly personalized for user
# After (merged context single turn): response correctly personalized for user
#   Pi interprets section-labeled merged content as intended
#   "[My profile]: Alice..." and "[My question]: ..." understood correctly

# Pre-call validation test:
validate_conversation([
    {"role": "human", "content": "A"},
    {"role": "assistant", "content": "B"},
    {"role": "human", "content": "C"},
])
# No error — valid alternating conversation

validate_conversation([
    {"role": "human", "content": "A"},
    {"role": "human", "content": "B"},
])
# ValueError: Invalid conversation format: messages[0] and messages[1]
# both have role='human'. Merge consecutive 'human' messages into one.

Related retainer posts


Track retainer hours without a client portal

HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.

See pricing →