cloudunity commited on
Commit
a024115
·
verified ·
1 Parent(s): 8b3409d

Create proxy.py

Browse files
Files changed (1) hide show
  1. proxy.py +279 -0
proxy.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Anthropic <-> OpenAI translation proxy.
3
+
4
+ Claude Code speaks Anthropic's /v1/messages schema. This proxy exposes
5
+ that same schema locally, translates each request into an OpenAI-style
6
+ /v1/chat/completions call against your real backend, and translates the
7
+ (streaming or non-streaming) response back into Anthropic's format.
8
+
9
+ Point Claude Code at this proxy via ANTHROPIC_BASE_URL=http://localhost:8317
10
+ and it never needs to know the real backend isn't Anthropic.
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import time
16
+ import uuid
17
+
18
+ import httpx
19
+ from fastapi import FastAPI, Request
20
+ from fastapi.responses import StreamingResponse, JSONResponse
21
+
22
+ app = FastAPI()
23
+
24
+ OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "http://localhost:8000")
25
+ OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
26
+ PROXY_PORT = int(os.environ.get("PROXY_PORT", 8317))
27
+
28
+ client = httpx.AsyncClient(timeout=120.0)
29
+
30
+
31
+ # ---------- Anthropic request -> OpenAI request ----------
32
+
33
+ def anthropic_to_openai_request(body: dict) -> dict:
34
+ messages = []
35
+
36
+ system = body.get("system")
37
+ if system:
38
+ if isinstance(system, list):
39
+ system_text = "\n".join(b.get("text", "") for b in system if b.get("type") == "text")
40
+ else:
41
+ system_text = system
42
+ messages.append({"role": "system", "content": system_text})
43
+
44
+ for msg in body.get("messages", []):
45
+ role = msg["role"]
46
+ content = msg["content"]
47
+
48
+ if isinstance(content, str):
49
+ messages.append({"role": role, "content": content})
50
+ continue
51
+
52
+ # content is a list of blocks (text, tool_use, tool_result, image)
53
+ text_parts = []
54
+ tool_calls = []
55
+ for block in content:
56
+ btype = block.get("type")
57
+ if btype == "text":
58
+ text_parts.append(block["text"])
59
+ elif btype == "tool_use":
60
+ tool_calls.append({
61
+ "id": block["id"],
62
+ "type": "function",
63
+ "function": {
64
+ "name": block["name"],
65
+ "arguments": json.dumps(block.get("input", {})),
66
+ },
67
+ })
68
+ elif btype == "tool_result":
69
+ messages.append({
70
+ "role": "tool",
71
+ "tool_call_id": block["tool_use_id"],
72
+ "content": _flatten_tool_result(block.get("content", "")),
73
+ })
74
+ elif btype == "image":
75
+ src = block.get("source", {})
76
+ text_parts.append(f"[image omitted: {src.get('media_type', 'unknown')}]")
77
+
78
+ entry = {"role": role, "content": "\n".join(text_parts) if text_parts else None}
79
+ if tool_calls:
80
+ entry["tool_calls"] = tool_calls
81
+ if entry["content"] is not None or tool_calls:
82
+ messages.append(entry)
83
+
84
+ openai_body = {
85
+ "model": os.environ.get("OPENAI_MODEL", body.get("model", "gpt-4")),
86
+ "messages": messages,
87
+ "max_tokens": body.get("max_tokens", 1024),
88
+ "temperature": body.get("temperature", 1.0),
89
+ "stream": body.get("stream", False),
90
+ }
91
+
92
+ if body.get("tools"):
93
+ openai_body["tools"] = [
94
+ {
95
+ "type": "function",
96
+ "function": {
97
+ "name": t["name"],
98
+ "description": t.get("description", ""),
99
+ "parameters": t.get("input_schema", {}),
100
+ },
101
+ }
102
+ for t in body["tools"]
103
+ ]
104
+
105
+ return openai_body
106
+
107
+
108
+ def _flatten_tool_result(content) -> str:
109
+ if isinstance(content, str):
110
+ return content
111
+ parts = []
112
+ for block in content:
113
+ if block.get("type") == "text":
114
+ parts.append(block["text"])
115
+ return "\n".join(parts)
116
+
117
+
118
+ # ---------- OpenAI response -> Anthropic response (non-streaming) ----------
119
+
120
+ def openai_to_anthropic_response(oa: dict, model: str) -> dict:
121
+ choice = oa["choices"][0]
122
+ message = choice["message"]
123
+ content_blocks = []
124
+
125
+ if message.get("content"):
126
+ content_blocks.append({"type": "text", "text": message["content"]})
127
+
128
+ for tc in message.get("tool_calls", []) or []:
129
+ try:
130
+ args = json.loads(tc["function"]["arguments"])
131
+ except (json.JSONDecodeError, TypeError):
132
+ args = {}
133
+ content_blocks.append({
134
+ "type": "tool_use",
135
+ "id": tc["id"],
136
+ "name": tc["function"]["name"],
137
+ "input": args,
138
+ })
139
+
140
+ finish_map = {"stop": "end_turn", "length": "max_tokens", "tool_calls": "tool_use"}
141
+
142
+ usage = oa.get("usage", {})
143
+
144
+ return {
145
+ "id": f"msg_{uuid.uuid4().hex[:24]}",
146
+ "type": "message",
147
+ "role": "assistant",
148
+ "model": model,
149
+ "content": content_blocks,
150
+ "stop_reason": finish_map.get(choice.get("finish_reason"), "end_turn"),
151
+ "stop_sequence": None,
152
+ "usage": {
153
+ "input_tokens": usage.get("prompt_tokens", 0),
154
+ "output_tokens": usage.get("completion_tokens", 0),
155
+ },
156
+ }
157
+
158
+
159
+ # ---------- OpenAI SSE stream -> Anthropic SSE stream ----------
160
+
161
+ async def stream_openai_to_anthropic(oa_stream, model: str):
162
+ message_id = f"msg_{uuid.uuid4().hex[:24]}"
163
+ started = False
164
+ block_open = False
165
+ block_index = 0
166
+
167
+ yield _sse("message_start", {
168
+ "type": "message_start",
169
+ "message": {
170
+ "id": message_id, "type": "message", "role": "assistant",
171
+ "model": model, "content": [], "stop_reason": None,
172
+ "stop_sequence": None, "usage": {"input_tokens": 0, "output_tokens": 0},
173
+ },
174
+ })
175
+ started = True
176
+
177
+ async for line in oa_stream:
178
+ if not line or not line.startswith("data: "):
179
+ continue
180
+ payload = line[len("data: "):].strip()
181
+ if payload == "[DONE]":
182
+ break
183
+ try:
184
+ chunk = json.loads(payload)
185
+ except json.JSONDecodeError:
186
+ continue
187
+
188
+ delta = chunk["choices"][0].get("delta", {})
189
+
190
+ if "content" in delta and delta["content"]:
191
+ if not block_open:
192
+ yield _sse("content_block_start", {
193
+ "type": "content_block_start", "index": block_index,
194
+ "content_block": {"type": "text", "text": ""},
195
+ })
196
+ block_open = True
197
+ yield _sse("content_block_delta", {
198
+ "type": "content_block_delta", "index": block_index,
199
+ "delta": {"type": "text_delta", "text": delta["content"]},
200
+ })
201
+
202
+ if delta.get("tool_calls"):
203
+ for tc in delta["tool_calls"]:
204
+ if block_open:
205
+ yield _sse("content_block_stop", {"type": "content_block_stop", "index": block_index})
206
+ block_index += 1
207
+ block_open = False
208
+ yield _sse("content_block_start", {
209
+ "type": "content_block_start", "index": block_index,
210
+ "content_block": {
211
+ "type": "tool_use",
212
+ "id": tc.get("id", f"toolu_{uuid.uuid4().hex[:16]}"),
213
+ "name": tc["function"]["name"],
214
+ "input": {},
215
+ },
216
+ })
217
+ args = tc["function"].get("arguments", "")
218
+ if args:
219
+ yield _sse("content_block_delta", {
220
+ "type": "content_block_delta", "index": block_index,
221
+ "delta": {"type": "input_json_delta", "partial_json": args},
222
+ })
223
+ yield _sse("content_block_stop", {"type": "content_block_stop", "index": block_index})
224
+ block_index += 1
225
+
226
+ if block_open:
227
+ yield _sse("content_block_stop", {"type": "content_block_stop", "index": block_index})
228
+
229
+ yield _sse("message_delta", {
230
+ "type": "message_delta",
231
+ "delta": {"stop_reason": "end_turn", "stop_sequence": None},
232
+ "usage": {"output_tokens": 0},
233
+ })
234
+ yield _sse("message_stop", {"type": "message_stop"})
235
+
236
+
237
+ def _sse(event: str, data: dict) -> str:
238
+ return f"event: {event}\ndata: {json.dumps(data)}\n\n"
239
+
240
+
241
+ # ---------- Route ----------
242
+
243
+ @app.post("/v1/messages")
244
+ async def messages(request: Request):
245
+ body = await request.json()
246
+ model = body.get("model", "claude-proxy")
247
+ openai_body = anthropic_to_openai_request(body)
248
+
249
+ headers = {"Content-Type": "application/json"}
250
+ if OPENAI_API_KEY:
251
+ headers["Authorization"] = f"Bearer {OPENAI_API_KEY}"
252
+
253
+ if openai_body.get("stream"):
254
+ async def event_gen():
255
+ async with client.stream(
256
+ "POST", f"{OPENAI_BASE_URL}/v1/chat/completions",
257
+ json=openai_body, headers=headers,
258
+ ) as resp:
259
+ async for chunk in stream_openai_to_anthropic(resp.aiter_lines(), model):
260
+ yield chunk
261
+ return StreamingResponse(event_gen(), media_type="text/event-stream")
262
+
263
+ resp = await client.post(
264
+ f"{OPENAI_BASE_URL}/v1/chat/completions",
265
+ json=openai_body, headers=headers,
266
+ )
267
+ resp.raise_for_status()
268
+ anthropic_resp = openai_to_anthropic_response(resp.json(), model)
269
+ return JSONResponse(anthropic_resp)
270
+
271
+
272
+ @app.get("/health")
273
+ async def health():
274
+ return {"status": "ok", "backend": OPENAI_BASE_URL}
275
+
276
+
277
+ if __name__ == "__main__":
278
+ import uvicorn
279
+ uvicorn.run(app, host="0.0.0.0", port=PROXY_PORT)