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.
- {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/PKG-INFO +58 -2
- {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/README.md +57 -1
- {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/pyproject.toml +1 -1
- {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy/__init__.py +1 -1
- codex_api_proxy-0.1.5/src/codex_api_proxy/anthropic_messages.py +257 -0
- {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy/config.py +9 -0
- {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy/main.py +218 -0
- {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/PKG-INFO +58 -2
- {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/SOURCES.txt +2 -0
- codex_api_proxy-0.1.5/tests/test_anthropic_messages.py +167 -0
- {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/tests/test_config.py +50 -0
- {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/setup.cfg +0 -0
- {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy/auth.py +0 -0
- {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy/chat_completions.py +0 -0
- {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy/cli.py +0 -0
- {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy/models.py +0 -0
- {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy/sse_utils.py +0 -0
- {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/dependency_links.txt +0 -0
- {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/entry_points.txt +0 -0
- {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/requires.txt +0 -0
- {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/top_level.txt +0 -0
- {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/tests/test_api.py +0 -0
- {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/tests/test_auth.py +0 -0
- {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/tests/test_chat_completions.py +0 -0
- {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/tests/test_cli.py +0 -0
- {codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/tests/test_models.py +0 -0
- {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
|
+
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
|
|
@@ -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
|
+
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{codex_api_proxy-0.1.3 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|