deepset-ai / deepset-ai/haystack

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

Open
#12,604 1 comment 0 reactions 1 assignee View on GitHub

@davidsbatista is already working on this.

Since Sep 14, 2026.

P2
Dominant language
Python
Stars
26.6k
Forks
3.2k
Avg merge
1d 3h
Merged PRs (30d)
194

Description

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 20875c85f)
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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.