-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathexa_proxy.py
More file actions
309 lines (253 loc) · 10.6 KB
/
Copy pathexa_proxy.py
File metadata and controls
309 lines (253 loc) · 10.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
"""
EXA Search Proxy for llama.cpp Research Center.
Transparent reverse proxy that sits between the WebUI browser and llama-server,
intercepting /v1/chat/completions to inject tool definitions and handle
function calling for EXA web search automatically.
Architecture:
Browser -> EXA Proxy (:8081) -> llama-server (:8080)
|
exa_helper.search_web()
"""
import json
import http.server
import urllib.request
import urllib.error
import threading
from typing import Optional
from exa_helper import search_web, EXA_SEARCH_TOOL
LLAMA_TARGET = "http://127.0.0.1:8080"
PROXY_PORT = 8081
class EXAProxyHandler(http.server.BaseHTTPRequestHandler):
def log_message(self, format, *args):
pass
def do_GET(self):
self._proxy_transparent("GET")
def do_HEAD(self):
self._proxy_transparent("HEAD")
def do_DELETE(self):
self._proxy_transparent("DELETE")
def do_PATCH(self):
self._proxy_transparent("PATCH")
def do_PUT(self):
self._proxy_transparent("PUT")
def do_POST(self):
if self.path == "/v1/chat/completions":
self._handle_chat_completion()
else:
self._proxy_transparent("POST")
def _handle_chat_completion(self):
"""Inject EXA tools, proxy to llama-server, execute any tool calls, and return final response."""
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
try:
request_data = json.loads(body)
except json.JSONDecodeError:
self._send_error(400, "Invalid JSON in request body")
return
client_wants_stream = request_data.get("stream", False)
request_data["tools"] = [EXA_SEARCH_TOOL]
request_data["tool_choice"] = "auto"
request_data["stream"] = False
response_data = self._forward_to_llama(request_data)
if response_data is None:
self._send_error(502, "llama-server upstream error on first pass")
return
tool_calls = self._extract_tool_calls(response_data)
if tool_calls:
response_data = self._execute_tool_loop(request_data, response_data, tool_calls)
self._send_response_to_client(response_data, client_wants_stream)
def _execute_tool_loop(
self,
request_data: dict,
response_data: dict,
tool_calls: list,
max_turns: int = 5,
) -> dict:
messages = list(request_data.get("messages", []))
current_tool_calls = tool_calls
current_response = response_data
for turn in range(max_turns):
assistant_msg = current_response["choices"][0].get("message", {})
if assistant_msg:
messages.append(assistant_msg)
has_results = False
for tc in current_tool_calls:
result = self._execute_single_tool(tc)
if result is not None:
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", "call_unknown"),
"content": result,
})
has_results = True
if not has_results:
break
updated_request = dict(request_data)
updated_request["messages"] = messages
current_response = self._forward_to_llama(updated_request)
if current_response is None:
return {"error": "Upstream error during tool loop"}
current_tool_calls = self._extract_tool_calls(current_response)
if not current_tool_calls:
break
return current_response
def _extract_tool_calls(self, response_data: dict) -> list:
try:
message = response_data["choices"][0]["message"]
return message.get("tool_calls", [])
except (KeyError, IndexError, TypeError):
return []
def _execute_single_tool(self, tool_call: dict) -> Optional[str]:
try:
func = tool_call.get("function", {})
func_name = func.get("name", "")
raw_args = func.get("arguments", "{}")
if isinstance(raw_args, str):
arguments = json.loads(raw_args)
else:
arguments = raw_args
if func_name == "web_search":
query = arguments.get("query", "")
num_results = arguments.get("num_results", 5)
print(f" [Proxy] web_search(query='{query[:60]}...')")
return search_web(query, num_results=num_results)
return f"Unknown tool: {func_name}. Available tools: web_search"
except json.JSONDecodeError:
return f"Error: could not parse tool arguments: {raw_args}"
except Exception as e:
return f"Error executing tool: {str(e)}"
def _forward_to_llama(self, data: dict) -> Optional[dict]:
url = f"{LLAMA_TARGET}{self.path}"
body = json.dumps(data).encode("utf-8")
req = urllib.request.Request(
url,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=300) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
print(f" [Proxy] Upstream HTTP {e.code}: {e.reason}")
return None
except urllib.error.URLError as e:
print(f" [Proxy] Upstream URL error: {e.reason}")
return None
except (OSError, ValueError, json.JSONDecodeError) as e:
print(f" [Proxy] Upstream error: {e}")
return None
def _send_response_to_client(self, response_data: dict, stream: bool):
if stream:
self._send_sse_response(response_data)
else:
self._send_json_response(response_data)
def _send_json_response(self, data: dict):
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def _send_sse_response(self, data: dict):
"""Convert buffered non-streaming response to SSE for stream=True clients."""
content = ""
finish_reason = "stop"
try:
content = data["choices"][0]["message"].get("content", "")
finish_reason = data["choices"][0]["finish_reason"]
except (KeyError, IndexError):
pass
self.send_response(200)
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "keep-alive")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
if content:
chunk = json.dumps({
"choices": [{
"index": 0,
"delta": {"role": "assistant", "content": content},
"finish_reason": finish_reason,
}]
}, ensure_ascii=False)
self.wfile.write(f"data: {chunk}\n\n".encode("utf-8"))
self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
def _send_error(self, code: int, message: str):
body = json.dumps({"error": {"message": message, "type": "proxy_error"}}).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def _proxy_transparent(self, method: str):
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length) if content_length > 0 else None
url = f"{LLAMA_TARGET}{self.path}"
req = urllib.request.Request(url, data=body, method=method)
for header_name in [
"Content-Type", "Accept", "Accept-Encoding", "Authorization",
"If-None-Match", "If-Modified-Since", "Range",
]:
if header_name in self.headers:
req.add_header(header_name, self.headers[header_name])
try:
with urllib.request.urlopen(req, timeout=30) as resp:
self.send_response(resp.status)
for header_name in [
"Content-Type", "Content-Length", "Content-Encoding",
"Cache-Control", "Etag", "Last-Modified", "Accept-Ranges",
"Access-Control-Allow-Origin",
]:
if header_name in resp.headers:
self.send_header(header_name, resp.headers[header_name])
self.end_headers()
while True:
chunk = resp.read(8192)
if not chunk:
break
self.wfile.write(chunk)
self.wfile.flush()
except urllib.error.HTTPError as e:
self.send_response(e.code)
body_content = e.read()
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body_content)))
self.end_headers()
self.wfile.write(body_content)
except urllib.error.URLError as e:
self._send_error(502, f"Upstream connection error: {e.reason}")
except (OSError, ConnectionError) as e:
self._send_error(502, f"Proxy error: {str(e)}")
def start_exa_proxy(
port: int = PROXY_PORT,
target_port: int = 8080,
daemon: bool = True,
) -> threading.Thread:
"""
Start the EXA Search proxy server in a background thread.
Args:
port: Port for the proxy to listen on (default: 8081).
target_port: Port of the llama-server instance (default: 8080).
daemon: Whether the thread should be a daemon.
Returns:
The background thread running the proxy server.
"""
global LLAMA_TARGET
LLAMA_TARGET = f"http://127.0.0.1:{target_port}"
server = http.server.ThreadingHTTPServer(
("0.0.0.0", port),
EXAProxyHandler,
)
thread = threading.Thread(
target=server.serve_forever,
daemon=daemon,
name="exa-proxy",
)
thread.start()
print(f" [Proxy] EXA Search active on port {port} -> llama-server:{target_port}")
return thread