deepset-ai / deepset-ai/haystack
ChatMessage.from_openai_dict_format silently collapses OpenAI's 'developer' role into 'system'
@davidsbatista is already working on this.
Since Sep 14, 2026.
- 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
- Public API contract.
from_openai_dict_format/to_openai_dict_formatare 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. - Reach is concrete. A user with a prompt that uses the
developerrole (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 adeveloperrole; if the user persists the conversation to disk and rehydrates it viaChatMessage.from_openai_dict_format(e.g. acrossPipeline.dump/Pipeline.load, or just by re-sending the same list of messages on the next call), the role is lost. - Other roles are faithfully round-tripped.
userandtoolboth round-trip correctly.assistantround-trips correctly. The asymmetry forsystemvsdeveloperis a gap, not a deliberate design. - No internal abstraction makes this unreachable. There is no
ChatRole.DEVELOPERenum entry, but the public_validate_openai_messageaccepts 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 thatdeveloperis 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 ("
developeris collapsed tosystemon 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
developertosystemat 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
DEVELOPERto the enum but also keep the existingfrom_openai_dict_formatcollapse. 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.