longcache 0.1.0__py3-none-any.whl

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,146 @@
1
+ """
2
+ OpenAI integration for LongCache.
3
+
4
+ Provides CachedOpenAI that wraps the OpenAI Python client
5
+ with semantic caching, supporting both sync and async calls
6
+ as well as streaming.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Optional
12
+
13
+ from longcache import LongCache, CacheConfig
14
+
15
+
16
+ class CachedOpenAI:
17
+ """
18
+ Wraps the OpenAI Python client with semantic caching.
19
+
20
+ Intercepts chat completion calls before they reach the OpenAI API.
21
+ On cache HIT, returns the cached response instantly without an API call.
22
+
23
+ Requires the 'openai' extra: uv pip install longcache[openai]
24
+
25
+ Usage:
26
+ from openai import OpenAI
27
+ from longcache.integrations.openai import CachedOpenAI
28
+
29
+ client = CachedOpenAI(OpenAI(api_key="..."), cache=LongCache())
30
+ response = client.chat.completions.create(
31
+ model="gpt-4o",
32
+ messages=[{"role": "user", "content": "What is RAG?"}]
33
+ )
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ client: Any,
39
+ cache: Optional[LongCache] = None,
40
+ config: Optional[CacheConfig] = None,
41
+ namespace: str = "openai",
42
+ ):
43
+ """
44
+ Initialize CachedOpenAI.
45
+
46
+ Args:
47
+ client: An OpenAI client instance (sync or async).
48
+ cache: Existing LongCache instance. Creates a new one if not provided.
49
+ config: CacheConfig for the new cache (ignored if cache is provided).
50
+ namespace: Default namespace for cache entries.
51
+ """
52
+ self._client = client
53
+ self.cache = cache or LongCache(config or CacheConfig(namespace=namespace))
54
+ self._namespace = namespace
55
+
56
+ # Wrap chat.completions with caching
57
+ self.chat = CachedChatCompletions(self)
58
+
59
+ def _extract_query(self, messages: list[dict]) -> str:
60
+ """
61
+ Extract the user query from a messages list.
62
+
63
+ Args:
64
+ messages: List of message dicts with 'role' and 'content' keys.
65
+
66
+ Returns:
67
+ The content of the last user message.
68
+ """
69
+ for msg in reversed(messages):
70
+ if isinstance(msg, dict) and msg.get("role") == "user":
71
+ return msg.get("content", str(msg))
72
+ # Fallback: stringify all messages
73
+ return str(messages)
74
+
75
+ def __getattr__(self, name: str) -> Any:
76
+ """Proxy all other attributes to the underlying client."""
77
+ return getattr(self._client, name)
78
+
79
+ def __repr__(self) -> str:
80
+ return f"CachedOpenAI(client={self._client!r}, cache={self.cache!r})"
81
+
82
+
83
+ class CachedChatCompletions:
84
+ """
85
+ Cached wrapper for the chat.completions endpoint.
86
+
87
+ Provides create() methods that check the cache before making API calls.
88
+ """
89
+
90
+ def __init__(self, parent: CachedOpenAI):
91
+ """
92
+ Initialize CachedChatCompletions.
93
+
94
+ Args:
95
+ parent: The parent CachedOpenAI instance.
96
+ """
97
+ self._parent = parent
98
+
99
+ def create(self, **kwargs) -> Any:
100
+ """
101
+ Create a chat completion with caching.
102
+
103
+ Extracts the user query from messages, checks the cache,
104
+ and only calls the API on a cache MISS.
105
+
106
+ Args:
107
+ **kwargs: Arguments passed to openai.chat.completions.create().
108
+ Must include 'messages' with at least one user message.
109
+
110
+ Returns:
111
+ The chat completion response (from cache or API).
112
+ """
113
+ messages = kwargs.get("messages", [])
114
+ query = self._parent._extract_query(messages)
115
+
116
+ cached = self._parent.cache.get(query, namespace=self._parent._namespace)
117
+ if cached is not None:
118
+ return cached
119
+
120
+ result = self._parent._client.chat.completions.create(**kwargs)
121
+ self._parent.cache.set(query, result, namespace=self._parent._namespace)
122
+ return result
123
+
124
+ async def acreate(self, **kwargs) -> Any:
125
+ """
126
+ Async create a chat completion with caching.
127
+
128
+ Args:
129
+ **kwargs: Arguments passed to the async OpenAI client.
130
+
131
+ Returns:
132
+ The chat completion response (from cache or API).
133
+ """
134
+ messages = kwargs.get("messages", [])
135
+ query = self._parent._extract_query(messages)
136
+
137
+ cached = self._parent.cache.get(query, namespace=self._parent._namespace)
138
+ if cached is not None:
139
+ return cached
140
+
141
+ result = await self._parent._client.chat.completions.acreate(**kwargs)
142
+ self._parent.cache.set(query, result, namespace=self._parent._namespace)
143
+ return result
144
+
145
+ def __repr__(self) -> str:
146
+ return f"CachedChatCompletions(parent={self._parent!r})"
@@ -0,0 +1,366 @@
1
+ """
2
+ FastAPI/Starlette ASGI middleware for LongCache.
3
+
4
+ Intercepts HTTP requests to chat completion endpoints, checks the
5
+ semantic cache before forwarding to the upstream LLM API, and
6
+ auto-caches responses. Drop-in middleware for any Starlette app.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import time
13
+ from typing import Any, Callable, Optional, Set
14
+
15
+ from longcache import LongCache, CacheConfig
16
+
17
+
18
+ class LongCacheMiddleware:
19
+ """
20
+ ASGI middleware that adds semantic caching to LLM API endpoints.
21
+
22
+ Intercepts POST requests to configured routes (default: /v1/chat/completions),
23
+ extracts the user query, checks the semantic cache, and either returns
24
+ the cached response or forwards the request and caches the result.
25
+
26
+ Usage:
27
+ from fastapi import FastAPI
28
+ from longcache.middleware import LongCacheMiddleware
29
+
30
+ app = FastAPI()
31
+ app.add_middleware(LongCacheMiddleware, cache=LongCache())
32
+
33
+ Or with Starlette:
34
+ from starlette.applications import Starlette
35
+ from longcache.middleware import LongCacheMiddleware
36
+
37
+ app = Starlette(middleware=[
38
+ (LongCacheMiddleware, {"cache": LongCache()})
39
+ ])
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ app: Callable,
45
+ cache: Optional[LongCache] = None,
46
+ config: Optional[CacheConfig] = None,
47
+ namespace: str = "http",
48
+ cached_routes: Optional[Set[str]] = None,
49
+ exclude_namespaces: Optional[Set[str]] = None,
50
+ ):
51
+ """
52
+ Initialize the LongCache ASGI middleware.
53
+
54
+ Args:
55
+ app: The ASGI application to wrap.
56
+ cache: Existing LongCache instance. Creates a new one if not provided.
57
+ config: CacheConfig for the new cache (ignored if cache is provided).
58
+ namespace: Default namespace for cache entries.
59
+ cached_routes: Set of URL paths that should be cached.
60
+ Defaults to {"/v1/chat/completions", "/chat/completions"}.
61
+ exclude_namespaces: Set of namespaces to exclude from caching.
62
+ """
63
+ self.app = app
64
+ self.cache = cache or LongCache(config or CacheConfig(namespace=namespace))
65
+ self._namespace = namespace
66
+ self.cached_routes = cached_routes or {
67
+ "/v1/chat/completions",
68
+ "/chat/completions",
69
+ }
70
+ self.exclude_namespaces = exclude_namespaces or set()
71
+
72
+ async def __call__(self, scope: dict, receive: Callable, send: Callable):
73
+ """
74
+ ASGI middleware entry point.
75
+
76
+ Intercepts request/response flow for configured routes.
77
+ """
78
+ if scope["type"] != "http":
79
+ await self.app(scope, receive, send)
80
+ return
81
+
82
+ path = scope.get("path", "")
83
+ method = scope.get("method", "")
84
+
85
+ # Only intercept POST requests to cached routes
86
+ if method != "POST" or path not in self.cached_routes:
87
+ await self.app(scope, receive, send)
88
+ return
89
+
90
+ # Read the request body
91
+ body = await self._read_body(receive)
92
+
93
+ try:
94
+ request_data = json.loads(body) if body else {}
95
+ except json.JSONDecodeError:
96
+ # Not JSON, pass through without caching
97
+ await self._send_passthrough(scope, body, send)
98
+ return
99
+
100
+ # Extract the user query from messages
101
+ query = self._extract_query(request_data)
102
+ if not query:
103
+ await self._send_passthrough(scope, body, send)
104
+ return
105
+
106
+ # Check cache
107
+ cached = self.cache.get(query, namespace=self._namespace)
108
+ if cached is not None:
109
+ await self._send_cached_response(cached, send)
110
+ return
111
+
112
+ # Cache MISS — forward request and capture response
113
+ response_data = await self._forward_and_capture(scope, body, send)
114
+
115
+ # Cache the response
116
+ if response_data is not None:
117
+ self.cache.set(query, response_data, namespace=self._namespace)
118
+
119
+ def _extract_query(self, request_data: dict) -> str:
120
+ """
121
+ Extract the user query from a chat completion request.
122
+
123
+ Args:
124
+ request_data: Parsed JSON request body.
125
+
126
+ Returns:
127
+ The extracted query string, or empty string if not found.
128
+ """
129
+ messages = request_data.get("messages", [])
130
+ for msg in reversed(messages):
131
+ if isinstance(msg, dict) and msg.get("role") == "user":
132
+ return msg.get("content", "")
133
+ return ""
134
+
135
+ async def _read_body(self, receive: Callable) -> bytes:
136
+ """
137
+ Read the full request body from the ASGI receive callable.
138
+
139
+ Args:
140
+ receive: The ASGI receive callable.
141
+
142
+ Returns:
143
+ The complete request body as bytes.
144
+ """
145
+ body = b""
146
+ while True:
147
+ message = await receive()
148
+ if message["type"] == "http.request":
149
+ body += message.get("body", b"")
150
+ if not message.get("more_body", False):
151
+ break
152
+ elif message["type"] == "http.disconnect":
153
+ break
154
+ return body
155
+
156
+ async def _send_passthrough(self, scope: dict, body: bytes, send: Callable):
157
+ """
158
+ Forward the original request without caching.
159
+
160
+ Reconstructs the ASGI receive callable with the captured body
161
+ and forwards to the upstream app.
162
+ """
163
+
164
+ async def receive_with_body():
165
+ return {
166
+ "type": "http.request",
167
+ "body": body,
168
+ "more_body": False,
169
+ }
170
+
171
+ await self.app(scope, receive_with_body, send)
172
+
173
+ async def _send_cached_response(self, cached_response: Any, send: Callable):
174
+ """
175
+ Send a cached response as an HTTP response.
176
+
177
+ Args:
178
+ cached_response: The cached response data.
179
+ send: The ASGI send callable.
180
+ """
181
+ response_body = json.dumps(cached_response, default=str).encode("utf-8")
182
+
183
+ await send(
184
+ {
185
+ "type": "http.response.start",
186
+ "status": 200,
187
+ "headers": [
188
+ [b"content-type", b"application/json"],
189
+ [b"x-longcache", b"HIT"],
190
+ ],
191
+ }
192
+ )
193
+ await send(
194
+ {
195
+ "type": "http.response.body",
196
+ "body": response_body,
197
+ }
198
+ )
199
+
200
+ async def _forward_and_capture(self, scope: dict, body: bytes, send: Callable) -> Optional[Any]:
201
+ """
202
+ Forward the request to the upstream app and capture the response.
203
+
204
+ Args:
205
+ scope: The ASGI scope.
206
+ body: The request body.
207
+ send: The original ASGI send callable.
208
+
209
+ Returns:
210
+ The parsed response data if successful, None otherwise.
211
+ """
212
+ response_parts = []
213
+ response_started = False
214
+ response_status = 200
215
+
216
+ async def capturing_send(message: dict):
217
+ nonlocal response_started, response_status
218
+
219
+ if message["type"] == "http.response.start":
220
+ response_started = True
221
+ response_status = message.get("status", 200)
222
+ # Only capture headers, pass through to real send
223
+ await send(message)
224
+ elif message["type"] == "http.response.body":
225
+ response_parts.append(message.get("body", b""))
226
+ await send(message)
227
+
228
+ async def receive_with_body():
229
+ return {
230
+ "type": "http.request",
231
+ "body": body,
232
+ "more_body": False,
233
+ }
234
+
235
+ try:
236
+ await self.app(scope, receive_with_body, capturing_send)
237
+ except Exception:
238
+ return None
239
+
240
+ if response_status != 200:
241
+ return None
242
+
243
+ # Parse the captured response
244
+ full_response = b"".join(response_parts)
245
+ try:
246
+ return json.loads(full_response)
247
+ except (json.JSONDecodeError, TypeError):
248
+ return None
249
+
250
+ def __repr__(self) -> str:
251
+ return f"LongCacheMiddleware(routes={self.cached_routes}, cache={self.cache!r})"
252
+
253
+
254
+ class StreamingCacheAdapter:
255
+ """
256
+ Adapter for caching streaming LLM responses.
257
+
258
+ Collects streaming chunks into a complete response for caching,
259
+ and replays cached responses token-by-token to maintain the
260
+ streaming UX for end users.
261
+
262
+ Usage:
263
+ from longcache.middleware import StreamingCacheAdapter
264
+
265
+ def my_stream_func(query):
266
+ for chunk in llm.stream(query):
267
+ yield chunk
268
+
269
+ adapter = StreamingCacheAdapter(cache=LongCache(), stream_func=my_stream_func)
270
+ for token in adapter.stream("What is RAG?"):
271
+ print(token, end="")
272
+ """
273
+
274
+ def __init__(
275
+ self,
276
+ cache: LongCache,
277
+ stream_func: Callable,
278
+ namespace: str = "streaming",
279
+ replay_delay: float = 0.01,
280
+ ):
281
+ """
282
+ Initialize the streaming cache adapter.
283
+
284
+ Args:
285
+ cache: LongCache instance for storage.
286
+ stream_func: Callable that yields response chunks for a query.
287
+ namespace: Cache namespace for streaming entries.
288
+ replay_delay: Delay in seconds between replayed tokens
289
+ to simulate streaming pacing.
290
+ """
291
+ self.cache = cache
292
+ self.stream_func = stream_func
293
+ self._namespace = namespace
294
+ self._replay_delay = replay_delay
295
+
296
+ def stream(self, query: str):
297
+ """
298
+ Stream a response with caching.
299
+
300
+ On cache HIT, replays the cached response token-by-token.
301
+ On cache MISS, streams from the source, collects chunks,
302
+ and caches the assembled response.
303
+
304
+ Args:
305
+ query: The query string.
306
+
307
+ Yields:
308
+ Response tokens/chunks.
309
+ """
310
+ cached = self.cache.get(query, namespace=self._namespace)
311
+ if cached is not None:
312
+ # Replay cached response with realistic pacing
313
+ import time as _time
314
+ import re
315
+
316
+ cached_str = str(cached)
317
+ tokens = re.findall(r"\S+\s*", cached_str)
318
+ for token in tokens:
319
+ yield token
320
+ if self._replay_delay > 0:
321
+ _time.sleep(self._replay_delay)
322
+ return
323
+
324
+ # Stream from source and collect
325
+ collected = []
326
+ for chunk in self.stream_func(query):
327
+ collected.append(str(chunk))
328
+ yield chunk
329
+
330
+ # Cache the assembled response
331
+ assembled = "".join(collected)
332
+ self.cache.set(query, assembled, namespace=self._namespace)
333
+
334
+ async def astream(self, query: str):
335
+ """
336
+ Async stream a response with caching.
337
+
338
+ Args:
339
+ query: The query string.
340
+
341
+ Yields:
342
+ Response tokens/chunks.
343
+ """
344
+ cached = self.cache.get(query, namespace=self._namespace)
345
+ if cached is not None:
346
+ import asyncio
347
+ import re
348
+
349
+ cached_str = str(cached)
350
+ tokens = re.findall(r"\S+\s*", cached_str)
351
+ for token in tokens:
352
+ yield token
353
+ if self._replay_delay > 0:
354
+ await asyncio.sleep(self._replay_delay)
355
+ return
356
+
357
+ collected = []
358
+ async for chunk in self.stream_func(query):
359
+ collected.append(str(chunk))
360
+ yield chunk
361
+
362
+ assembled = "".join(collected)
363
+ self.cache.set(query, assembled, namespace=self._namespace)
364
+
365
+ def __repr__(self) -> str:
366
+ return f"StreamingCacheAdapter(cache={self.cache!r})"