codex-api-proxy 0.1.4__tar.gz → 0.1.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/PKG-INFO +43 -2
  2. {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/README.md +42 -1
  3. {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/pyproject.toml +1 -1
  4. {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy/__init__.py +1 -1
  5. codex_api_proxy-0.1.5/src/codex_api_proxy/anthropic_messages.py +257 -0
  6. {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy/main.py +218 -0
  7. {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/PKG-INFO +43 -2
  8. {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/SOURCES.txt +2 -0
  9. codex_api_proxy-0.1.5/tests/test_anthropic_messages.py +167 -0
  10. {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/setup.cfg +0 -0
  11. {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy/auth.py +0 -0
  12. {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy/chat_completions.py +0 -0
  13. {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy/cli.py +0 -0
  14. {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy/config.py +0 -0
  15. {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy/models.py +0 -0
  16. {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy/sse_utils.py +0 -0
  17. {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/dependency_links.txt +0 -0
  18. {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/entry_points.txt +0 -0
  19. {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/requires.txt +0 -0
  20. {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/top_level.txt +0 -0
  21. {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/tests/test_api.py +0 -0
  22. {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/tests/test_auth.py +0 -0
  23. {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/tests/test_chat_completions.py +0 -0
  24. {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/tests/test_cli.py +0 -0
  25. {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/tests/test_config.py +0 -0
  26. {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/tests/test_models.py +0 -0
  27. {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/tests/test_release_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-api-proxy
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: Local OpenAI-compatible HTTP proxy backed by Codex/OpenAI credentials
5
5
  Author: codex-api-proxy contributors
6
6
  License-Expression: MIT
@@ -34,8 +34,9 @@ This package is intentionally narrow:
34
34
 
35
35
  - expose OpenAI-compatible local endpoints for clients such as OpenClaw;
36
36
  - reuse the user's existing Codex/OpenAI login credentials;
37
- - support `/v1/chat/completions`, `/v1/responses`, and `/v1/models`;
37
+ - support `/v1/chat/completions`, `/v1/responses`, `/v1/messages`, and `/v1/models`;
38
38
  - convert Chat Completions to Responses API when using ChatGPT Codex credentials;
39
+ - convert Anthropic-style Messages API text requests to Responses API for local Anthropic-compatible clients;
39
40
  - otherwise transparently forward OpenAI API-key requests to OpenAI's native `/chat/completions`.
40
41
 
41
42
  Out of scope:
@@ -250,6 +251,46 @@ curl -sS http://127.0.0.1:8765/v1/chat/completions \
250
251
 
251
252
  When using ChatGPT Codex credentials, Chat Completions image parts are converted to Responses API `input_image` parts. `/v1/responses` requests are passed through unchanged.
252
253
 
254
+ Anthropic-style Messages API:
255
+
256
+ ```bash
257
+ curl -sS http://127.0.0.1:8765/v1/messages \
258
+ -H 'Content-Type: application/json' \
259
+ -H 'anthropic-version: 2023-06-01' \
260
+ -d '{
261
+ "model": "gpt-5.5",
262
+ "max_tokens": 128,
263
+ "system": "Be brief.",
264
+ "messages": [{"role":"user","content":"Reply with exactly: pong"}]
265
+ }'
266
+ ```
267
+
268
+ Streaming Anthropic-style Messages API:
269
+
270
+ ```bash
271
+ curl -N http://127.0.0.1:8765/v1/messages \
272
+ -H 'Content-Type: application/json' \
273
+ -H 'anthropic-version: 2023-06-01' \
274
+ -d '{
275
+ "model": "gpt-5.5",
276
+ "max_tokens": 128,
277
+ "stream": true,
278
+ "messages": [{"role":"user","content":[{"type":"text","text":"Reply with exactly: pong"}]}]
279
+ }'
280
+ ```
281
+
282
+ For Anthropic-compatible clients such as Claude Code, point the Anthropic base URL at this proxy and use the local API key configured with `--api-key`, if any:
283
+
284
+ ```bash
285
+ ANTHROPIC_BASE_URL=http://127.0.0.1:8765 \
286
+ ANTHROPIC_AUTH_TOKEN=local-secret \
287
+ claude
288
+ ```
289
+
290
+ When local `--api-key` auth is enabled, `/v1/messages` accepts either `Authorization: Bearer <key>` or `x-api-key: <key>` because Anthropic-compatible clients vary in which header they send.
291
+
292
+ The Anthropic compatibility layer currently covers text messages, system prompts, streaming text deltas, and image content blocks. Full Anthropic `tool_use` / `tool_result` round-tripping is not yet implemented.
293
+
253
294
  When `--api-key` is configured:
254
295
 
255
296
  ```bash
@@ -10,8 +10,9 @@ This package is intentionally narrow:
10
10
 
11
11
  - expose OpenAI-compatible local endpoints for clients such as OpenClaw;
12
12
  - reuse the user's existing Codex/OpenAI login credentials;
13
- - support `/v1/chat/completions`, `/v1/responses`, and `/v1/models`;
13
+ - support `/v1/chat/completions`, `/v1/responses`, `/v1/messages`, and `/v1/models`;
14
14
  - convert Chat Completions to Responses API when using ChatGPT Codex credentials;
15
+ - convert Anthropic-style Messages API text requests to Responses API for local Anthropic-compatible clients;
15
16
  - otherwise transparently forward OpenAI API-key requests to OpenAI's native `/chat/completions`.
16
17
 
17
18
  Out of scope:
@@ -226,6 +227,46 @@ curl -sS http://127.0.0.1:8765/v1/chat/completions \
226
227
 
227
228
  When using ChatGPT Codex credentials, Chat Completions image parts are converted to Responses API `input_image` parts. `/v1/responses` requests are passed through unchanged.
228
229
 
230
+ Anthropic-style Messages API:
231
+
232
+ ```bash
233
+ curl -sS http://127.0.0.1:8765/v1/messages \
234
+ -H 'Content-Type: application/json' \
235
+ -H 'anthropic-version: 2023-06-01' \
236
+ -d '{
237
+ "model": "gpt-5.5",
238
+ "max_tokens": 128,
239
+ "system": "Be brief.",
240
+ "messages": [{"role":"user","content":"Reply with exactly: pong"}]
241
+ }'
242
+ ```
243
+
244
+ Streaming Anthropic-style Messages API:
245
+
246
+ ```bash
247
+ curl -N http://127.0.0.1:8765/v1/messages \
248
+ -H 'Content-Type: application/json' \
249
+ -H 'anthropic-version: 2023-06-01' \
250
+ -d '{
251
+ "model": "gpt-5.5",
252
+ "max_tokens": 128,
253
+ "stream": true,
254
+ "messages": [{"role":"user","content":[{"type":"text","text":"Reply with exactly: pong"}]}]
255
+ }'
256
+ ```
257
+
258
+ For Anthropic-compatible clients such as Claude Code, point the Anthropic base URL at this proxy and use the local API key configured with `--api-key`, if any:
259
+
260
+ ```bash
261
+ ANTHROPIC_BASE_URL=http://127.0.0.1:8765 \
262
+ ANTHROPIC_AUTH_TOKEN=local-secret \
263
+ claude
264
+ ```
265
+
266
+ When local `--api-key` auth is enabled, `/v1/messages` accepts either `Authorization: Bearer <key>` or `x-api-key: <key>` because Anthropic-compatible clients vary in which header they send.
267
+
268
+ The Anthropic compatibility layer currently covers text messages, system prompts, streaming text deltas, and image content blocks. Full Anthropic `tool_use` / `tool_result` round-tripping is not yet implemented.
269
+
229
270
  When `--api-key` is configured:
230
271
 
231
272
  ```bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codex-api-proxy"
3
- version = "0.1.4"
3
+ version = "0.1.5"
4
4
  description = "Local OpenAI-compatible HTTP proxy backed by Codex/OpenAI credentials"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """OpenAI-compatible HTTP proxy backed by Codex/OpenAI credentials."""
2
2
 
3
- __version__ = "0.1.4"
3
+ __version__ = "0.1.5"
@@ -0,0 +1,257 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import uuid
5
+ from typing import AsyncIterator
6
+
7
+ from codex_api_proxy.chat_completions import DEFAULT_INSTRUCTIONS
8
+ from codex_api_proxy.chat_completions import apply_response_controls
9
+ from codex_api_proxy.chat_completions import extract_output_text_from_events
10
+ from codex_api_proxy.sse_utils import parse_sse_chunk
11
+ from codex_api_proxy.sse_utils import parse_sse_events
12
+
13
+
14
+ class AnthropicMessageError(ValueError):
15
+ """Raised when an Anthropic Messages request cannot be converted."""
16
+
17
+
18
+ def anthropic_to_responses_request(
19
+ message: dict,
20
+ *,
21
+ default_reasoning_effort: str | None = None,
22
+ default_verbosity: str | None = None,
23
+ default_service_tier: str | None = None,
24
+ ) -> dict:
25
+ model = message.get("model")
26
+ if not isinstance(model, str) or not model.strip():
27
+ raise AnthropicMessageError("model is required")
28
+
29
+ messages = message.get("messages")
30
+ if not isinstance(messages, list) or not messages:
31
+ raise AnthropicMessageError("messages is required")
32
+
33
+ input_items: list[dict] = []
34
+ for item in messages:
35
+ if not isinstance(item, dict):
36
+ continue
37
+ role = item.get("role")
38
+ if role == "user":
39
+ input_items.append(
40
+ {
41
+ "type": "message",
42
+ "role": "user",
43
+ "content": anthropic_content_to_response_content(item.get("content", ""), user=True),
44
+ }
45
+ )
46
+ continue
47
+ if role == "assistant":
48
+ input_items.append(
49
+ {
50
+ "type": "message",
51
+ "role": "assistant",
52
+ "content": anthropic_content_to_response_content(item.get("content", ""), user=False),
53
+ }
54
+ )
55
+
56
+ if not input_items:
57
+ raise AnthropicMessageError("at least one user or assistant message is required")
58
+
59
+ payload = {
60
+ "model": model.strip(),
61
+ "instructions": anthropic_system_to_text(message.get("system")) or DEFAULT_INSTRUCTIONS,
62
+ "input": input_items,
63
+ "stream": True,
64
+ "store": False,
65
+ }
66
+ apply_response_controls(
67
+ payload,
68
+ message,
69
+ default_reasoning_effort=default_reasoning_effort,
70
+ default_verbosity=default_verbosity,
71
+ default_service_tier=default_service_tier,
72
+ )
73
+ return payload
74
+
75
+
76
+ def anthropic_system_to_text(system: object) -> str:
77
+ if isinstance(system, str):
78
+ return system
79
+ return anthropic_content_to_text(system)
80
+
81
+
82
+ def anthropic_content_to_text(content: object) -> str:
83
+ if isinstance(content, str):
84
+ return content
85
+ if not isinstance(content, list):
86
+ return ""
87
+
88
+ parts: list[str] = []
89
+ for item in content:
90
+ if isinstance(item, str):
91
+ parts.append(item)
92
+ continue
93
+ if not isinstance(item, dict):
94
+ continue
95
+ if item.get("type") == "text" and isinstance(item.get("text"), str):
96
+ parts.append(item["text"])
97
+ continue
98
+ if item.get("type") == "tool_result":
99
+ parts.append(anthropic_content_to_text(item.get("content", "")))
100
+ return "".join(parts)
101
+
102
+
103
+ def anthropic_content_to_response_content(content: object, *, user: bool) -> list[dict[str, str]]:
104
+ text_type = "input_text" if user else "output_text"
105
+ if isinstance(content, str):
106
+ return [{"type": text_type, "text": content}]
107
+ if not isinstance(content, list):
108
+ return [{"type": text_type, "text": ""}]
109
+
110
+ parts: list[dict[str, str]] = []
111
+ for item in content:
112
+ if isinstance(item, str):
113
+ parts.append({"type": text_type, "text": item})
114
+ continue
115
+ if not isinstance(item, dict):
116
+ continue
117
+
118
+ part_type = item.get("type")
119
+ if part_type == "text" and isinstance(item.get("text"), str):
120
+ parts.append({"type": text_type, "text": item["text"]})
121
+ continue
122
+ if user and part_type == "image":
123
+ image_part = _anthropic_image_to_response_part(item)
124
+ if image_part:
125
+ parts.append(image_part)
126
+ continue
127
+ if user and part_type == "tool_result":
128
+ tool_text = anthropic_content_to_text(item.get("content", ""))
129
+ if tool_text:
130
+ parts.append({"type": "input_text", "text": tool_text})
131
+
132
+ return parts or [{"type": text_type, "text": ""}]
133
+
134
+
135
+ def _anthropic_image_to_response_part(item: dict) -> dict[str, str] | None:
136
+ source = item.get("source")
137
+ if not isinstance(source, dict):
138
+ return None
139
+
140
+ if source.get("type") == "base64":
141
+ media_type = source.get("media_type")
142
+ data = source.get("data")
143
+ if isinstance(media_type, str) and isinstance(data, str):
144
+ return {"type": "input_image", "image_url": f"data:{media_type};base64,{data}"}
145
+
146
+ if source.get("type") == "url" and isinstance(source.get("url"), str):
147
+ return {"type": "input_image", "image_url": source["url"]}
148
+
149
+ return None
150
+
151
+
152
+ def build_anthropic_message(
153
+ *,
154
+ message_id: str,
155
+ model: str,
156
+ content: str,
157
+ stop_reason: str = "end_turn",
158
+ ) -> dict[str, object]:
159
+ return {
160
+ "id": message_id,
161
+ "type": "message",
162
+ "role": "assistant",
163
+ "model": model,
164
+ "content": [{"type": "text", "text": content}],
165
+ "stop_reason": stop_reason,
166
+ "stop_sequence": None,
167
+ "usage": {"input_tokens": 0, "output_tokens": 0},
168
+ }
169
+
170
+
171
+ def responses_events_to_anthropic_message(events: list[dict], *, model: str) -> dict[str, object]:
172
+ return build_anthropic_message(
173
+ message_id=_message_id_from_events(events),
174
+ model=model,
175
+ content=extract_output_text_from_events(events),
176
+ )
177
+
178
+
179
+ async def stream_anthropic_message_events(upstream_bytes: AsyncIterator[bytes], *, model: str) -> AsyncIterator[bytes]:
180
+ message_id = f"msg_{uuid.uuid4().hex[:24]}"
181
+ yield encode_anthropic_sse(
182
+ "message_start",
183
+ {
184
+ "type": "message_start",
185
+ "message": build_anthropic_message(message_id=message_id, model=model, content="", stop_reason=None),
186
+ },
187
+ )
188
+ yield encode_anthropic_sse(
189
+ "content_block_start",
190
+ {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
191
+ )
192
+
193
+ buffer = bytearray()
194
+ async for chunk in upstream_bytes:
195
+ events, buffer = parse_sse_chunk(chunk, buffer)
196
+ for event in events:
197
+ event_type = event.get("type")
198
+ if event_type == "response.output_text.delta":
199
+ delta = event.get("delta")
200
+ if isinstance(delta, str) and delta:
201
+ yield encode_anthropic_sse(
202
+ "content_block_delta",
203
+ {
204
+ "type": "content_block_delta",
205
+ "index": 0,
206
+ "delta": {"type": "text_delta", "text": delta},
207
+ },
208
+ )
209
+ elif event_type == "response.completed":
210
+ for stop_event in _anthropic_stream_stop_events():
211
+ yield stop_event
212
+ return
213
+ elif event_type == "error":
214
+ message = "upstream responses stream failed"
215
+ error = event.get("error")
216
+ if isinstance(error, dict) and isinstance(error.get("message"), str):
217
+ message = error["message"]
218
+ raise AnthropicMessageError(message)
219
+
220
+ if buffer:
221
+ events = parse_sse_events(buffer.decode("utf-8", errors="replace"))
222
+ if any(event.get("type") == "response.completed" for event in events):
223
+ for stop_event in _anthropic_stream_stop_events():
224
+ yield stop_event
225
+ return
226
+
227
+ for stop_event in _anthropic_stream_stop_events():
228
+ yield stop_event
229
+
230
+
231
+ def encode_anthropic_sse(event: str, payload: dict[str, object]) -> bytes:
232
+ return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n".encode("utf-8")
233
+
234
+
235
+ def _anthropic_stream_stop_events() -> list[bytes]:
236
+ return [
237
+ encode_anthropic_sse("content_block_stop", {"type": "content_block_stop", "index": 0}),
238
+ encode_anthropic_sse(
239
+ "message_delta",
240
+ {
241
+ "type": "message_delta",
242
+ "delta": {"stop_reason": "end_turn", "stop_sequence": None},
243
+ "usage": {"output_tokens": 0},
244
+ },
245
+ ),
246
+ encode_anthropic_sse("message_stop", {"type": "message_stop"}),
247
+ ]
248
+
249
+
250
+ def _message_id_from_events(events: list[dict]) -> str:
251
+ for event in events:
252
+ if event.get("type") != "response.completed":
253
+ continue
254
+ response = event.get("response")
255
+ if isinstance(response, dict) and isinstance(response.get("id"), str):
256
+ return response["id"].replace("resp_", "msg_", 1)
257
+ return f"msg_{uuid.uuid4().hex[:24]}"
@@ -14,6 +14,10 @@ from fastapi.responses import JSONResponse
14
14
  from fastapi.responses import StreamingResponse
15
15
 
16
16
  from codex_api_proxy import __version__
17
+ from codex_api_proxy.anthropic_messages import AnthropicMessageError
18
+ from codex_api_proxy.anthropic_messages import anthropic_to_responses_request
19
+ from codex_api_proxy.anthropic_messages import responses_events_to_anthropic_message
20
+ from codex_api_proxy.anthropic_messages import stream_anthropic_message_events
17
21
  from codex_api_proxy.auth import AuthError
18
22
  from codex_api_proxy.auth import ProxyAuth
19
23
  from codex_api_proxy.auth import load_proxy_auth_auto_refresh
@@ -246,6 +250,91 @@ def create_app(settings: Settings | None = None) -> FastAPI:
246
250
  upstream_client_instance=client,
247
251
  )
248
252
 
253
+ @app.post("/v1/messages")
254
+ @app.post("/messages")
255
+ async def proxy_anthropic_messages(request: Request) -> Response:
256
+ if not _authorized_anthropic(request, settings):
257
+ return _unauthorized()
258
+
259
+ request_id = uuid.uuid4().hex
260
+ app.state.requests_total += 1
261
+ started_at = time.perf_counter()
262
+ body = await request.body()
263
+ try:
264
+ message_body = json.loads(body)
265
+ except json.JSONDecodeError:
266
+ return _counted_error(app, 400, {"error": {"type": "invalid_request_error", "message": "invalid JSON body"}})
267
+ if not isinstance(message_body, dict):
268
+ return _counted_error(
269
+ app,
270
+ 400,
271
+ {"error": {"type": "invalid_request_error", "message": "request body must be a JSON object"}},
272
+ )
273
+
274
+ model = message_body.get("model")
275
+ stream = bool(message_body.get("stream"))
276
+ client = _shared_upstream_client(app, settings)
277
+
278
+ try:
279
+ auth = await load_proxy_auth_auto_refresh(
280
+ upstream_proxy=settings.proxy,
281
+ upstream_client_instance=client,
282
+ )
283
+ except AuthError as exc:
284
+ return _counted_error(app, 503, {"error": {"type": "api_error", "message": str(exc)}})
285
+
286
+ try:
287
+ try:
288
+ responses_payload = anthropic_to_responses_request(
289
+ message_body,
290
+ default_reasoning_effort=settings.effective_reasoning_effort,
291
+ default_verbosity=settings.effective_verbosity,
292
+ default_service_tier=settings.effective_service_tier,
293
+ )
294
+ except AnthropicMessageError as exc:
295
+ return _counted_error(app, 400, {"error": {"type": "invalid_request_error", "message": str(exc)}})
296
+
297
+ responses_bytes = json.dumps(responses_payload).encode("utf-8")
298
+ if stream:
299
+ response = await _forward_anthropic_messages_stream(
300
+ request,
301
+ responses_bytes,
302
+ auth,
303
+ str(model),
304
+ retry_on_unauthorized=True,
305
+ upstream_proxy=settings.proxy,
306
+ upstream_client_instance=client,
307
+ )
308
+ else:
309
+ response = await _forward_anthropic_messages_unary(
310
+ request,
311
+ responses_bytes,
312
+ auth,
313
+ str(model),
314
+ retry_on_unauthorized=True,
315
+ upstream_proxy=settings.proxy,
316
+ upstream_client_instance=client,
317
+ )
318
+ except AnthropicMessageError as exc:
319
+ return _counted_error(app, 502, {"error": {"type": "api_error", "message": str(exc)}})
320
+
321
+ if 200 <= response.status_code < 400:
322
+ app.state.requests_ok += 1
323
+ else:
324
+ _record_error(app, response.status_code)
325
+ _log_latency_event(
326
+ {
327
+ "event": "anthropic_message_latency",
328
+ "request_id": request_id,
329
+ "status": "ok" if 200 <= response.status_code < 400 else "error",
330
+ "stream": stream,
331
+ "model": model,
332
+ "upstream_mode": auth.mode,
333
+ "total_ms": round((time.perf_counter() - started_at) * 1000, 2),
334
+ }
335
+ )
336
+ return response
337
+
249
338
  return app
250
339
 
251
340
 
@@ -263,6 +352,12 @@ def _authorized(request: Request, settings: Settings) -> bool:
263
352
  return request.headers.get("authorization") == f"Bearer {settings.api_key}"
264
353
 
265
354
 
355
+ def _authorized_anthropic(request: Request, settings: Settings) -> bool:
356
+ if _authorized(request, settings):
357
+ return True
358
+ return bool(settings.api_key and request.headers.get("x-api-key") == settings.api_key)
359
+
360
+
266
361
  def _unauthorized() -> JSONResponse:
267
362
  return JSONResponse(status_code=401, content={"error": "unauthorized"})
268
363
 
@@ -629,6 +724,129 @@ async def _forward_chat_completions_unary(
629
724
  await client.aclose()
630
725
 
631
726
 
727
+ async def _forward_anthropic_messages_stream(
728
+ request: Request,
729
+ responses_body: bytes,
730
+ auth: ProxyAuth,
731
+ model: str,
732
+ *,
733
+ retry_on_unauthorized: bool,
734
+ upstream_proxy: str | None,
735
+ upstream_client_instance: httpx.AsyncClient | None = None,
736
+ ) -> Response:
737
+ headers = _build_forward_headers(request, auth)
738
+ headers["Accept"] = "text/event-stream"
739
+ owns_client = upstream_client_instance is None
740
+ client = upstream_client_instance or upstream_client(upstream_proxy)
741
+ try:
742
+ upstream = await client.send(
743
+ client.build_request("POST", auth.responses_url(), content=responses_body, headers=headers),
744
+ stream=True,
745
+ )
746
+ if upstream.status_code == 401 and retry_on_unauthorized:
747
+ await upstream.aclose()
748
+ if owns_client:
749
+ await client.aclose()
750
+ try:
751
+ refreshed_auth = await refresh_proxy_auth(
752
+ auth,
753
+ upstream_proxy=upstream_proxy,
754
+ upstream_client_instance=upstream_client_instance,
755
+ )
756
+ except AuthError as exc:
757
+ return JSONResponse(status_code=503, content={"error": {"type": "api_error", "message": str(exc)}})
758
+ return await _forward_anthropic_messages_stream(
759
+ request,
760
+ responses_body,
761
+ refreshed_auth,
762
+ model,
763
+ retry_on_unauthorized=False,
764
+ upstream_proxy=upstream_proxy,
765
+ upstream_client_instance=upstream_client_instance,
766
+ )
767
+ if upstream.status_code >= 400:
768
+ content = await upstream.aread()
769
+ await _close_upstream(upstream, client, owns_client)
770
+ return Response(
771
+ content=content,
772
+ status_code=upstream.status_code,
773
+ headers=_filter_response_headers(upstream.headers),
774
+ )
775
+
776
+ async def generate():
777
+ try:
778
+ async for chunk in stream_anthropic_message_events(upstream.aiter_bytes(), model=model):
779
+ yield chunk
780
+ finally:
781
+ await upstream.aclose()
782
+ if owns_client:
783
+ await client.aclose()
784
+
785
+ return StreamingResponse(generate(), status_code=200, media_type="text/event-stream")
786
+ except Exception:
787
+ if owns_client:
788
+ await client.aclose()
789
+ raise
790
+
791
+
792
+ async def _forward_anthropic_messages_unary(
793
+ request: Request,
794
+ responses_body: bytes,
795
+ auth: ProxyAuth,
796
+ model: str,
797
+ *,
798
+ retry_on_unauthorized: bool,
799
+ upstream_proxy: str | None,
800
+ upstream_client_instance: httpx.AsyncClient | None = None,
801
+ ) -> Response:
802
+ headers = _build_forward_headers(request, auth)
803
+ headers["Accept"] = "text/event-stream"
804
+ owns_client = upstream_client_instance is None
805
+ client = upstream_client_instance or upstream_client(upstream_proxy)
806
+ try:
807
+ upstream = await client.send(
808
+ client.build_request("POST", auth.responses_url(), content=responses_body, headers=headers),
809
+ stream=True,
810
+ )
811
+ if upstream.status_code == 401 and retry_on_unauthorized:
812
+ await upstream.aclose()
813
+ if owns_client:
814
+ await client.aclose()
815
+ try:
816
+ refreshed_auth = await refresh_proxy_auth(
817
+ auth,
818
+ upstream_proxy=upstream_proxy,
819
+ upstream_client_instance=upstream_client_instance,
820
+ )
821
+ except AuthError as exc:
822
+ return JSONResponse(status_code=503, content={"error": {"type": "api_error", "message": str(exc)}})
823
+ return await _forward_anthropic_messages_unary(
824
+ request,
825
+ responses_body,
826
+ refreshed_auth,
827
+ model,
828
+ retry_on_unauthorized=False,
829
+ upstream_proxy=upstream_proxy,
830
+ upstream_client_instance=upstream_client_instance,
831
+ )
832
+ if upstream.status_code >= 400:
833
+ content = await upstream.aread()
834
+ await _close_upstream(upstream, client, owns_client)
835
+ return Response(
836
+ content=content,
837
+ status_code=upstream.status_code,
838
+ headers=_filter_response_headers(upstream.headers),
839
+ )
840
+
841
+ body = await upstream.aread()
842
+ await upstream.aclose()
843
+ events = parse_sse_events(body.decode("utf-8", errors="replace"))
844
+ return JSONResponse(content=responses_events_to_anthropic_message(events, model=model))
845
+ finally:
846
+ if owns_client:
847
+ await client.aclose()
848
+
849
+
632
850
  def _should_stream(upstream: httpx.Response, request: Request) -> bool:
633
851
  content_type = upstream.headers.get("content-type", "")
634
852
  if "text/event-stream" in content_type:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-api-proxy
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: Local OpenAI-compatible HTTP proxy backed by Codex/OpenAI credentials
5
5
  Author: codex-api-proxy contributors
6
6
  License-Expression: MIT
@@ -34,8 +34,9 @@ This package is intentionally narrow:
34
34
 
35
35
  - expose OpenAI-compatible local endpoints for clients such as OpenClaw;
36
36
  - reuse the user's existing Codex/OpenAI login credentials;
37
- - support `/v1/chat/completions`, `/v1/responses`, and `/v1/models`;
37
+ - support `/v1/chat/completions`, `/v1/responses`, `/v1/messages`, and `/v1/models`;
38
38
  - convert Chat Completions to Responses API when using ChatGPT Codex credentials;
39
+ - convert Anthropic-style Messages API text requests to Responses API for local Anthropic-compatible clients;
39
40
  - otherwise transparently forward OpenAI API-key requests to OpenAI's native `/chat/completions`.
40
41
 
41
42
  Out of scope:
@@ -250,6 +251,46 @@ curl -sS http://127.0.0.1:8765/v1/chat/completions \
250
251
 
251
252
  When using ChatGPT Codex credentials, Chat Completions image parts are converted to Responses API `input_image` parts. `/v1/responses` requests are passed through unchanged.
252
253
 
254
+ Anthropic-style Messages API:
255
+
256
+ ```bash
257
+ curl -sS http://127.0.0.1:8765/v1/messages \
258
+ -H 'Content-Type: application/json' \
259
+ -H 'anthropic-version: 2023-06-01' \
260
+ -d '{
261
+ "model": "gpt-5.5",
262
+ "max_tokens": 128,
263
+ "system": "Be brief.",
264
+ "messages": [{"role":"user","content":"Reply with exactly: pong"}]
265
+ }'
266
+ ```
267
+
268
+ Streaming Anthropic-style Messages API:
269
+
270
+ ```bash
271
+ curl -N http://127.0.0.1:8765/v1/messages \
272
+ -H 'Content-Type: application/json' \
273
+ -H 'anthropic-version: 2023-06-01' \
274
+ -d '{
275
+ "model": "gpt-5.5",
276
+ "max_tokens": 128,
277
+ "stream": true,
278
+ "messages": [{"role":"user","content":[{"type":"text","text":"Reply with exactly: pong"}]}]
279
+ }'
280
+ ```
281
+
282
+ For Anthropic-compatible clients such as Claude Code, point the Anthropic base URL at this proxy and use the local API key configured with `--api-key`, if any:
283
+
284
+ ```bash
285
+ ANTHROPIC_BASE_URL=http://127.0.0.1:8765 \
286
+ ANTHROPIC_AUTH_TOKEN=local-secret \
287
+ claude
288
+ ```
289
+
290
+ When local `--api-key` auth is enabled, `/v1/messages` accepts either `Authorization: Bearer <key>` or `x-api-key: <key>` because Anthropic-compatible clients vary in which header they send.
291
+
292
+ The Anthropic compatibility layer currently covers text messages, system prompts, streaming text deltas, and image content blocks. Full Anthropic `tool_use` / `tool_result` round-tripping is not yet implemented.
293
+
253
294
  When `--api-key` is configured:
254
295
 
255
296
  ```bash
@@ -1,6 +1,7 @@
1
1
  README.md
2
2
  pyproject.toml
3
3
  src/codex_api_proxy/__init__.py
4
+ src/codex_api_proxy/anthropic_messages.py
4
5
  src/codex_api_proxy/auth.py
5
6
  src/codex_api_proxy/chat_completions.py
6
7
  src/codex_api_proxy/cli.py
@@ -14,6 +15,7 @@ src/codex_api_proxy.egg-info/dependency_links.txt
14
15
  src/codex_api_proxy.egg-info/entry_points.txt
15
16
  src/codex_api_proxy.egg-info/requires.txt
16
17
  src/codex_api_proxy.egg-info/top_level.txt
18
+ tests/test_anthropic_messages.py
17
19
  tests/test_api.py
18
20
  tests/test_auth.py
19
21
  tests/test_chat_completions.py
@@ -0,0 +1,167 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import httpx
5
+ from fastapi.testclient import TestClient
6
+
7
+ import codex_api_proxy.main as main_module
8
+ from codex_api_proxy.auth import ProxyAuth
9
+ from codex_api_proxy.config import Settings
10
+ from codex_api_proxy.main import create_app
11
+
12
+
13
+ def chatgpt_auth(tmp_path: Path) -> ProxyAuth:
14
+ return ProxyAuth(
15
+ mode="chatgpt",
16
+ upstream_base="https://chatgpt.com/backend-api/codex",
17
+ headers={"Authorization": "Bearer codex-token", "ChatGPT-Account-ID": "acct-123"},
18
+ auth_path=tmp_path / "auth.json",
19
+ )
20
+
21
+
22
+ def mock_auto_auth(monkeypatch, auth: ProxyAuth) -> None:
23
+ async def load_auto_auth(*args, **kwargs):
24
+ return auth
25
+
26
+ monkeypatch.setattr(main_module, "load_proxy_auth_auto_refresh", load_auto_auth)
27
+
28
+
29
+ def mock_upstream(monkeypatch, handler):
30
+ monkeypatch.setattr(
31
+ main_module,
32
+ "upstream_client",
33
+ lambda explicit_proxy=None: httpx.AsyncClient(transport=httpx.MockTransport(handler), trust_env=False),
34
+ )
35
+
36
+
37
+ def anthropic_sse_events(body: str) -> list[dict[str, object]]:
38
+ events = []
39
+ for line in body.splitlines():
40
+ if not line.startswith("data: "):
41
+ continue
42
+ events.append(json.loads(line.removeprefix("data: ")))
43
+ return events
44
+
45
+
46
+ def test_chatgpt_mode_converts_anthropic_message_to_responses(tmp_path: Path, monkeypatch) -> None:
47
+ seen = {}
48
+
49
+ def handler(request: httpx.Request) -> httpx.Response:
50
+ seen["url"] = str(request.url)
51
+ seen["authorization"] = request.headers["authorization"]
52
+ seen["accept"] = request.headers["accept"]
53
+ seen["body"] = json.loads(request.content)
54
+ body = "\n\n".join(
55
+ [
56
+ 'data: {"type":"response.output_text.delta","delta":"pong"}',
57
+ (
58
+ 'data: {"type":"response.completed","response":{"id":"resp_123","output":['
59
+ '{"content":[{"type":"output_text","text":"pong"}]}]}}'
60
+ ),
61
+ "",
62
+ ]
63
+ )
64
+ return httpx.Response(200, content=body, headers={"content-type": "text/event-stream"})
65
+
66
+ mock_auto_auth(monkeypatch, chatgpt_auth(tmp_path))
67
+ mock_upstream(monkeypatch, handler)
68
+ app = create_app(Settings())
69
+ client = TestClient(app)
70
+
71
+ response = client.post(
72
+ "/v1/messages",
73
+ headers={"anthropic-version": "2023-06-01", "x-api-key": "local-unused"},
74
+ json={
75
+ "model": "gpt-5.5",
76
+ "max_tokens": 128,
77
+ "system": "Be brief.",
78
+ "messages": [{"role": "user", "content": "ping"}],
79
+ },
80
+ )
81
+
82
+ assert response.status_code == 200
83
+ assert response.json()["type"] == "message"
84
+ assert response.json()["role"] == "assistant"
85
+ assert response.json()["model"] == "gpt-5.5"
86
+ assert response.json()["content"] == [{"type": "text", "text": "pong"}]
87
+ assert response.json()["stop_reason"] == "end_turn"
88
+ assert seen["url"] == "https://chatgpt.com/backend-api/codex/responses"
89
+ assert seen["authorization"] == "Bearer codex-token"
90
+ assert seen["accept"] == "text/event-stream"
91
+ assert seen["body"]["model"] == "gpt-5.5"
92
+ assert seen["body"]["instructions"] == "Be brief."
93
+ assert seen["body"]["input"][0]["role"] == "user"
94
+ assert seen["body"]["input"][0]["content"] == [{"type": "input_text", "text": "ping"}]
95
+ assert seen["body"]["stream"] is True
96
+ assert seen["body"]["store"] is False
97
+ assert "max_output_tokens" not in seen["body"]
98
+
99
+
100
+ def test_chatgpt_mode_streams_anthropic_message_events(tmp_path: Path, monkeypatch) -> None:
101
+ def handler(request: httpx.Request) -> httpx.Response:
102
+ body = "\n\n".join(
103
+ [
104
+ 'data: {"type":"response.output_text.delta","delta":"po"}',
105
+ 'data: {"type":"response.output_text.delta","delta":"ng"}',
106
+ 'data: {"type":"response.completed","response":{"id":"resp_123","output":[]}}',
107
+ "",
108
+ ]
109
+ )
110
+ return httpx.Response(200, content=body, headers={"content-type": "text/event-stream"})
111
+
112
+ mock_auto_auth(monkeypatch, chatgpt_auth(tmp_path))
113
+ mock_upstream(monkeypatch, handler)
114
+ app = create_app(Settings())
115
+ client = TestClient(app)
116
+
117
+ with client.stream(
118
+ "POST",
119
+ "/v1/messages",
120
+ json={
121
+ "model": "gpt-5.5",
122
+ "max_tokens": 128,
123
+ "stream": True,
124
+ "messages": [{"role": "user", "content": [{"type": "text", "text": "ping"}]}],
125
+ },
126
+ ) as response:
127
+ body = response.read().decode("utf-8")
128
+
129
+ assert response.status_code == 200
130
+ assert response.headers["content-type"].startswith("text/event-stream")
131
+ events = anthropic_sse_events(body)
132
+ assert [event["type"] for event in events] == [
133
+ "message_start",
134
+ "content_block_start",
135
+ "content_block_delta",
136
+ "content_block_delta",
137
+ "content_block_stop",
138
+ "message_delta",
139
+ "message_stop",
140
+ ]
141
+ assert events[0]["message"]["type"] == "message"
142
+ assert events[0]["message"]["model"] == "gpt-5.5"
143
+ assert events[2]["delta"] == {"type": "text_delta", "text": "po"}
144
+ assert events[3]["delta"] == {"type": "text_delta", "text": "ng"}
145
+ assert events[-2]["delta"] == {"stop_reason": "end_turn", "stop_sequence": None}
146
+
147
+
148
+ def test_anthropic_message_accepts_x_api_key_when_local_auth_is_enabled(tmp_path: Path, monkeypatch) -> None:
149
+ def handler(request: httpx.Request) -> httpx.Response:
150
+ return httpx.Response(
151
+ 200,
152
+ content='data: {"type":"response.completed","response":{"output":[]}}\n\n',
153
+ headers={"content-type": "text/event-stream"},
154
+ )
155
+
156
+ mock_auto_auth(monkeypatch, chatgpt_auth(tmp_path))
157
+ mock_upstream(monkeypatch, handler)
158
+ app = create_app(Settings(api_key="local-secret"))
159
+ client = TestClient(app)
160
+
161
+ response = client.post(
162
+ "/v1/messages",
163
+ headers={"x-api-key": "local-secret"},
164
+ json={"model": "gpt-5.5", "max_tokens": 128, "messages": [{"role": "user", "content": "ping"}]},
165
+ )
166
+
167
+ assert response.status_code == 200