codex-api-proxy 0.1.3__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.3 → codex_api_proxy-0.1.5}/PKG-INFO +58 -2
  2. {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/README.md +57 -1
  3. {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/pyproject.toml +1 -1
  4. {codex_api_proxy-0.1.3 → 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.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy/config.py +9 -0
  7. {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy/main.py +218 -0
  8. {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/PKG-INFO +58 -2
  9. {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/SOURCES.txt +2 -0
  10. codex_api_proxy-0.1.5/tests/test_anthropic_messages.py +167 -0
  11. {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/tests/test_config.py +50 -0
  12. {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/setup.cfg +0 -0
  13. {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy/auth.py +0 -0
  14. {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy/chat_completions.py +0 -0
  15. {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy/cli.py +0 -0
  16. {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy/models.py +0 -0
  17. {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy/sse_utils.py +0 -0
  18. {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/dependency_links.txt +0 -0
  19. {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/entry_points.txt +0 -0
  20. {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/requires.txt +0 -0
  21. {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/top_level.txt +0 -0
  22. {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/tests/test_api.py +0 -0
  23. {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/tests/test_auth.py +0 -0
  24. {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/tests/test_chat_completions.py +0 -0
  25. {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/tests/test_cli.py +0 -0
  26. {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/tests/test_models.py +0 -0
  27. {codex_api_proxy-0.1.3 → 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.3
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:
@@ -102,6 +103,20 @@ You can also set the upstream proxy explicitly at startup:
102
103
  codex-api-proxy start --proxy=http://127.0.0.1:8118
103
104
  ```
104
105
 
106
+ If the upstream connection passes through a corporate TLS proxy or another endpoint
107
+ using a private/self-signed certificate chain, point the proxy at a CA bundle that
108
+ trusts that chain:
109
+
110
+ - `CODEX_API_PROXY_CA_BUNDLE`
111
+ - `REQUESTS_CA_BUNDLE`
112
+ - `SSL_CERT_FILE`
113
+
114
+ For example:
115
+
116
+ ```bash
117
+ CODEX_API_PROXY_CA_BUNDLE=/path/to/internal-ca-bundle.pem codex-api-proxy start
118
+ ```
119
+
105
120
  ## Run
106
121
 
107
122
  Start in the background:
@@ -164,6 +179,7 @@ Environment variables for the local server:
164
179
  - `CODEX_PROXY_PORT`
165
180
  - `CODEX_PROXY_API_KEY`
166
181
  - `CODEX_API_PROXY_HTTPS_PROXY`
182
+ - `CODEX_API_PROXY_CA_BUNDLE`
167
183
  - `CODEX_PROXY_LOG_LEVEL`
168
184
 
169
185
  Token refresh compatibility variables:
@@ -235,6 +251,46 @@ curl -sS http://127.0.0.1:8765/v1/chat/completions \
235
251
 
236
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.
237
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
+
238
294
  When `--api-key` is configured:
239
295
 
240
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:
@@ -78,6 +79,20 @@ You can also set the upstream proxy explicitly at startup:
78
79
  codex-api-proxy start --proxy=http://127.0.0.1:8118
79
80
  ```
80
81
 
82
+ If the upstream connection passes through a corporate TLS proxy or another endpoint
83
+ using a private/self-signed certificate chain, point the proxy at a CA bundle that
84
+ trusts that chain:
85
+
86
+ - `CODEX_API_PROXY_CA_BUNDLE`
87
+ - `REQUESTS_CA_BUNDLE`
88
+ - `SSL_CERT_FILE`
89
+
90
+ For example:
91
+
92
+ ```bash
93
+ CODEX_API_PROXY_CA_BUNDLE=/path/to/internal-ca-bundle.pem codex-api-proxy start
94
+ ```
95
+
81
96
  ## Run
82
97
 
83
98
  Start in the background:
@@ -140,6 +155,7 @@ Environment variables for the local server:
140
155
  - `CODEX_PROXY_PORT`
141
156
  - `CODEX_PROXY_API_KEY`
142
157
  - `CODEX_API_PROXY_HTTPS_PROXY`
158
+ - `CODEX_API_PROXY_CA_BUNDLE`
143
159
  - `CODEX_PROXY_LOG_LEVEL`
144
160
 
145
161
  Token refresh compatibility variables:
@@ -211,6 +227,46 @@ curl -sS http://127.0.0.1:8765/v1/chat/completions \
211
227
 
212
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.
213
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
+
214
270
  When `--api-key` is configured:
215
271
 
216
272
  ```bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codex-api-proxy"
3
- version = "0.1.3"
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.3"
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]}"
@@ -76,6 +76,14 @@ def upstream_https_proxy(explicit_proxy: str | None = None) -> str | None:
76
76
  return DEFAULT_HTTPS_PROXY
77
77
 
78
78
 
79
+ def upstream_ssl_verify() -> bool | str:
80
+ for key in ("CODEX_API_PROXY_CA_BUNDLE", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE"):
81
+ value = os.environ.get(key, "").strip()
82
+ if value:
83
+ return value
84
+ return True
85
+
86
+
79
87
  def upstream_originator() -> str:
80
88
  override = os.environ.get("CODEX_INTERNAL_ORIGINATOR_OVERRIDE", "").strip()
81
89
  return override or DEFAULT_ORIGINATOR
@@ -95,6 +103,7 @@ def upstream_client(explicit_proxy: str | None = None) -> httpx.AsyncClient:
95
103
  return httpx.AsyncClient(
96
104
  timeout=DEFAULT_TIMEOUT,
97
105
  proxy=upstream_https_proxy(explicit_proxy),
106
+ verify=upstream_ssl_verify(),
98
107
  trust_env=False,
99
108
  )
100
109
 
@@ -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.3
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:
@@ -102,6 +103,20 @@ You can also set the upstream proxy explicitly at startup:
102
103
  codex-api-proxy start --proxy=http://127.0.0.1:8118
103
104
  ```
104
105
 
106
+ If the upstream connection passes through a corporate TLS proxy or another endpoint
107
+ using a private/self-signed certificate chain, point the proxy at a CA bundle that
108
+ trusts that chain:
109
+
110
+ - `CODEX_API_PROXY_CA_BUNDLE`
111
+ - `REQUESTS_CA_BUNDLE`
112
+ - `SSL_CERT_FILE`
113
+
114
+ For example:
115
+
116
+ ```bash
117
+ CODEX_API_PROXY_CA_BUNDLE=/path/to/internal-ca-bundle.pem codex-api-proxy start
118
+ ```
119
+
105
120
  ## Run
106
121
 
107
122
  Start in the background:
@@ -164,6 +179,7 @@ Environment variables for the local server:
164
179
  - `CODEX_PROXY_PORT`
165
180
  - `CODEX_PROXY_API_KEY`
166
181
  - `CODEX_API_PROXY_HTTPS_PROXY`
182
+ - `CODEX_API_PROXY_CA_BUNDLE`
167
183
  - `CODEX_PROXY_LOG_LEVEL`
168
184
 
169
185
  Token refresh compatibility variables:
@@ -235,6 +251,46 @@ curl -sS http://127.0.0.1:8765/v1/chat/completions \
235
251
 
236
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.
237
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
+
238
294
  When `--api-key` is configured:
239
295
 
240
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
@@ -1,5 +1,6 @@
1
1
  from codex_api_proxy.config import DEFAULT_HTTPS_PROXY
2
2
  from codex_api_proxy.config import Settings
3
+ from codex_api_proxy.config import upstream_client
3
4
  from codex_api_proxy.config import upstream_https_proxy
4
5
 
5
6
 
@@ -64,3 +65,52 @@ def test_upstream_https_proxy_defaults_to_privoxy(monkeypatch) -> None:
64
65
  def test_upstream_https_proxy_env_override(monkeypatch) -> None:
65
66
  monkeypatch.setenv("CODEX_API_PROXY_HTTPS_PROXY", "http://127.0.0.1:9999")
66
67
  assert upstream_https_proxy() == "http://127.0.0.1:9999"
68
+
69
+
70
+ def test_upstream_client_defaults_to_system_tls_trust(monkeypatch) -> None:
71
+ for key in ("CODEX_API_PROXY_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"):
72
+ monkeypatch.delenv(key, raising=False)
73
+ captured = {}
74
+
75
+ def fake_async_client(**kwargs):
76
+ captured.update(kwargs)
77
+ return object()
78
+
79
+ monkeypatch.setattr("codex_api_proxy.config.httpx.AsyncClient", fake_async_client)
80
+
81
+ upstream_client()
82
+
83
+ assert captured["verify"] is True
84
+
85
+
86
+ def test_upstream_client_uses_proxy_ca_bundle(monkeypatch) -> None:
87
+ monkeypatch.setenv("CODEX_API_PROXY_CA_BUNDLE", "/etc/ssl/certs/internal-ca.pem")
88
+ monkeypatch.setenv("SSL_CERT_FILE", "/etc/ssl/certs/ignored.pem")
89
+ captured = {}
90
+
91
+ def fake_async_client(**kwargs):
92
+ captured.update(kwargs)
93
+ return object()
94
+
95
+ monkeypatch.setattr("codex_api_proxy.config.httpx.AsyncClient", fake_async_client)
96
+
97
+ upstream_client()
98
+
99
+ assert captured["verify"] == "/etc/ssl/certs/internal-ca.pem"
100
+
101
+
102
+ def test_upstream_client_uses_common_ca_bundle_env(monkeypatch) -> None:
103
+ monkeypatch.delenv("CODEX_API_PROXY_CA_BUNDLE", raising=False)
104
+ monkeypatch.setenv("REQUESTS_CA_BUNDLE", "/etc/ssl/certs/requests-ca.pem")
105
+ monkeypatch.setenv("SSL_CERT_FILE", "/etc/ssl/certs/ssl-ca.pem")
106
+ captured = {}
107
+
108
+ def fake_async_client(**kwargs):
109
+ captured.update(kwargs)
110
+ return object()
111
+
112
+ monkeypatch.setattr("codex_api_proxy.config.httpx.AsyncClient", fake_async_client)
113
+
114
+ upstream_client()
115
+
116
+ assert captured["verify"] == "/etc/ssl/certs/requests-ca.pem"