Skip to content

Commit 2e5cf93

Browse files
Yinhan-Luclaude
andauthored
fix: extract system messages from Responses API input into instructions (#53)
The upstream Codex Responses API rejects `role: "system"` messages in the input array. This causes a 400 error when clients (e.g., langchain-openai with use_responses_api=True) include SystemMessage in the input. Modify `_normalize_input_messages` to extract system and developer messages from the input array and merge them into the `instructions` field before forwarding upstream. This matches the behavior already implemented in the Chat Completions → Responses API converter. Fixes #52 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 39dc5f8 commit 2e5cf93

2 files changed

Lines changed: 107 additions & 10 deletions

File tree

ccproxy/plugins/codex/adapter.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -647,22 +647,38 @@ def _normalize_input_messages(self, data: dict[str, Any]) -> dict[str, Any]:
647647
return data
648648

649649
normalized_items: list[Any] = []
650+
system_segments: list[str] = []
650651
for item in input_items:
651-
if (
652-
isinstance(item, dict)
653-
and "type" not in item
654-
and "role" in item
655-
and "content" in item
656-
):
657-
normalized_item = dict(item)
658-
normalized_item["type"] = "message"
659-
normalized_items.append(normalized_item)
660-
continue
652+
if isinstance(item, dict) and "role" in item and "content" in item:
653+
role = item.get("role", "")
654+
# Extract system/developer messages into instructions
655+
# so they are not rejected by the upstream Responses API.
656+
if role in ("system", "developer"):
657+
content = item.get("content")
658+
if isinstance(content, str) and content.strip():
659+
system_segments.append(content.strip())
660+
continue
661+
662+
if "type" not in item:
663+
normalized_item = dict(item)
664+
normalized_item["type"] = "message"
665+
normalized_items.append(normalized_item)
666+
continue
661667

662668
normalized_items.append(item)
663669

664670
result = dict(data)
665671
result["input"] = normalized_items
672+
673+
# Merge extracted system messages into the instructions field
674+
if system_segments:
675+
existing = result.get("instructions")
676+
parts = []
677+
if isinstance(existing, str) and existing.strip():
678+
parts.append(existing.strip())
679+
parts.extend(system_segments)
680+
result["instructions"] = "\n\n".join(parts)
681+
666682
return result
667683

668684
def _request_body_is_encoded(self, headers: dict[str, str]) -> bool:

tests/plugins/codex/unit/test_adapter.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,87 @@ async def test_prepare_provider_request_keeps_msaf_reasoning_when_detection_disa
441441
assert "temperature" not in result_data
442442
assert "max_tokens" not in result_data
443443

444+
@pytest.mark.asyncio
445+
async def test_normalize_input_extracts_system_messages_to_instructions(
446+
self, adapter_with_disabled_detection: CodexAdapter
447+
) -> None:
448+
"""System messages in input should be extracted into instructions.
449+
450+
The upstream Codex Responses API rejects role: system in the input
451+
array. _normalize_input_messages must move them to the instructions
452+
field so the request is accepted.
453+
"""
454+
body = json.dumps(
455+
{
456+
"model": "gpt-5",
457+
"input": [
458+
{"role": "system", "content": "You are a helpful assistant"},
459+
{"role": "user", "content": "Hello"},
460+
],
461+
}
462+
).encode()
463+
464+
result_body, _ = await adapter_with_disabled_detection.prepare_provider_request(
465+
body, {}, "/responses"
466+
)
467+
result_data = json.loads(result_body.decode())
468+
469+
# System message should be moved to instructions
470+
assert result_data["instructions"] == "You are a helpful assistant"
471+
# Only the user message should remain in input
472+
assert len(result_data["input"]) == 1
473+
assert result_data["input"][0]["role"] == "user"
474+
475+
@pytest.mark.asyncio
476+
async def test_normalize_input_merges_system_with_existing_instructions(
477+
self, adapter_with_disabled_detection: CodexAdapter
478+
) -> None:
479+
"""System messages should be appended to existing instructions."""
480+
body = json.dumps(
481+
{
482+
"model": "gpt-5",
483+
"instructions": "Existing instructions",
484+
"input": [
485+
{"role": "system", "content": "Extra system context"},
486+
{"role": "user", "content": "Hello"},
487+
],
488+
}
489+
).encode()
490+
491+
result_body, _ = await adapter_with_disabled_detection.prepare_provider_request(
492+
body, {}, "/responses"
493+
)
494+
result_data = json.loads(result_body.decode())
495+
496+
assert (
497+
result_data["instructions"]
498+
== "Existing instructions\n\nExtra system context"
499+
)
500+
assert len(result_data["input"]) == 1
501+
502+
@pytest.mark.asyncio
503+
async def test_normalize_input_extracts_developer_messages(
504+
self, adapter_with_disabled_detection: CodexAdapter
505+
) -> None:
506+
"""Developer role messages should also be extracted to instructions."""
507+
body = json.dumps(
508+
{
509+
"model": "gpt-5",
510+
"input": [
511+
{"role": "developer", "content": "Developer instructions"},
512+
{"role": "user", "content": "Hello"},
513+
],
514+
}
515+
).encode()
516+
517+
result_body, _ = await adapter_with_disabled_detection.prepare_provider_request(
518+
body, {}, "/responses"
519+
)
520+
result_data = json.loads(result_body.decode())
521+
522+
assert result_data["instructions"] == "Developer instructions"
523+
assert len(result_data["input"]) == 1
524+
444525
@pytest.mark.asyncio
445526
async def test_process_provider_response(self, adapter: CodexAdapter) -> None:
446527
"""Test response processing and format conversion."""

0 commit comments

Comments
 (0)