Hacktoberfest 2026: le issue che i maintainer hanno segnato per ottobre, aperte e adatte ai principianti. Sfoglia le issue Hacktoberfest

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

Chiusa
#12,604 2 commenti 0 reazioni 1 assegnatario Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

@sjrl ci sta già lavorando.

Dal 27/9/2026.

Valutazione

Questa issue non è ancora stata valutata.

Descrizione

P3

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.

Lingua principale
Python
Stelle
26.8k
Fork
3.3k
Merge medio
1g 6h
PR unite (30g)
251

Preparare l'ambiente

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di deepset-ai/haystack

Tutte le issue di deepset-ai/haystack

Issue simili

Altre issue su Python

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.