magpie-gemini-web 0.3.2 → 0.4.0

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.
@@ -0,0 +1,441 @@
1
+ """HTTP server implementing local OpenAI-compatible endpoints."""
2
+ from __future__ import annotations
3
+
4
+ import hmac
5
+ import json
6
+ import re
7
+ import socket
8
+ import threading
9
+ import time
10
+ import urllib.parse
11
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
12
+ from typing import Any
13
+
14
+ from .config import RuntimeConfig
15
+ from .contract import BridgeError, MAX_REQUEST_BYTES, json_loads, parse_request
16
+ from .protocol import MODEL_ID
17
+ from .state import Manager
18
+
19
+ DIGITS = re.compile(r"^[0-9]+$")
20
+
21
+
22
+ class BridgeHandler(BaseHTTPRequestHandler):
23
+ protocol_version = "HTTP/1.1"
24
+
25
+ def setup(self):
26
+ super().setup()
27
+ if hasattr(self, "connection") and hasattr(self.connection, "settimeout"):
28
+ self.connection.settimeout(15.0)
29
+
30
+ def log_message(self, format: str, *args: Any):
31
+ pass
32
+
33
+ @property
34
+ def server_instance(self) -> BridgeServer:
35
+ return self.server # type: ignore
36
+
37
+ def _send_error_json(self, status: int, code: str, message: str, sign_in_kept: bool = True):
38
+ body = json.dumps({
39
+ "error": {
40
+ "code": code,
41
+ "type": "invalid_request_error" if status < 500 else "api_error",
42
+ "message": message,
43
+ "param": None,
44
+ }
45
+ }).encode("utf-8")
46
+ try:
47
+ self.send_response(status)
48
+ self.send_header("Content-Type", "application/json")
49
+ self.send_header("Content-Length", str(len(body)))
50
+ self.send_header("Connection", "close")
51
+ if sign_in_kept:
52
+ self.send_header("X-Magpie-Sign-In", "kept")
53
+ else:
54
+ self.send_header("X-Magpie-Sign-In", "expired")
55
+ self.end_headers()
56
+ self.wfile.write(body)
57
+ self.wfile.flush()
58
+ except (BrokenPipeError, ConnectionResetError, OSError):
59
+ pass
60
+
61
+ def _check_auth(self) -> bool:
62
+ auth_hdrs = self.headers.get_all("Authorization")
63
+ if not auth_hdrs or len(auth_hdrs) > 1:
64
+ self._send_error_json(401, "unauthorized", "Missing or duplicate Authorization header.", sign_in_kept=False)
65
+ return False
66
+
67
+ auth_hdr = auth_hdrs[0]
68
+ if not auth_hdr.startswith("Bearer "):
69
+ self._send_error_json(401, "unauthorized", "Authorization header must use Bearer scheme.", sign_in_kept=False)
70
+ return False
71
+
72
+ token = auth_hdr[7:].strip()
73
+ expected = self.server_instance.config.api_key
74
+ if not hmac.compare_digest(token.encode("utf-8"), expected.encode("utf-8")):
75
+ self._send_error_json(401, "unauthorized", "Invalid local API key.", sign_in_kept=False)
76
+ return False
77
+ return True
78
+
79
+ def _read_body_json(self) -> dict | None:
80
+ te_hdrs = self.headers.get_all("Transfer-Encoding")
81
+ if te_hdrs:
82
+ self._send_error_json(400, "unsupported_transfer_encoding", "Transfer-Encoding is not supported.")
83
+ return None
84
+
85
+ cl_hdrs = self.headers.get_all("Content-Length")
86
+ if not cl_hdrs:
87
+ self._send_error_json(400, "missing_content_length", "Content-Length header is required.")
88
+ return None
89
+ if len(cl_hdrs) > 1:
90
+ self._send_error_json(400, "duplicate_content_length", "Duplicate Content-Length headers are not permitted.")
91
+ return None
92
+
93
+ cl_str = cl_hdrs[0].strip()
94
+ if len(cl_str) > 7:
95
+ self._send_error_json(400, "request_too_large", f"Content-Length exceeds {MAX_REQUEST_BYTES} bytes limit.")
96
+ return None
97
+
98
+ if not DIGITS.fullmatch(cl_str):
99
+ self._send_error_json(400, "invalid_content_length", "Content-Length must be an unsigned integer.")
100
+ return None
101
+
102
+ length = int(cl_str)
103
+ if length > MAX_REQUEST_BYTES:
104
+ self._send_error_json(400, "request_too_large", f"Content-Length exceeds {MAX_REQUEST_BYTES} bytes limit.")
105
+ return None
106
+
107
+ try:
108
+ raw = self.rfile.read(length)
109
+ if len(raw) != length:
110
+ self._send_error_json(400, "truncated_body", "Request body was truncated.")
111
+ return None
112
+ parsed = json_loads(raw.decode("utf-8"))
113
+ if parsed is None or not isinstance(parsed, dict):
114
+ self._send_error_json(400, "invalid_json", "Request body must be valid JSON.")
115
+ return None
116
+ return parsed
117
+ except UnicodeError:
118
+ self._send_error_json(400, "invalid_utf8", "Request body must be valid UTF-8.")
119
+ return None
120
+ except Exception:
121
+ self._send_error_json(400, "invalid_json", "Request body must be valid JSON.")
122
+ return None
123
+
124
+ def do_GET(self):
125
+ if not self._check_auth():
126
+ return
127
+
128
+ parsed = urllib.parse.urlparse(self.path)
129
+ path = parsed.path
130
+
131
+ if path == "/health":
132
+ body = json.dumps({
133
+ "service": "gemini-native-task",
134
+ "version": "0.1.0",
135
+ "instance_id": self.server_instance.config.instance_id,
136
+ "active_tasks": 1 if self.server_instance.manager._active_task_id else 0,
137
+ }).encode("utf-8")
138
+ self.send_response(200)
139
+ self.send_header("Content-Type", "application/json")
140
+ self.send_header("Content-Length", str(len(body)))
141
+ self.send_header("Connection", "close")
142
+ self.end_headers()
143
+ self.wfile.write(body)
144
+ self.wfile.flush()
145
+ return
146
+
147
+ if path == "/v1/models":
148
+ body = json.dumps({
149
+ "object": "list",
150
+ "data": [{
151
+ "id": MODEL_ID,
152
+ "object": "model",
153
+ "created": self.server_instance.created_time,
154
+ "owned_by": "gemini-native-task",
155
+ }],
156
+ }).encode("utf-8")
157
+ self.send_response(200)
158
+ self.send_header("Content-Type", "application/json")
159
+ self.send_header("Content-Length", str(len(body)))
160
+ self.send_header("Connection", "close")
161
+ self.end_headers()
162
+ self.wfile.write(body)
163
+ self.wfile.flush()
164
+ return
165
+
166
+ if path.startswith("/v1/native/sessions/"):
167
+ raw_session = path[len("/v1/native/sessions/"):]
168
+ session_id = urllib.parse.unquote(raw_session)
169
+ try:
170
+ status_data = self.server_instance.manager.status(session_id)
171
+ body = json.dumps(status_data).encode("utf-8")
172
+ self.send_response(200)
173
+ self.send_header("Content-Type", "application/json")
174
+ self.send_header("Content-Length", str(len(body)))
175
+ self.send_header("Connection", "close")
176
+ self.end_headers()
177
+ self.wfile.write(body)
178
+ self.wfile.flush()
179
+ except BridgeError as be:
180
+ self._send_error_json(be.status, be.code, be.message)
181
+ return
182
+
183
+ self._send_error_json(404, "not_found", f"Path {path} not found.")
184
+
185
+ def do_POST(self):
186
+ if not self._check_auth():
187
+ return
188
+
189
+ parsed = urllib.parse.urlparse(self.path)
190
+ path = parsed.path
191
+
192
+ if path == "/v1/native/shutdown":
193
+ body_dict = self._read_body_json()
194
+ if body_dict is None:
195
+ return
196
+
197
+ body = json.dumps({"status": "shutting_down"}).encode("utf-8")
198
+ self.send_response(202)
199
+ self.send_header("Content-Type", "application/json")
200
+ self.send_header("Content-Length", str(len(body)))
201
+ self.send_header("Connection", "close")
202
+ self.end_headers()
203
+ self.wfile.write(body)
204
+ self.wfile.flush()
205
+ threading.Thread(target=self.server_instance.stop, daemon=True).start()
206
+ return
207
+
208
+ if path.startswith("/v1/native/sessions/") and path.endswith("/cancel"):
209
+ raw_session = path[len("/v1/native/sessions/"):-len("/cancel")]
210
+ session_id = urllib.parse.unquote(raw_session)
211
+ body_dict = self._read_body_json()
212
+ if body_dict is None:
213
+ return
214
+
215
+ try:
216
+ res = self.server_instance.manager.cancel(session_id)
217
+ body = json.dumps(res).encode("utf-8")
218
+ self.send_response(202)
219
+ self.send_header("Content-Type", "application/json")
220
+ self.send_header("Content-Length", str(len(body)))
221
+ self.send_header("Connection", "close")
222
+ self.end_headers()
223
+ self.wfile.write(body)
224
+ self.wfile.flush()
225
+ except BridgeError as be:
226
+ self._send_error_json(be.status, be.code, be.message)
227
+ return
228
+
229
+ if path == "/v1/chat/completions":
230
+ gemini_sessions = self.headers.get_all("X-Gemini-Session") or []
231
+ magpie_sessions = self.headers.get_all("X-Magpie-Session") or []
232
+
233
+ if len(gemini_sessions) > 1 or len(magpie_sessions) > 1:
234
+ self._send_error_json(400, "duplicate_session_header", "Duplicate session headers provided.")
235
+ return
236
+
237
+ sess_gemini = gemini_sessions[0] if gemini_sessions else None
238
+ sess_magpie = magpie_sessions[0] if magpie_sessions else None
239
+
240
+ if sess_gemini and sess_magpie and sess_gemini != sess_magpie:
241
+ self._send_error_json(400, "session_header_conflict", "X-Gemini-Session and X-Magpie-Session must match.")
242
+ return
243
+
244
+ session_id = sess_gemini or sess_magpie
245
+ if not session_id:
246
+ self._send_error_json(400, "missing_session", "Provide a stable session ID via X-Gemini-Session header.")
247
+ return
248
+
249
+ body_dict = self._read_body_json()
250
+ if body_dict is None:
251
+ return
252
+
253
+ try:
254
+ comp_req = parse_request(body_dict)
255
+ ticket = self.server_instance.manager.submit(session_id, comp_req)
256
+ except BridgeError as be:
257
+ self._send_error_json(be.status, be.code, be.message)
258
+ return
259
+ except Exception:
260
+ self._send_error_json(409, "submission_failed", "Failed to submit completion request.")
261
+ return
262
+
263
+ if not comp_req.stream:
264
+ try:
265
+ result = ticket.result(timeout=self.server_instance.config.task_timeout)
266
+ resp_bytes = json.dumps(result, ensure_ascii=False).encode("utf-8")
267
+ self.send_response(200)
268
+ self.send_header("Content-Type", "application/json")
269
+ self.send_header("Content-Length", str(len(resp_bytes)))
270
+ self.send_header("Connection", "close")
271
+ self.end_headers()
272
+ self.wfile.write(resp_bytes)
273
+ self.wfile.flush()
274
+ except BridgeError as be:
275
+ self._send_error_json(be.status, be.code, be.message)
276
+ except TimeoutError:
277
+ self.server_instance.manager.abandon_delivery(session_id, ticket.task_id, comp_req.digest)
278
+ self._send_error_json(409, "task_timeout", "Task timed out waiting for completion.")
279
+ except (BrokenPipeError, ConnectionResetError, OSError):
280
+ self.server_instance.manager.abandon_delivery(session_id, ticket.task_id, comp_req.digest)
281
+ except Exception:
282
+ self._send_error_json(409, "execution_failed", "Task execution failed.")
283
+ return
284
+
285
+ # Streaming response (SSE)
286
+ try:
287
+ self.send_response(200)
288
+ self.send_header("Content-Type", "text/event-stream")
289
+ self.send_header("Cache-Control", "no-cache")
290
+ self.send_header("Connection", "close")
291
+ self.send_header("X-Magpie-Sign-In", "kept")
292
+ self.end_headers()
293
+ except (BrokenPipeError, ConnectionResetError, OSError):
294
+ self.server_instance.manager.abandon_delivery(session_id, ticket.task_id, comp_req.digest)
295
+ return
296
+
297
+ disconnected = False
298
+ stream_deadline = time.monotonic() + self.server_instance.config.task_timeout
299
+ interval = self.server_instance.heartbeat_interval
300
+
301
+ while not ticket.event.wait(timeout=interval):
302
+ if time.monotonic() >= stream_deadline:
303
+ self.server_instance.manager.abandon_delivery(session_id, ticket.task_id, comp_req.digest)
304
+ try:
305
+ err_chunk = f"data: {json.dumps({'error': {'code': 'task_timeout', 'type': 'api_error', 'message': 'Task timed out.'}})}\n\ndata: [DONE]\n\n".encode("utf-8")
306
+ self.wfile.write(err_chunk)
307
+ self.wfile.flush()
308
+ except (BrokenPipeError, ConnectionResetError, OSError):
309
+ pass
310
+ return
311
+
312
+ try:
313
+ self.wfile.write(b": keepalive\n\n")
314
+ self.wfile.flush()
315
+ except (BrokenPipeError, ConnectionResetError, OSError):
316
+ disconnected = True
317
+ break
318
+
319
+ if disconnected:
320
+ self.server_instance.manager.abandon_delivery(session_id, ticket.task_id, comp_req.digest)
321
+ return
322
+
323
+ if ticket.error is not None:
324
+ err_chunk = f"data: {json.dumps(ticket.error.body())}\n\ndata: [DONE]\n\n".encode("utf-8")
325
+ try:
326
+ self.wfile.write(err_chunk)
327
+ self.wfile.flush()
328
+ except (BrokenPipeError, ConnectionResetError, OSError):
329
+ self.server_instance.manager.abandon_delivery(session_id, ticket.task_id, comp_req.digest)
330
+ return
331
+
332
+ resp = ticket.response
333
+ if resp is None:
334
+ err_chunk = f"data: {json.dumps({'error': {'code': 'no_response', 'type': 'api_error', 'message': 'Empty ticket response.'}})}\n\ndata: [DONE]\n\n".encode("utf-8")
335
+ try:
336
+ self.wfile.write(err_chunk)
337
+ self.wfile.flush()
338
+ except (BrokenPipeError, ConnectionResetError, OSError):
339
+ self.server_instance.manager.abandon_delivery(session_id, ticket.task_id, comp_req.digest)
340
+ return
341
+
342
+ resp_id = resp["id"]
343
+ created = resp["created"]
344
+ choice = resp["choices"][0]
345
+ finish_reason = choice["finish_reason"]
346
+ msg = choice["message"]
347
+
348
+ chunks = [
349
+ {
350
+ "id": resp_id,
351
+ "object": "chat.completion.chunk",
352
+ "created": created,
353
+ "model": MODEL_ID,
354
+ "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}],
355
+ }
356
+ ]
357
+
358
+ if msg.get("tool_calls"):
359
+ tc = msg["tool_calls"][0]
360
+ chunks.append({
361
+ "id": resp_id,
362
+ "object": "chat.completion.chunk",
363
+ "created": created,
364
+ "model": MODEL_ID,
365
+ "choices": [{
366
+ "index": 0,
367
+ "delta": {
368
+ "tool_calls": [{
369
+ "index": 0,
370
+ "id": tc["id"],
371
+ "type": "function",
372
+ "function": {
373
+ "name": tc["function"]["name"],
374
+ "arguments": tc["function"]["arguments"],
375
+ },
376
+ }]
377
+ },
378
+ "finish_reason": None,
379
+ }],
380
+ })
381
+ elif msg.get("content"):
382
+ chunks.append({
383
+ "id": resp_id,
384
+ "object": "chat.completion.chunk",
385
+ "created": created,
386
+ "model": MODEL_ID,
387
+ "choices": [{"index": 0, "delta": {"content": msg["content"]}, "finish_reason": None}],
388
+ })
389
+
390
+ chunks.append({
391
+ "id": resp_id,
392
+ "object": "chat.completion.chunk",
393
+ "created": created,
394
+ "model": MODEL_ID,
395
+ "choices": [{"index": 0, "delta": {}, "finish_reason": finish_reason}],
396
+ })
397
+
398
+ try:
399
+ for chunk in chunks:
400
+ self.wfile.write(f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n".encode("utf-8"))
401
+ self.wfile.write(b"data: [DONE]\n\n")
402
+ self.wfile.flush()
403
+ except (BrokenPipeError, ConnectionResetError, OSError):
404
+ self.server_instance.manager.abandon_delivery(session_id, ticket.task_id, comp_req.digest)
405
+ return
406
+
407
+ self._send_error_json(404, "not_found", f"Path {path} not found.")
408
+
409
+
410
+ class BridgeServer(ThreadingHTTPServer):
411
+ def __init__(
412
+ self,
413
+ config: RuntimeConfig,
414
+ manager: Manager | None = None,
415
+ bind_port: int | None = None,
416
+ heartbeat_interval: float = 10.0,
417
+ ):
418
+ self.config = config
419
+ self.manager = manager or Manager(config)
420
+ self.created_time = int(time.time())
421
+ self.heartbeat_interval = heartbeat_interval
422
+ self._stopped_event = threading.Event()
423
+ self._stop_lock = threading.Lock()
424
+ port = config.port if bind_port is None else bind_port
425
+ super().__init__(("127.0.0.1", port), BridgeHandler)
426
+ self.port = self.server_address[1]
427
+
428
+ def handle_error(self, request, client_address):
429
+ # Override to prevent emitting tracebacks to stdout/stderr
430
+ pass
431
+
432
+ def stop(self):
433
+ with self._stop_lock:
434
+ if self._stopped_event.is_set():
435
+ return
436
+ self._stopped_event.set()
437
+ try:
438
+ self.manager.close()
439
+ finally:
440
+ self.shutdown()
441
+ self.server_close()