Preserve vision inputs in Codex OAuth proxy

Convert Chat Completions image_url content parts into Responses API input_image parts instead of normalizing multimodal messages down to text. Keep text-only content lists as plain text and add OAuth bridge regression tests for image passthrough.

Alessandro committed May 23, 2026 at 12:02 UTC 4a836940f37213a98d1111cf2f82bca080b0f203
2 files changed +127 -2
plugins/_oauth/helpers/codex.py
+79 -2
@@ -718,12 +718,14 @@ def chat_messages_to_response_body(body: dict[str, Any]) -> dict[str, Any]:
718 continue
719 role = str(message.get("role") or "user")
720 content = message.get("content", "")
721 - text = normalize_message_content(content)
721 if role in {"system", "developer"}:
722 + text = normalize_message_content(content)
723 if text:
724 instructions.append(text)
725 continue
726 - response_input.append({"role": role, "content": text})
726 + response_input.append(
727 + {"role": role, "content": response_message_content(content)}
728 + )
729
730 response_body: dict[str, Any] = {
731 "model": body.get("model") or "gpt-5.2",
@@ -758,6 +760,81 @@ def normalize_message_content(content: Any) -> str:
760 return str(content)
761
762
763 +def response_message_content(content: Any) -> str | list[dict[str, Any]]:
764 + if not isinstance(content, list):
765 + return normalize_message_content(content)
766 +
767 + converted: list[dict[str, Any]] = []
768 + has_media = False
769 + for item in content:
770 + if isinstance(item, str):
771 + if item:
772 + converted.append({"type": "input_text", "text": item})
773 + continue
774 + if not isinstance(item, dict):
775 + continue
776 +
777 + item_type = str(item.get("type") or "").strip()
778 + if item_type == "text":
779 + text = item.get("text")
780 + if isinstance(text, str) and text:
781 + converted.append({"type": "input_text", "text": text})
782 + continue
783 + if item_type == "input_text":
784 + text = item.get("text")
785 + if isinstance(text, str) and text:
786 + converted.append({"type": "input_text", "text": text})
787 + continue
788 + if item_type == "image_url":
789 + image_url = item.get("image_url")
790 + url = ""
791 + detail = item.get("detail")
792 + if isinstance(image_url, dict):
793 + url = str(image_url.get("url") or "").strip()
794 + detail = image_url.get("detail", detail)
795 + elif isinstance(image_url, str):
796 + url = image_url.strip()
797 + if url:
798 + converted.append(
799 + {
800 + "type": "input_image",
801 + "image_url": url,
802 + "detail": str(detail or "auto"),
803 + }
804 + )
805 + has_media = True
806 + continue
807 + if item_type == "input_image":
808 + image_url = item.get("image_url")
809 + file_id = item.get("file_id")
810 + image: dict[str, Any] = {"type": "input_image"}
811 + if isinstance(image_url, str) and image_url.strip():
812 + image["image_url"] = image_url.strip()
813 + if isinstance(file_id, str) and file_id.strip():
814 + image["file_id"] = file_id.strip()
815 + if "image_url" in image or "file_id" in image:
816 + image["detail"] = str(item.get("detail") or "auto")
817 + converted.append(image)
818 + has_media = True
819 + continue
820 +
821 + text = item.get("text")
822 + if isinstance(text, str) and text:
823 + converted.append({"type": "input_text", "text": text})
824 + continue
825 + nested_content = item.get("content")
826 + if isinstance(nested_content, str) and nested_content:
827 + converted.append({"type": "input_text", "text": nested_content})
828 +
829 + if has_media:
830 + return converted
831 + return "\n".join(
832 + part["text"]
833 + for part in converted
834 + if part.get("type") == "input_text" and isinstance(part.get("text"), str)
835 + )
836 +
837 +
838 def response_text(response: dict[str, Any]) -> str:
839 value = response.get("output_text")
840 if isinstance(value, str):
tests/test_oauth_codex.py
+48
@@ -74,6 +74,54 @@ def test_chat_messages_to_response_body_extracts_instructions():
74 assert body["reasoning"] == {"effort": "high"}
75
76
77 +def test_chat_messages_to_response_body_preserves_image_parts_for_responses():
78 + data_url = "data:image/png;base64,abcd"
79 +
80 + body = codex.chat_messages_to_response_body(
81 + {
82 + "model": "gpt-5.5",
83 + "messages": [
84 + {
85 + "role": "user",
86 + "content": [
87 + {"type": "text", "text": "Inspect this screenshot."},
88 + {"type": "image_url", "image_url": {"url": data_url}},
89 + ],
90 + }
91 + ],
92 + }
93 + )
94 +
95 + assert body["input"] == [
96 + {
97 + "role": "user",
98 + "content": [
99 + {"type": "input_text", "text": "Inspect this screenshot."},
100 + {"type": "input_image", "image_url": data_url, "detail": "auto"},
101 + ],
102 + }
103 + ]
104 +
105 +
106 +def test_chat_messages_to_response_body_keeps_text_only_lists_as_text():
107 + body = codex.chat_messages_to_response_body(
108 + {
109 + "model": "gpt-5.5",
110 + "messages": [
111 + {
112 + "role": "user",
113 + "content": [
114 + {"type": "text", "text": "first"},
115 + {"type": "text", "text": "second"},
116 + ],
117 + }
118 + ],
119 + }
120 + )
121 +
122 + assert body["input"] == [{"role": "user", "content": "first\nsecond"}]
123 +
124 +
125 def test_response_text_reads_output_text_or_output_blocks():
126 assert codex.response_text({"output_text": "direct"}) == "direct"
127