Skip to content

ChatMessage.from_openai_dict_format silently collapses OpenAI's 'developer' role into 'system' #12604

Description

@Harsh23Kashyap

Bug: ChatMessage.from_openai_dict_format / to_openai_dict_format silently collapses the OpenAI developer role into system

Haystack version: 3.1.0rc0 (upstream 20875c85f)
Affected code: ChatMessage.from_openai_dict_format, ChatMessage.to_openai_dict_format, ChatMessage.from_system, ChatRole enum in haystack/dataclasses/chat_message.py
Reach: public API helper used by every OpenAI-compatible chat generator (OpenAIChatGenerator, OpenAIResponsesChatGenerator, plus any subclass or custom chat generator that round-trips messages through this pair).

Problem

OpenAI's Chat Completions API distinguishes between the system and developer roles. Both were added in 2024; the practical difference is that system messages can be overridden by end users in ChatGPT while developer messages cannot. Most production prompts that use prompt caching or that want a non-overridable system prompt now use the developer role. Haystack's _validate_openai_message accepts both (if role not in ["assistant", "user", "system", "developer", "tool"]: raise), but the deserializer maps both system and developer to ChatRole.SYSTEM and the serializer always emits "system" for any ChatRole.SYSTEM message. A message that arrived as "role": "developer" round-trips through Haystack as "role": "system".

The role collapse is silent. _validate_openai_message happily accepts "developer", the deserializer silently stores it as ChatRole.SYSTEM, the serializer silently re-emits it as "system". A user who sends a developer prompt to the OpenAI API and then re-sends the same message history on the next turn has lost the role distinction in transit.

haystack/dataclasses/chat_message.py:

# _validate_openai_message accepts both:
if role not in ["assistant", "user", "system", "developer", "tool"]:
    raise ValueError(f"Unsupported role: {role}")

# but from_openai_dict_format maps them to the same enum:
if role in ["system", "developer"]:
    return cls.from_system(text=content, name=name)   # ChatRole.SYSTEM
# to_openai_dict_format cannot emit "developer" because ChatRole has no entry for it:
class ChatRole(str, Enum):
    USER = "user"
    SYSTEM = "system"
    ASSISTANT = "assistant"
    TOOL = "tool"
# ChatMessage.from_system always uses SYSTEM:
@classmethod
def from_system(cls, text, meta=None, name=None):
    return cls(_role=ChatRole.SYSTEM, _content=[TextContent(text=text)], _meta=meta or {}, _name=name)

Why this matters

  1. Public API contract. from_openai_dict_format / to_openai_dict_format are documented as a round-trip for OpenAI's wire format. The validator accepts five roles, the deserializer accepts five roles, the serializer emits only four. A round-trip drops one of the five valid inputs.
  2. Reach is concrete. A user with a prompt that uses the developer role (the recommended role for non-overridable system prompts since 2024) hits this the first time they round-trip a multi-turn conversation. The OpenAI API returns the message in its next response as a developer role; if the user persists the conversation to disk and rehydrates it via ChatMessage.from_openai_dict_format (e.g. across Pipeline.dump / Pipeline.load, or just by re-sending the same list of messages on the next call), the role is lost.
  3. Other roles are faithfully round-tripped. user and tool both round-trip correctly. assistant round-trips correctly. The asymmetry for system vs developer is a gap, not a deliberate design.
  4. No internal abstraction makes this unreachable. There is no ChatRole.DEVELOPER enum entry, but the public _validate_openai_message accepts the string "developer" without comment, which makes the gap look like a support promise rather than a gap. A user writing a custom chat generator that round-trips OpenAI messages has no signal that developer is a one-way street.

Reproducer (verified locally against upstream/main 20875c8)

from haystack.dataclasses import ChatMessage

# Round-trip a "developer" message:
m = ChatMessage.from_openai_dict_format({"role": "developer", "content": "x"})
print(m._role)            # ChatRole.SYSTEM  -- role collapsed
print(m.to_openai_dict_format()["role"])  # "system"  -- round-trip lost the role

# Compare to "system" which round-trips correctly:
m2 = ChatMessage.from_openai_dict_format({"role": "system", "content": "x"})
print(m2._role)           # ChatRole.SYSTEM
print(m2.to_openai_dict_format()["role"])  # "system"

# The two input roles become indistinguishable after round-trip:
for in_role in ("system", "developer"):
    msg = ChatMessage.from_openai_dict_format({"role": in_role, "content": "x"})
    print(f"input={in_role!r:12} -> round-trip role={msg.to_openai_dict_format()['role']!r}")
# input='system'     -> round-trip role='system'
# input='developer'  -> round-trip role='system'  (lost)

Proposed fix

Add a DEVELOPER = "developer" entry to the ChatRole enum, a from_developer classmethod mirroring from_system, and update the deserializer to map role == "developer" to ChatRole.DEVELOPER. The serializer will then automatically emit "developer" for any ChatRole.DEVELOPER message (because the serializer is self._role.value).

class ChatRole(str, Enum):
    USER = "user"
    SYSTEM = "system"
    DEVELOPER = "developer"   # NEW
    ASSISTANT = "assistant"
    TOOL = "tool"


@classmethod
def from_developer(cls, text, meta=None, name=None):    # NEW
    return cls(_role=ChatRole.DEVELOPER, _content=[TextContent(text=text)], _meta=meta or {}, _name=name)


# in from_openai_dict_format:
if role == "system":
    return cls.from_system(text=content, name=name)
if role == "developer":     # NEW branch
    return cls.from_developer(text=content, name=name)

_validate_openai_message already accepts "developer", so no change is needed there. The ChatMessage(role=self._role.value) path used in to_openai_dict_format already emits the new value as "developer" for any ChatRole.DEVELOPER message.

Suggested regression tests

def test_developer_role_round_trip_preserved(self):
    """from_openai then to_openai must preserve the 'developer' role, not collapse it to 'system'."""
    m = ChatMessage.from_openai_dict_format({"role": "developer", "content": "x"})
    assert m._role == ChatRole.DEVELOPER
    assert m.to_openai_dict_format()["role"] == "developer"

def test_from_developer_classmethod(self):
    m = ChatMessage.from_developer(text="x")
    assert m._role == ChatRole.DEVELOPER
    assert m.to_openai_dict_format()["role"] == "developer"

def test_system_and_developer_messages_are_distinct(self):
    """The two roles should not be silently collapsed into one."""
    s = ChatMessage.from_openai_dict_format({"role": "system", "content": "x"})
    d = ChatMessage.from_openai_dict_format({"role": "developer", "content": "x"})
    assert s._role != d._role
    assert s.to_openai_dict_format()["role"] != d.to_openai_dict_format()["role"]

Scope

Single file (haystack/dataclasses/chat_message.py). 3-line enum entry, 4-line classmethod, 1-line deserializer branch. No public API removal; additive change. No backward-compat concern: any user currently relying on the buggy developer -> system collapse was already losing the role distinction.

Alternatives considered

  • Document the limitation in the docstring ("developer is collapsed to system on round-trip"). Records the bug but does not fix it. The role distinction is documented in OpenAI's API; making users handle it in user code rather than at the framework boundary is the wrong place to put the friction.
  • Re-route developer to system at the deserializer only and keep the docstring saying "developer is treated as system". Same as the current bug; explicit endorsement does not fix the gap.
  • Add DEVELOPER to the enum but also keep the existing from_openai_dict_format collapse. Inconsistent and confusing. Better to fix the round-trip end to end.

Risks

Very low. The change is additive on the enum and additive on the constructor. The only behavior change is that a developer input now survives round-trip instead of being silently rewritten to system; any code that today sees system after sending developer will now see developer, which is the documented OpenAI behavior.

Activity

  1. SuhrudhC commented on Sep 10, 2026

    @SuhrudhC

    Hi — I'd like to work on this if it is still open.

    I checked on current main: _validate_openai_message accepts "developer", but from_openai_dict_format maps both "system" and "developer" through from_system (chat_message.py ~831–832), and to_openai_dict_format only emits "system" for ChatRole.SYSTEM. Round-tripping silently collapses the role.

    Plan:

    1. Add ChatRole.DEVELOPER (and a from_developer constructor mirroring from_system).
    2. Deserialize "developer" to that role; serialize it back as "developer".
    3. Keep "system" behavior unchanged.
    4. Add unit tests for deserialize → serialize round-trip for both roles, plus a regression that a developer message does not become system.

    Because this touches the public ChatRole / ChatMessage API, I wanted to confirm the approach before opening a PR. Could a maintainer assign this to me if that sounds right?

  2. added
    P2Medium priority, add to the next sprint if no P1 available
    on Sep 14, 2026
  3. added
    P3Low priority, leave it in the backlog
    and removed
    P2Medium priority, add to the next sprint if no P1 available
    on Sep 25, 2026
  4. Jayanth-reflex commented on Sep 27, 2026

    @Jayanth-reflex
    Contributor

    Hi, I'd like to work on this. Before opening a PR I want to check the direction, since there are two ways to do it:

    Option A: new ChatRole.DEVELOPER (what the issue proposes)

    Add DEVELOPER = "developer" and ChatMessage.from_developer().
    from_openai_dict_format maps "developer" to it, and to_openai_dict_format writes it back.
    Downside: every place that checks is_from(ChatRole.SYSTEM) has to decide how to treat it. In core that is Agent (system prompt handling), ChatPromptBuilder (templating) and the compaction hook, and every chat generator in haystack-core-integrations that pulls out system messages (Anthropic, Bedrock, Google, and others) would need to handle the new role or it would reach those APIs as an unknown role.

    Option B: keep ChatRole.SYSTEM, remember the original role

    from_openai_dict_format still returns a system message but records that it came in as "developer" (for example in meta), and to_openai_dict_format writes "developer" back when that is set.
    The round trip is fixed and nothing else changes. The distinction is only visible to OpenAI-format serialization.
    I lean towards B because it fixes the round trip without touching integrations, but A is cleaner if you want developer to be a first-class role. Which would you prefer? Happy to open the PR once you confirm.

  5. sjrl commented on Sep 28, 2026

    @sjrl
    Contributor

    Closing as won't do see comment #12991 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P3Low priority, leave it in the backlog

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions