betterdb-agent-cache 0.4.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.
- betterdb_agent_cache/__init__.py +89 -0
- betterdb_agent_cache/adapters/__init__.py +0 -0
- betterdb_agent_cache/adapters/anthropic.py +248 -0
- betterdb_agent_cache/adapters/langchain.py +189 -0
- betterdb_agent_cache/adapters/langgraph.py +361 -0
- betterdb_agent_cache/adapters/llamaindex.py +166 -0
- betterdb_agent_cache/adapters/openai.py +205 -0
- betterdb_agent_cache/adapters/openai_responses.py +212 -0
- betterdb_agent_cache/agent_cache.py +262 -0
- betterdb_agent_cache/analytics.py +113 -0
- betterdb_agent_cache/cluster.py +63 -0
- betterdb_agent_cache/default_cost_table.py +1986 -0
- betterdb_agent_cache/errors.py +15 -0
- betterdb_agent_cache/normalizer.py +157 -0
- betterdb_agent_cache/telemetry.py +136 -0
- betterdb_agent_cache/tiers/__init__.py +0 -0
- betterdb_agent_cache/tiers/llm_cache.py +311 -0
- betterdb_agent_cache/tiers/session_store.py +312 -0
- betterdb_agent_cache/tiers/tool_cache.py +274 -0
- betterdb_agent_cache/types.py +217 -0
- betterdb_agent_cache/utils.py +88 -0
- betterdb_agent_cache-0.4.0.dist-info/METADATA +34 -0
- betterdb_agent_cache-0.4.0.dist-info/RECORD +24 -0
- betterdb_agent_cache-0.4.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""LangGraph checkpoint saver adapter.
|
|
2
|
+
|
|
3
|
+
Implements LangGraph's ``BaseCheckpointSaver`` backed by the AgentCache
|
|
4
|
+
session tier. Works on vanilla Valkey 7+ — no modules, no RedisJSON, no
|
|
5
|
+
RediSearch required.
|
|
6
|
+
|
|
7
|
+
Storage layout in the session tier:
|
|
8
|
+
``{name}:session:{thread_id}:checkpoint:{checkpoint_id}`` = JSON(CheckpointTuple)
|
|
9
|
+
``{name}:session:{thread_id}:__checkpoint_latest`` = JSON(latest CheckpointTuple)
|
|
10
|
+
``{name}:session:{thread_id}:writes:{ckpt_id}|{task_id}|{channel}|{idx}`` = JSON(value)
|
|
11
|
+
|
|
12
|
+
Known limitations:
|
|
13
|
+
- The general ``alist()`` path loads all checkpoint data for a thread before
|
|
14
|
+
filtering (via ``get_all()``), which refreshes TTL via the sliding window.
|
|
15
|
+
For typical agent deployments this is fine; for threads with thousands of
|
|
16
|
+
large checkpoints consider a dedicated Redis 8+ checkpoint backend.
|
|
17
|
+
|
|
18
|
+
Usage::
|
|
19
|
+
|
|
20
|
+
from betterdb_agent_cache.adapters.langgraph import BetterDBSaver
|
|
21
|
+
|
|
22
|
+
saver = BetterDBSaver(cache=agent_cache)
|
|
23
|
+
graph = builder.compile(checkpointer=saver)
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
from collections import ChainMap
|
|
29
|
+
from typing import TYPE_CHECKING, Any, AsyncIterator, Optional
|
|
30
|
+
from urllib.parse import quote, unquote
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _make_serializable(obj: Any) -> Any:
|
|
34
|
+
"""Recursively convert to JSON-safe types.
|
|
35
|
+
|
|
36
|
+
- ChainMap → dict
|
|
37
|
+
- Unserializable leaf values are dropped (returns sentinel and caller skips them)
|
|
38
|
+
"""
|
|
39
|
+
if isinstance(obj, ChainMap):
|
|
40
|
+
return _make_serializable(dict(obj))
|
|
41
|
+
if isinstance(obj, dict):
|
|
42
|
+
out = {}
|
|
43
|
+
for k, v in obj.items():
|
|
44
|
+
converted = _make_serializable(v)
|
|
45
|
+
if converted is not _SKIP:
|
|
46
|
+
out[k] = converted
|
|
47
|
+
return out
|
|
48
|
+
if isinstance(obj, (list, tuple)):
|
|
49
|
+
return [v for v in (_make_serializable(i) for i in obj) if v is not _SKIP]
|
|
50
|
+
try:
|
|
51
|
+
json.dumps(obj)
|
|
52
|
+
return obj
|
|
53
|
+
except (TypeError, ValueError):
|
|
54
|
+
return _SKIP
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_SKIP = object() # sentinel for values that cannot be serialised
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _clean_config(config: Any) -> dict:
|
|
61
|
+
"""Return a storable copy of a LangGraph RunnableConfig.
|
|
62
|
+
|
|
63
|
+
Strips internal ``__pregel_*`` keys from ``configurable`` — these hold
|
|
64
|
+
non-serialisable runtime objects (Runtime, callbacks, etc.) that must not
|
|
65
|
+
be persisted.
|
|
66
|
+
"""
|
|
67
|
+
if not isinstance(config, dict):
|
|
68
|
+
return {}
|
|
69
|
+
configurable = dict(config.get("configurable") or {})
|
|
70
|
+
for key in list(configurable):
|
|
71
|
+
if key.startswith("__pregel_"):
|
|
72
|
+
del configurable[key]
|
|
73
|
+
return {**{k: v for k, v in config.items() if k != "configurable"},
|
|
74
|
+
"configurable": configurable}
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
from langgraph.checkpoint.base import BaseCheckpointSaver, CheckpointTuple
|
|
78
|
+
from langchain_core.runnables import RunnableConfig
|
|
79
|
+
_LANGGRAPH_AVAILABLE = True
|
|
80
|
+
except ImportError:
|
|
81
|
+
_LANGGRAPH_AVAILABLE = False
|
|
82
|
+
BaseCheckpointSaver = object # type: ignore[assignment,misc]
|
|
83
|
+
CheckpointTuple = Any # type: ignore[misc,assignment]
|
|
84
|
+
RunnableConfig = Any # type: ignore[misc,assignment]
|
|
85
|
+
|
|
86
|
+
from ..errors import AgentCacheUsageError
|
|
87
|
+
|
|
88
|
+
if TYPE_CHECKING:
|
|
89
|
+
from ..agent_cache import AgentCache
|
|
90
|
+
|
|
91
|
+
_LATEST_FIELD = "__checkpoint_latest"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class BetterDBSaver(BaseCheckpointSaver):
|
|
95
|
+
"""LangGraph checkpoint saver backed by AgentCache session storage."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, cache: "AgentCache") -> None:
|
|
98
|
+
if not _LANGGRAPH_AVAILABLE:
|
|
99
|
+
raise ImportError(
|
|
100
|
+
"langgraph and langchain-core are required for BetterDBSaver. "
|
|
101
|
+
"Install them with: pip install betterdb-agent-cache[langgraph]"
|
|
102
|
+
)
|
|
103
|
+
super().__init__()
|
|
104
|
+
self._cache = cache
|
|
105
|
+
|
|
106
|
+
# ── Read ──────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
|
109
|
+
thread_id: str = (config.get("configurable") or {}).get("thread_id", "")
|
|
110
|
+
if not thread_id:
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
checkpoint_id: str | None = (config.get("configurable") or {}).get("checkpoint_id")
|
|
114
|
+
field = f"checkpoint:{checkpoint_id}" if checkpoint_id else _LATEST_FIELD
|
|
115
|
+
|
|
116
|
+
data = await self._cache.session.get(thread_id, field)
|
|
117
|
+
if not data:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
tuple_data: dict[str, Any] = json.loads(data)
|
|
122
|
+
except (json.JSONDecodeError, ValueError):
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
resolved_id = checkpoint_id or (tuple_data.get("checkpoint") or {}).get("id")
|
|
126
|
+
if resolved_id:
|
|
127
|
+
write_fields = await self._cache.session.scan_fields_by_prefix(
|
|
128
|
+
thread_id, f"writes:{quote(resolved_id, safe='')}|"
|
|
129
|
+
)
|
|
130
|
+
pending = _extract_pending_writes(write_fields, resolved_id)
|
|
131
|
+
if pending:
|
|
132
|
+
tuple_data["pending_writes"] = pending
|
|
133
|
+
|
|
134
|
+
return _dict_to_tuple(tuple_data)
|
|
135
|
+
|
|
136
|
+
async def alist( # type: ignore[override]
|
|
137
|
+
self,
|
|
138
|
+
config: Optional[RunnableConfig],
|
|
139
|
+
*,
|
|
140
|
+
filter: Optional[dict[str, Any]] = None,
|
|
141
|
+
before: Optional[RunnableConfig] = None,
|
|
142
|
+
limit: Optional[int] = None,
|
|
143
|
+
) -> AsyncIterator[CheckpointTuple]:
|
|
144
|
+
async for item in self._alist_impl(config, filter=filter, before=before, limit=limit):
|
|
145
|
+
yield item
|
|
146
|
+
|
|
147
|
+
async def _alist_impl(
|
|
148
|
+
self,
|
|
149
|
+
config: Optional[RunnableConfig],
|
|
150
|
+
*,
|
|
151
|
+
filter: Optional[dict[str, Any]] = None,
|
|
152
|
+
before: Optional[RunnableConfig] = None,
|
|
153
|
+
limit: Optional[int] = None,
|
|
154
|
+
) -> AsyncIterator[CheckpointTuple]: # type: ignore[override]
|
|
155
|
+
thread_id: str = ((config or {}).get("configurable") or {}).get("thread_id", "")
|
|
156
|
+
if not thread_id:
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
# Fast path: limit=1, no before filter, no metadata filter → read the latest pointer directly
|
|
160
|
+
if limit == 1 and not before and not filter:
|
|
161
|
+
latest = await self._cache.session.get(thread_id, _LATEST_FIELD)
|
|
162
|
+
if latest:
|
|
163
|
+
try:
|
|
164
|
+
td: dict[str, Any] = json.loads(latest)
|
|
165
|
+
ckpt_id = (td.get("checkpoint") or {}).get("id")
|
|
166
|
+
if ckpt_id:
|
|
167
|
+
write_fields = await self._cache.session.scan_fields_by_prefix(
|
|
168
|
+
thread_id, f"writes:{quote(ckpt_id, safe='')}|"
|
|
169
|
+
)
|
|
170
|
+
pending = _extract_pending_writes(write_fields, ckpt_id)
|
|
171
|
+
if pending:
|
|
172
|
+
td["pending_writes"] = pending
|
|
173
|
+
yield _dict_to_tuple(td)
|
|
174
|
+
except (json.JSONDecodeError, ValueError):
|
|
175
|
+
pass
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
# General path: load all fields, partition into checkpoints and writes
|
|
179
|
+
all_fields = await self._cache.session.get_all(thread_id)
|
|
180
|
+
write_map: dict[str, str] = {}
|
|
181
|
+
checkpoints: list[dict[str, Any]] = []
|
|
182
|
+
|
|
183
|
+
for field_name, value in all_fields.items():
|
|
184
|
+
if field_name.startswith("writes:"):
|
|
185
|
+
write_map[field_name] = value
|
|
186
|
+
elif field_name.startswith("checkpoint:"):
|
|
187
|
+
try:
|
|
188
|
+
checkpoints.append(json.loads(value))
|
|
189
|
+
except (json.JSONDecodeError, ValueError):
|
|
190
|
+
pass
|
|
191
|
+
|
|
192
|
+
# Attach pending writes
|
|
193
|
+
for td in checkpoints:
|
|
194
|
+
ckpt_id = (td.get("checkpoint") or {}).get("id")
|
|
195
|
+
if ckpt_id:
|
|
196
|
+
pending = _extract_pending_writes(write_map, ckpt_id)
|
|
197
|
+
if pending:
|
|
198
|
+
td["pending_writes"] = pending
|
|
199
|
+
|
|
200
|
+
# Sort by timestamp descending
|
|
201
|
+
def _ts_key(td: dict[str, Any]) -> tuple[int, str]:
|
|
202
|
+
ts: str = (td.get("checkpoint") or {}).get("ts", "")
|
|
203
|
+
try:
|
|
204
|
+
from datetime import datetime, timezone
|
|
205
|
+
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
206
|
+
return (1, dt.isoformat())
|
|
207
|
+
except (ValueError, AttributeError):
|
|
208
|
+
return (0, ts)
|
|
209
|
+
|
|
210
|
+
checkpoints.sort(key=_ts_key, reverse=True)
|
|
211
|
+
|
|
212
|
+
# Apply before filter
|
|
213
|
+
before_id: str | None = ((before or {}).get("configurable") or {}).get("checkpoint_id")
|
|
214
|
+
if before_id and not any((td.get("checkpoint") or {}).get("id") == before_id for td in checkpoints):
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
started = before_id is None
|
|
218
|
+
yielded = 0
|
|
219
|
+
|
|
220
|
+
for td in checkpoints:
|
|
221
|
+
if not started:
|
|
222
|
+
if (td.get("checkpoint") or {}).get("id") == before_id:
|
|
223
|
+
started = True
|
|
224
|
+
continue
|
|
225
|
+
if limit is not None and yielded >= limit:
|
|
226
|
+
break
|
|
227
|
+
# Apply metadata filter if provided
|
|
228
|
+
if filter:
|
|
229
|
+
meta = td.get("metadata") or {}
|
|
230
|
+
if not all(meta.get(k) == v for k, v in filter.items()):
|
|
231
|
+
continue
|
|
232
|
+
yield _dict_to_tuple(td)
|
|
233
|
+
yielded += 1
|
|
234
|
+
|
|
235
|
+
# ── Write ─────────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
async def aput(
|
|
238
|
+
self,
|
|
239
|
+
config: RunnableConfig,
|
|
240
|
+
checkpoint: Any,
|
|
241
|
+
metadata: Any,
|
|
242
|
+
new_versions: Any,
|
|
243
|
+
) -> RunnableConfig:
|
|
244
|
+
thread_id = (config.get("configurable") or {}).get("thread_id")
|
|
245
|
+
if not thread_id:
|
|
246
|
+
raise AgentCacheUsageError("aput() requires config['configurable']['thread_id']")
|
|
247
|
+
|
|
248
|
+
checkpoint_id = checkpoint.get("id") if isinstance(checkpoint, dict) else getattr(checkpoint, "id", None)
|
|
249
|
+
parent_config = _clean_config(config)
|
|
250
|
+
parent_checkpoint_id = (parent_config.get("configurable") or {}).get("checkpoint_id")
|
|
251
|
+
clean = _clean_config(config)
|
|
252
|
+
clean.setdefault("configurable", {})["checkpoint_id"] = checkpoint_id
|
|
253
|
+
stored = {
|
|
254
|
+
"config": clean,
|
|
255
|
+
"checkpoint": checkpoint,
|
|
256
|
+
"metadata": metadata,
|
|
257
|
+
"parent_config": parent_config if parent_checkpoint_id else None,
|
|
258
|
+
}
|
|
259
|
+
serialised = json.dumps(_make_serializable(stored))
|
|
260
|
+
|
|
261
|
+
await self._cache.session.set(thread_id, f"checkpoint:{checkpoint_id}", serialised)
|
|
262
|
+
await self._cache.session.set(thread_id, _LATEST_FIELD, serialised)
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
**config,
|
|
266
|
+
"configurable": {**(config.get("configurable") or {}), "checkpoint_id": checkpoint_id},
|
|
267
|
+
} # return original (with runtime objects) so LangGraph can continue using it
|
|
268
|
+
|
|
269
|
+
async def aput_writes(
|
|
270
|
+
self,
|
|
271
|
+
config: RunnableConfig,
|
|
272
|
+
writes: list[tuple[str, Any]],
|
|
273
|
+
task_id: str,
|
|
274
|
+
) -> None:
|
|
275
|
+
thread_id = (config.get("configurable") or {}).get("thread_id")
|
|
276
|
+
checkpoint_id = (config.get("configurable") or {}).get("checkpoint_id")
|
|
277
|
+
if not thread_id or not checkpoint_id:
|
|
278
|
+
raise AgentCacheUsageError(
|
|
279
|
+
"aput_writes() requires both thread_id and checkpoint_id in config['configurable']"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
encoded_ckpt = quote(checkpoint_id, safe="")
|
|
283
|
+
encoded_task = quote(task_id, safe="")
|
|
284
|
+
import asyncio
|
|
285
|
+
|
|
286
|
+
await asyncio.gather(*(
|
|
287
|
+
self._cache.session.set(
|
|
288
|
+
thread_id,
|
|
289
|
+
f"writes:{encoded_ckpt}|{encoded_task}|{quote(channel, safe='')}|{i}",
|
|
290
|
+
json.dumps(_make_serializable(value)),
|
|
291
|
+
)
|
|
292
|
+
for i, (channel, value) in enumerate(writes)
|
|
293
|
+
))
|
|
294
|
+
|
|
295
|
+
# ── Delete ────────────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
async def adelete_thread(self, thread_id: str) -> None:
|
|
298
|
+
await self._cache.session.destroy_thread(thread_id)
|
|
299
|
+
|
|
300
|
+
# ── Sync stubs (not supported) ────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]: # type: ignore[override]
|
|
303
|
+
raise RuntimeError("BetterDBSaver is async-only. Use aget_tuple().")
|
|
304
|
+
|
|
305
|
+
def list(self, config: Any, **kwargs: Any) -> Any: # type: ignore[override]
|
|
306
|
+
raise RuntimeError("BetterDBSaver is async-only. Use alist().")
|
|
307
|
+
|
|
308
|
+
def put(self, *args: Any, **kwargs: Any) -> Any: # type: ignore[override]
|
|
309
|
+
raise RuntimeError("BetterDBSaver is async-only. Use aput().")
|
|
310
|
+
|
|
311
|
+
def put_writes(self, *args: Any, **kwargs: Any) -> None: # type: ignore[override]
|
|
312
|
+
raise RuntimeError("BetterDBSaver is async-only. Use aput_writes().")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
def _extract_pending_writes(
|
|
318
|
+
all_fields: dict[str, str],
|
|
319
|
+
checkpoint_id: str,
|
|
320
|
+
) -> list[tuple[str, str, Any]]:
|
|
321
|
+
"""Reconstruct pending writes from session fields.
|
|
322
|
+
|
|
323
|
+
Key format: writes:{encoded_ckpt_id}|{encoded_task_id}|{encoded_channel}|{idx}
|
|
324
|
+
All components are URL-encoded; literal ``|`` inside values appears as ``%7C``.
|
|
325
|
+
"""
|
|
326
|
+
prefix = f"writes:{quote(checkpoint_id, safe='')}|"
|
|
327
|
+
items: list[tuple[int, tuple[str, str, Any]]] = []
|
|
328
|
+
|
|
329
|
+
for field, raw_value in all_fields.items():
|
|
330
|
+
if not field.startswith(prefix):
|
|
331
|
+
continue
|
|
332
|
+
rest = field[len(prefix):]
|
|
333
|
+
parts = rest.split("|")
|
|
334
|
+
if len(parts) != 3:
|
|
335
|
+
continue
|
|
336
|
+
task_id = unquote(parts[0])
|
|
337
|
+
channel = unquote(parts[1])
|
|
338
|
+
try:
|
|
339
|
+
idx = int(parts[2])
|
|
340
|
+
value = json.loads(raw_value)
|
|
341
|
+
items.append((idx, (task_id, channel, value)))
|
|
342
|
+
except (ValueError, json.JSONDecodeError):
|
|
343
|
+
pass
|
|
344
|
+
|
|
345
|
+
items.sort(key=lambda x: x[0])
|
|
346
|
+
return [write for _, write in items]
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _dict_to_tuple(data: dict[str, Any]) -> Any:
|
|
350
|
+
"""Convert a stored dict back to a CheckpointTuple."""
|
|
351
|
+
try:
|
|
352
|
+
return CheckpointTuple(
|
|
353
|
+
config=data.get("config"),
|
|
354
|
+
checkpoint=data.get("checkpoint"),
|
|
355
|
+
metadata=data.get("metadata"),
|
|
356
|
+
parent_config=data.get("parent_config"),
|
|
357
|
+
pending_writes=data.get("pending_writes"),
|
|
358
|
+
)
|
|
359
|
+
except Exception:
|
|
360
|
+
# Fallback: return as-is if CheckpointTuple constructor doesn't match
|
|
361
|
+
return data
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""LlamaIndex adapter.
|
|
2
|
+
|
|
3
|
+
Converts a list of ``ChatMessage`` dicts (as used by LlamaIndex) into
|
|
4
|
+
``LlmCacheParams``.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from betterdb_agent_cache.adapters.llamaindex import prepare_params
|
|
9
|
+
|
|
10
|
+
params = await prepare_params(messages, model="gpt-4o")
|
|
11
|
+
result = await cache.llm.check(params)
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from ..normalizer import BinaryNormalizer, default_normalizer
|
|
20
|
+
from ..types import BinaryBlock, ContentBlock, LlmCacheParams, TextBlock, ToolCallBlock
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class LlamaIndexPrepareOptions:
|
|
25
|
+
model: str = ""
|
|
26
|
+
normalizer: BinaryNormalizer = field(default_factory=lambda: default_normalizer)
|
|
27
|
+
temperature: float | None = None
|
|
28
|
+
top_p: float | None = None
|
|
29
|
+
max_tokens: int | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parse_input(value: Any) -> Any:
|
|
33
|
+
if isinstance(value, str):
|
|
34
|
+
try:
|
|
35
|
+
return json.loads(value)
|
|
36
|
+
except (json.JSONDecodeError, ValueError):
|
|
37
|
+
return {"__raw": value}
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def _normalize_detail(
|
|
42
|
+
part: dict[str, Any],
|
|
43
|
+
normalizer: BinaryNormalizer,
|
|
44
|
+
) -> ContentBlock | None:
|
|
45
|
+
t = part.get("type")
|
|
46
|
+
|
|
47
|
+
if t == "text":
|
|
48
|
+
return {"type": "text", "text": part.get("text") or ""}
|
|
49
|
+
|
|
50
|
+
if t == "image_url" and part.get("image_url"):
|
|
51
|
+
url: str = part["image_url"]["url"]
|
|
52
|
+
media_type = "image/*"
|
|
53
|
+
if url.startswith("data:"):
|
|
54
|
+
semi = url.find(";")
|
|
55
|
+
if semi > 5:
|
|
56
|
+
media_type = url[5:semi]
|
|
57
|
+
source: dict[str, Any] = {"type": "base64", "data": url}
|
|
58
|
+
else:
|
|
59
|
+
source = {"type": "url", "url": url}
|
|
60
|
+
ref = await normalizer({"kind": "image", "source": source})
|
|
61
|
+
return {"type": "binary", "kind": "image", "mediaType": media_type, "ref": ref}
|
|
62
|
+
|
|
63
|
+
if t == "file" and part.get("data"):
|
|
64
|
+
ref = await normalizer({
|
|
65
|
+
"kind": "document",
|
|
66
|
+
"source": {"type": "base64", "data": part["data"]},
|
|
67
|
+
})
|
|
68
|
+
return {
|
|
69
|
+
"type": "binary", "kind": "document",
|
|
70
|
+
"mediaType": part.get("mime_type") or "application/octet-stream",
|
|
71
|
+
"ref": ref,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if t in ("audio", "image") and part.get("data"):
|
|
75
|
+
kind = "audio" if t == "audio" else "image"
|
|
76
|
+
ref = await normalizer({
|
|
77
|
+
"kind": kind,
|
|
78
|
+
"source": {"type": "base64", "data": part["data"]},
|
|
79
|
+
})
|
|
80
|
+
default_media = "audio/*" if kind == "audio" else "image/*"
|
|
81
|
+
return {
|
|
82
|
+
"type": "binary", "kind": kind,
|
|
83
|
+
"mediaType": part.get("mime_type") or default_media,
|
|
84
|
+
"ref": ref,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def prepare_params(
|
|
91
|
+
messages: list[dict[str, Any]],
|
|
92
|
+
opts: LlamaIndexPrepareOptions | None = None,
|
|
93
|
+
*,
|
|
94
|
+
model: str | None = None,
|
|
95
|
+
normalizer: BinaryNormalizer | None = None,
|
|
96
|
+
temperature: float | None = None,
|
|
97
|
+
top_p: float | None = None,
|
|
98
|
+
max_tokens: int | None = None,
|
|
99
|
+
) -> LlmCacheParams:
|
|
100
|
+
"""Normalise a LlamaIndex message list to ``LlmCacheParams``.
|
|
101
|
+
|
|
102
|
+
Either pass an ``LlamaIndexPrepareOptions`` instance or use the keyword
|
|
103
|
+
arguments directly::
|
|
104
|
+
|
|
105
|
+
params = await prepare_params(msgs, model="gpt-4o", temperature=0.7)
|
|
106
|
+
"""
|
|
107
|
+
if opts is None:
|
|
108
|
+
opts = LlamaIndexPrepareOptions(
|
|
109
|
+
model=model or "",
|
|
110
|
+
normalizer=normalizer or default_normalizer,
|
|
111
|
+
temperature=temperature,
|
|
112
|
+
top_p=top_p,
|
|
113
|
+
max_tokens=max_tokens,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
norm = opts.normalizer
|
|
117
|
+
out: list[Any] = []
|
|
118
|
+
|
|
119
|
+
for msg in messages:
|
|
120
|
+
options: dict[str, Any] = msg.get("options") or {}
|
|
121
|
+
|
|
122
|
+
if options.get("tool_result"):
|
|
123
|
+
tr = options["tool_result"]
|
|
124
|
+
out.append({
|
|
125
|
+
"role": "tool",
|
|
126
|
+
"toolCallId": tr["id"],
|
|
127
|
+
"content": [{"type": "text", "text": tr.get("result", "")}],
|
|
128
|
+
})
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
raw_role: str = msg.get("role", "user")
|
|
132
|
+
role = "system" if raw_role in ("memory", "developer") else raw_role
|
|
133
|
+
|
|
134
|
+
blocks: list[ContentBlock] = []
|
|
135
|
+
content = msg.get("content")
|
|
136
|
+
if isinstance(content, str):
|
|
137
|
+
if content:
|
|
138
|
+
blocks.append({"type": "text", "text": content})
|
|
139
|
+
elif isinstance(content, list):
|
|
140
|
+
for part in content:
|
|
141
|
+
b = await _normalize_detail(part, norm)
|
|
142
|
+
if b is not None:
|
|
143
|
+
blocks.append(b)
|
|
144
|
+
|
|
145
|
+
tool_calls = options.get("tool_call")
|
|
146
|
+
if tool_calls:
|
|
147
|
+
calls = tool_calls if isinstance(tool_calls, list) else [tool_calls]
|
|
148
|
+
for tc in calls:
|
|
149
|
+
blocks.append({
|
|
150
|
+
"type": "tool_call",
|
|
151
|
+
"id": tc["id"],
|
|
152
|
+
"name": tc["name"],
|
|
153
|
+
"args": _parse_input(tc.get("input")),
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
out.append({"role": role, "content": blocks})
|
|
157
|
+
|
|
158
|
+
result: LlmCacheParams = {"model": opts.model, "messages": out}
|
|
159
|
+
if opts.temperature is not None:
|
|
160
|
+
result["temperature"] = opts.temperature
|
|
161
|
+
if opts.top_p is not None:
|
|
162
|
+
result["top_p"] = opts.top_p
|
|
163
|
+
if opts.max_tokens is not None:
|
|
164
|
+
result["max_tokens"] = opts.max_tokens
|
|
165
|
+
|
|
166
|
+
return result
|