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