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.
- {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/PKG-INFO +43 -2
- {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/README.md +42 -1
- {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/pyproject.toml +1 -1
- {codex_api_proxy-0.1.4 → 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.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy/main.py +218 -0
- {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/PKG-INFO +43 -2
- {codex_api_proxy-0.1.4 → 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.4 → codex_api_proxy-0.1.5}/setup.cfg +0 -0
- {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy/auth.py +0 -0
- {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy/chat_completions.py +0 -0
- {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy/cli.py +0 -0
- {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy/config.py +0 -0
- {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy/models.py +0 -0
- {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy/sse_utils.py +0 -0
- {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/dependency_links.txt +0 -0
- {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/entry_points.txt +0 -0
- {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/requires.txt +0 -0
- {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/top_level.txt +0 -0
- {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/tests/test_api.py +0 -0
- {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/tests/test_auth.py +0 -0
- {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/tests/test_chat_completions.py +0 -0
- {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/tests/test_cli.py +0 -0
- {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/tests/test_config.py +0 -0
- {codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/tests/test_models.py +0 -0
- {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.
|
|
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
|
|
@@ -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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codex_api_proxy-0.1.4 → codex_api_proxy-0.1.5}/src/codex_api_proxy.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{codex_api_proxy-0.1.4 → 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
|
|
File without changes
|