-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathapi_wrapper.py
More file actions
331 lines (282 loc) · 11.1 KB
/
Copy pathapi_wrapper.py
File metadata and controls
331 lines (282 loc) · 11.1 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
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
"""
FastAPI proxy server for Ollama API with rate limiting and authentication.
This module implements a proxy server that forwards requests to an Ollama API instance,
adding authentication and rate limiting capabilities.
"""
import httpx
import asyncio
from fastapi import FastAPI, Request, HTTPException, Depends
from fastapi.responses import JSONResponse, PlainTextResponse
from APIKeyManager import APIKeyManager
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from slowapi import Limiter
import logging
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler()
]
)
# Create a logger
logger = logging.getLogger(__name__)
# Initialize the FastAPI app
app = FastAPI()
# Initialize API key management
API_KEY_MANAGER = APIKeyManager()
# Print API key information and security warnings
print("\n" + "="*50)
print(" " * 5 + "⚠️ IMPORTANT: API Key Information ⚠️" + " " * 5)
print("="*50)
print("\n" + " " * 3 + f" Session API Key: {API_KEY_MANAGER.api_key} " + "\n")
print("="*50)
print("\nNOTE:")
print("- Do not share your API key publicly.")
print("- Avoid committing API keys in code repositories.")
print("- If exposed, reset and replace it immediately.\n")
print("="*50 + "\n")
#: str: The URL of the internal Ollama API service
OLLAMA_URL = "http://ollama:11434"
#: Limiter: Rate limiter instance configured with default limits
limiter = Limiter(
key_func=get_remote_address,
default_limits=["1/second"]
)
@app.middleware("http")
async def rate_limit_middleware(request, call_next):
"""
Middleware for handling rate limiting of requests.
:param request: The incoming HTTP request
:type request: Request
:param call_next: The next middleware or endpoint in the chain
:type call_next: callable
:return: HTTP response with rate limiting applied
:rtype: Response
"""
try:
response = await call_next(request)
return response
except RateLimitExceeded:
return PlainTextResponse(
"Rate limit exceeded. Try again later.",
status_code=429
)
@app.get("/health")
@limiter.limit("1/second")
async def health_check(request: Request):
"""
Health check endpoint to verify the service is running.
:param request: The incoming HTTP request
:type request: Request
:return: JSON response indicating service status
:rtype: JSONResponse
"""
return JSONResponse(content={"status": "ok"})
@app.api_route("/{path:path}", methods=["GET", "POST"], dependencies=[Depends(API_KEY_MANAGER.verify_api_key)])
@limiter.limit("10/second")
async def proxy_request(path: str, request: Request):
"""
Proxy endpoint that forwards requests to the Ollama API.
Handles both GET and POST requests with proper client disconnection handling.
:param path: The API path to forward to
:type path: str
:param request: The incoming HTTP request
:type request: Request
:return: Proxied response from Ollama API
:rtype: JSONResponse
"""
async with RequestHandler(request, path) as handler:
return await handler.execute()
class RequestHandler:
"""
Handles request forwarding and client disconnection monitoring.
This class manages the lifecycle of HTTP requests forwarded to the Ollama API,
including proper cleanup and client disconnection detection.
"""
def __init__(self, request: Request, path: str):
"""
Initialize the request handler.
:param request: The FastAPI request object
:type request: Request
:param path: The API path to forward to
:type path: str
"""
self.request = request
self.path = path
self.client = None
self.disconnect_event = asyncio.Event()
async def __aenter__(self):
"""
Async context manager entry point.
:return: The RequestHandler instance
:rtype: RequestHandler
"""
timeout = httpx.Timeout(timeout=180.0, connect=10.0, read=180.0, write=180.0, pool=180.0)
self.client = httpx.AsyncClient(timeout=timeout)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""
Async context manager exit point.
:param exc_type: Exception type if an exception occurred
:type exc_type: type or None
:param exc_val: Exception value if an exception occurred
:type exc_val: Exception or None
:param exc_tb: Exception traceback if an exception occurred
:type exc_tb: traceback or None
"""
if self.client:
await self.client.aclose()
async def listen_for_disconnect(self):
"""
Listen for client disconnect events.
Monitors the request stream for disconnect messages and sets the
disconnect event when a disconnection is detected.
"""
try:
while not self.disconnect_event.is_set():
message = await self.request.receive()
if message.get("type") == "http.disconnect":
self.disconnect_event.set()
break
except Exception:
self.disconnect_event.set()
async def check_client_disconnect(self):
"""
Monitor client connection status.
Periodically checks if the client is still connected and sets the
disconnect event if a disconnection is detected.
:return: True when client disconnects
:rtype: bool
"""
while not self.disconnect_event.is_set():
if await self.request.is_disconnected():
self.disconnect_event.set()
return True
await asyncio.sleep(0.1)
return True
def _filter_headers(self, headers):
"""
Filter out problematic headers for forwarding.
Removes headers that should not be forwarded to the upstream service
to prevent conflicts or issues.
:param headers: Original request headers
:type headers: dict
:return: Filtered headers safe for forwarding
:rtype: dict
"""
return {
k: v for k, v in headers.items()
if k.lower() not in ['host', 'content-length', 'transfer-encoding']
}
async def _make_request(self):
"""
Create the appropriate HTTP request based on method.
Constructs and sends either a GET or POST request to the Ollama API
with properly filtered headers and request data.
:return: HTTP response from the Ollama API
:rtype: httpx.Response
"""
headers = self._filter_headers(self.request.headers)
if self.request.method == "GET":
return await self.client.get(
f"{OLLAMA_URL}/{self.path}",
headers=headers,
params=self.request.query_params
)
elif self.request.method == "POST":
body = await self.request.json()
return await self.client.post(
f"{OLLAMA_URL}/{self.path}",
headers=headers,
json=body
)
async def _handle_response(self, response):
"""
Process the response from Ollama service.
Converts the HTTP response to a JSONResponse, handling JSON parsing
errors gracefully by falling back to text content.
:param response: The HTTP response from Ollama
:type response: httpx.Response
:return: Formatted JSON response
:rtype: JSONResponse
"""
try:
response_content = response.json()
except (ValueError, TypeError) as e:
logger.exception(f"JSON decode error: {e}")
response_content = response.text
return JSONResponse(
content=response_content,
status_code=response.status_code
)
async def execute(self):
"""
Execute the request with proper error handling and cancellation.
Orchestrates the request forwarding process with concurrent monitoring
for client disconnections. Handles various error conditions and ensures
proper cleanup of resources.
:return: HTTP response or error response
:rtype: JSONResponse or HTTPException
"""
try:
# Set up concurrent tasks
tasks = [
asyncio.create_task(self._make_request()),
asyncio.create_task(self.check_client_disconnect()),
asyncio.create_task(self.listen_for_disconnect())
]
# Wait for first completion
done, pending = await asyncio.wait(
tasks, return_when=asyncio.FIRST_COMPLETED
)
# Cancel pending tasks
for task in pending:
task.cancel()
# Clean up cancelled tasks
if pending:
await asyncio.gather(*pending, return_exceptions=True)
# Check results
for task in done:
if task == tasks[0]: # Request task completed
try:
response = task.result()
return await self._handle_response(response)
except httpx.ReadError as e:
logger.exception(f"Read error from Ollama service: {e}")
return JSONResponse(
content={"error": "Connection error to Ollama service"},
status_code=502
)
except Exception as e:
logger.exception(f"Error getting response: {e}")
return JSONResponse(
content={"error": str(e)},
status_code=500
)
else: # Client disconnected
raise HTTPException(status_code=499, detail="Client disconnected")
# Shouldn't reach here
raise HTTPException(status_code=500, detail="Unexpected state")
except asyncio.CancelledError as e:
raise HTTPException(status_code=499, detail="Request cancelled") from e
except httpx.ReadError as e:
logger.exception(f"HTTP Read error: {e}")
return JSONResponse(
content={"error": "Connection error to Ollama service"},
status_code=502
)
except httpx.TimeoutException:
return JSONResponse(
content={"error": "Request to Ollama service timed out"},
status_code=504
)
except HTTPException as e:
raise e
except Exception as e:
logger.exception(f"Unexpected error: {e}")
return JSONResponse(
content={"error": str(e)},
status_code=500
)