memtrust-cli 0.3.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.
Files changed (42) hide show
  1. memtrust/__init__.py +11 -0
  2. memtrust/adapters/__init__.py +62 -0
  3. memtrust/adapters/base.py +2020 -0
  4. memtrust/adapters/mem0_adapter.py +456 -0
  5. memtrust/adapters/mem0_direct_adapter.py +1217 -0
  6. memtrust/adapters/mempalace_adapter.py +1166 -0
  7. memtrust/adapters/openviking_adapter.py +570 -0
  8. memtrust/adapters/zep_graphiti_adapter.py +181 -0
  9. memtrust/adapters/zep_graphiti_selfhosted_adapter.py +882 -0
  10. memtrust/cli.py +1201 -0
  11. memtrust/evals/__init__.py +5 -0
  12. memtrust/evals/compression.py +235 -0
  13. memtrust/evals/contradiction.py +451 -0
  14. memtrust/evals/crash_recovery.py +290 -0
  15. memtrust/evals/embedder_cost.py +163 -0
  16. memtrust/evals/embedding_drift.py +273 -0
  17. memtrust/evals/episode_temporal_leak.py +182 -0
  18. memtrust/evals/extraction_quality.py +373 -0
  19. memtrust/evals/filter_injection.py +371 -0
  20. memtrust/evals/language_degradation.py +189 -0
  21. memtrust/evals/lock_contention.py +263 -0
  22. memtrust/evals/locomo.py +392 -0
  23. memtrust/evals/longmemeval.py +249 -0
  24. memtrust/evals/mempalace_metadata_scale.py +494 -0
  25. memtrust/evals/migration_rollback.py +276 -0
  26. memtrust/evals/orphan_cleanup.py +235 -0
  27. memtrust/evals/ranking_quality.py +303 -0
  28. memtrust/evals/resource_sync_safety.py +336 -0
  29. memtrust/evals/result_consistency.py +239 -0
  30. memtrust/evals/scale_fixtures.py +189 -0
  31. memtrust/evals/scale_stress.py +502 -0
  32. memtrust/evals/stats_accuracy.py +240 -0
  33. memtrust/evals/temporal_kg_boundary.py +281 -0
  34. memtrust/receipt.py +318 -0
  35. memtrust/scoring/__init__.py +1 -0
  36. memtrust/scoring/cost_tracker.py +199 -0
  37. memtrust/scoring/llm_judge.py +161 -0
  38. memtrust_cli-0.3.0.dist-info/METADATA +624 -0
  39. memtrust_cli-0.3.0.dist-info/RECORD +42 -0
  40. memtrust_cli-0.3.0.dist-info/WHEEL +4 -0
  41. memtrust_cli-0.3.0.dist-info/entry_points.txt +2 -0
  42. memtrust_cli-0.3.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,456 @@
1
+ """Adapters for Mem0 (https://mem0.ai): hosted Platform API and self-hosted
2
+ OSS server.
3
+
4
+ `Mem0Adapter` (hosted, this module's original adapter) and
5
+ `Mem0SelfHostedAdapter` (below) are deliberately two separate classes, not
6
+ one adapter with a deployment flag, following the precedent this codebase
7
+ already set for Zep/Graphiti in docs/methodology.md ("If self-hosted
8
+ Graphiti support is wanted later, it should be a second adapter ... with
9
+ its own configuration story, not a silent branch inside this one"). The
10
+ two Mem0 deployments have different base URLs, different route prefixes,
11
+ different auth defaults, and different confidence levels -- collapsing
12
+ them into one class with an if/else would hide that from callers and from
13
+ the confidence table in docs/methodology.md.
14
+
15
+ ## Mem0Adapter (hosted Platform API)
16
+
17
+ Confidence: HIGH. Mem0 publishes a documented Python SDK (`mem0ai` on PyPI)
18
+ built around `MemoryClient(api_key=...)` with `.add()`, `.search()`, and
19
+ `.update()` methods, and reads `MEM0_API_KEY` from the environment when no
20
+ key is passed explicitly (docs.mem0.ai/platform/quickstart, June 2026 SDK
21
+ v2.0.8 release notes). This adapter talks to the same REST surface the
22
+ official SDK wraps, using httpx directly rather than depending on the
23
+ `mem0ai` package, to keep memtrust's own dependency tree small and to keep
24
+ every HTTP call independently mockable in tests without a vendor SDK
25
+ installed. Endpoint paths below are best-effort reconstructions of the
26
+ REST API the documented SDK calls sit on top of -- see
27
+ docs/methodology.md for what is confirmed vs. inferred.
28
+
29
+ Mem0's own memory pipeline decides, per stored fact, whether to ADD a new
30
+ memory, UPDATE an existing one, or leave it alone -- this is documented
31
+ Mem0 behavior, not a memtrust assumption, and it is exactly the kind of
32
+ vendor-side conflict resolution the contradiction-detection eval is built
33
+ to observe rather than short-circuit.
34
+
35
+ ## Mem0SelfHostedAdapter (self-hosted OSS `server/` FastAPI wrapper)
36
+
37
+ Confidence: MEDIUM-HIGH on route shape and request/response models, LOW on
38
+ end-to-end live behavior. Unlike the hosted adapter above (reconstructed
39
+ from SDK docs) or MemPalace/OpenViking below (reconstructed from product
40
+ docs that could not be fully fetched), this adapter's route shape was
41
+ confirmed by fetching the *actual source code* of `server/main.py` and
42
+ `server/auth.py` from the `mem0ai/mem0` GitHub repository's `main` branch
43
+ directly (`raw.githubusercontent.com/mem0ai/mem0/main/server/...`) during
44
+ this build, 2026-07-11. That is stronger evidence than a documentation
45
+ page, but it is still: (a) a snapshot of an unpinned `main` branch that
46
+ can drift out from under any specific self-hosted deployment's actual
47
+ server version, and (b) never exercised against a live running instance
48
+ in this environment -- no self-hosted mem0 server was started and hit
49
+ with a real HTTP request during this build. Confirmed from that source
50
+ read:
51
+
52
+ * Routes are unprefixed -- `POST /memories`, `GET /memories`,
53
+ `GET /memories/{id}`, `PUT /memories/{id}`, `DELETE /memories/{id}`,
54
+ `DELETE /memories`, `POST /search`, `GET /memories/{id}/history`,
55
+ `POST /reset` -- there is no `/v1/...` prefix anywhere in this
56
+ router, unlike the hosted Platform API.
57
+ * `POST /memories` request body (`MemoryCreate`): `messages`, `user_id`,
58
+ `agent_id`, `run_id`, `metadata`, `expiration_date`, `infer`,
59
+ `memory_type`, `prompt`. The handler 400s unless at least one of
60
+ `user_id`/`agent_id`/`run_id` is set.
61
+ * `POST /search` request body (`SearchRequest`): `query`, `filters`
62
+ (a dict), `top_k`, `threshold`, `explain`, `show_expired`, plus
63
+ deprecated top-level `user_id`/`run_id`/`agent_id` fields that the
64
+ handler merges into `filters` -- but only `if entity_val:`, a
65
+ *truthy* check, not `is not None`. That means the self-hosted
66
+ server's own deprecated-field merge path silently drops a
67
+ deliberately-empty-string `run_id`/`agent_id` instead of scoping the
68
+ filter to "must be empty" -- this is the concrete, source-confirmed
69
+ shape of the entity-id filter-scoping issue (mem0ai/mem0#5973) that
70
+ this adapter exists to make reachable. This adapter always sends
71
+ `run_id`/`agent_id` inside the `filters` dict directly (never through
72
+ the deprecated top-level fields) using an `is not None` check, so a
73
+ caller's deliberate empty string reaches the server's filter-matching
74
+ logic intact rather than being dropped by memtrust itself -- whether
75
+ the self-hosted `Memory.search()` implementation then handles that
76
+ empty-string filter correctly is exactly what the eval is meant to
77
+ observe, not something this adapter should paper over.
78
+ * `PUT /memories/{id}` body (`MemoryUpdate`): `text`, `metadata`,
79
+ `expiration_date`.
80
+ * No auth by default: `verify_auth` requires a bearer JWT or
81
+ `X-API-Key` header unless `AUTH_DISABLED` is set server-side, and
82
+ the project's own Docker self-hosting guide (mem0.ai/blog/
83
+ self-host-mem0-docker) ships `AUTH_DISABLED` on by default for local
84
+ use, mapping the container to `localhost:8888`. That is why this
85
+ adapter's required configuration is a base URL, not an API key (the
86
+ same reasoning docs/methodology.md already gives for
87
+ `MEMPALACE_STORAGE_PATH`), with an optional API key for deployments
88
+ that front the server with their own auth.
89
+
90
+ Not confirmed by this build, and explicitly out of scope for this change:
91
+ the exact JSON shape `Memory.search()`/`Memory.add()` return (the FastAPI
92
+ handlers pass that dict through unmodified, so this adapter reuses the
93
+ hosted adapter's `{"results": [...]}` parsing, which is the OSS `mem0ai`
94
+ SDK's own documented response shape, but that specific field name was not
95
+ re-verified against `server/main.py` beyond what's shown above); the
96
+ `DELETE /memories` multi-filter route that mem0ai/mem0#5936/#5970
97
+ describe as truncating results (there is no `delete()` method on
98
+ `MemoryBackendAdapter` for this adapter to implement it against, and
99
+ adding one is a larger interface change than this backlog item scopes to
100
+ -- see docs/methodology.md); and the embedding-dimension-mismatch failure
101
+ mem0ai/mem0#4297 describes, which lives entirely in self-hosted vector
102
+ store configuration this adapter has no surface to trigger or observe --
103
+ merely routing eval traffic at a self-hosted instance is what makes that
104
+ bug class newly *reachable* by a memtrust user, not something this
105
+ adapter's code exercises directly.
106
+ """
107
+
108
+ from __future__ import annotations
109
+
110
+ import os
111
+
112
+ import httpx
113
+
114
+ from memtrust.adapters.base import (
115
+ BackendAPIError,
116
+ BackendNotConfiguredError,
117
+ ConflictSignal,
118
+ DeleteResult,
119
+ ExtractionSignal,
120
+ MemoryBackendAdapter,
121
+ MemoryRecord,
122
+ QueryResult,
123
+ StoreResult,
124
+ UpdateResult,
125
+ )
126
+
127
+ DEFAULT_BASE_URL = "https://api.mem0.ai"
128
+ DEFAULT_SELFHOSTED_BASE_URL = "http://localhost:8888"
129
+
130
+
131
+ class Mem0Adapter(MemoryBackendAdapter):
132
+ name = "mem0"
133
+ env_var = "MEM0_API_KEY"
134
+ supports_update = True
135
+
136
+ def __init__(self, base_url: str = DEFAULT_BASE_URL, timeout: float = 30.0) -> None:
137
+ api_key = os.environ.get(self.env_var)
138
+ if not api_key:
139
+ raise BackendNotConfiguredError(self.name, self.env_var)
140
+ self._http = httpx.Client(
141
+ base_url=base_url,
142
+ headers={
143
+ "Authorization": f"Token {api_key}",
144
+ "Content-Type": "application/json",
145
+ },
146
+ timeout=timeout,
147
+ )
148
+
149
+ def store(
150
+ self,
151
+ session_id: str,
152
+ content: str,
153
+ metadata: dict[str, str] | None = None,
154
+ mode: str | None = None,
155
+ ) -> StoreResult:
156
+ # Mem0 has no documented operating-mode variant to select -- `mode`
157
+ # is accepted and ignored (no-op) so callers can pass it uniformly
158
+ # across every adapter. See MemoryBackendAdapter.supported_modes.
159
+ del mode
160
+ timer = self._timed()
161
+ payload: dict[str, object] = {
162
+ "messages": [{"role": "user", "content": content}],
163
+ "user_id": session_id,
164
+ }
165
+ if metadata:
166
+ payload["metadata"] = metadata
167
+ try:
168
+ resp = self._http.post("/v1/memories/", json=payload)
169
+ resp.raise_for_status()
170
+ data = resp.json()
171
+ except httpx.HTTPError as exc:
172
+ raise BackendAPIError(self.name, str(exc)) from exc
173
+ memory_id = _extract_memory_id(data)
174
+ # See ExtractionSignal in base.py -- mem0ai/mem0#5178: a store()
175
+ # call can complete without raising and still carry no usable
176
+ # memory id anywhere in the response, which is byte-identical to a
177
+ # genuine successful store unless flagged explicitly here.
178
+ extraction_signal = (
179
+ ExtractionSignal.EMPTY_EXTRACTION if not memory_id else ExtractionSignal.FACTS_EXTRACTED
180
+ )
181
+ return StoreResult(
182
+ memory_id=memory_id,
183
+ latency_ms=timer.elapsed_ms(),
184
+ raw=data,
185
+ extraction_signal=extraction_signal,
186
+ )
187
+
188
+ def query(
189
+ self, session_id: str, query: str, top_k: int = 5, mode: str | None = None
190
+ ) -> QueryResult:
191
+ del mode # no-op, see store() above
192
+ timer = self._timed()
193
+ payload = {"query": query, "filters": {"user_id": session_id}, "top_k": top_k}
194
+ try:
195
+ resp = self._http.post("/v1/memories/search/", json=payload)
196
+ resp.raise_for_status()
197
+ data = resp.json()
198
+ except httpx.HTTPError as exc:
199
+ raise BackendAPIError(self.name, str(exc)) from exc
200
+
201
+ raw_results = data.get("results", data if isinstance(data, list) else [])
202
+ records = [
203
+ MemoryRecord(
204
+ memory_id=str(item.get("id", "")),
205
+ content=str(item.get("memory", item.get("text", ""))),
206
+ score=item.get("score"),
207
+ created_at=item.get("created_at"),
208
+ metadata=item.get("metadata") or {},
209
+ raw=item,
210
+ )
211
+ for item in raw_results
212
+ ]
213
+ # Mem0's search response does not carry an explicit "this result
214
+ # superseded a prior one" marker in the documented API surface --
215
+ # conflict handling happens inside Mem0's own add/update pipeline,
216
+ # invisibly to the caller. Until Mem0 documents a per-result
217
+ # conflict marker, memtrust conservatively records SILENT_OVERWRITE
218
+ # when exactly one record is returned for a query that the
219
+ # contradiction eval knows should have two candidate facts, and
220
+ # FLAGGED only if the response ever includes more than one
221
+ # conflicting record for the same fact slot. See
222
+ # evals/contradiction.py for how this signal is actually derived
223
+ # per test case; this adapter only reports the raw record set.
224
+ conflict_signal = ConflictSignal.NOT_APPLICABLE
225
+ return QueryResult(
226
+ records=records,
227
+ conflict_signal=conflict_signal,
228
+ latency_ms=timer.elapsed_ms(),
229
+ raw=data,
230
+ )
231
+
232
+ def update(self, session_id: str, memory_id: str, content: str) -> UpdateResult:
233
+ timer = self._timed()
234
+ try:
235
+ resp = self._http.put(f"/v1/memories/{memory_id}/", json={"text": content})
236
+ resp.raise_for_status()
237
+ data = resp.json()
238
+ except httpx.HTTPError as exc:
239
+ raise BackendAPIError(self.name, str(exc)) from exc
240
+ return UpdateResult(
241
+ memory_id=memory_id, acknowledged=True, latency_ms=timer.elapsed_ms(), raw=data
242
+ )
243
+
244
+ def delete(self, memory_id: str) -> DeleteResult:
245
+ """Delete a memory via Mem0's documented DELETE /v1/memories/{id}/
246
+ endpoint (docs.mem0.ai/platform/quickstart lists delete alongside
247
+ add/search/update on the same REST surface this adapter targets).
248
+
249
+ This is the primitive an eval needs to reproduce the real,
250
+ merged mem0ai/mem0#5936 / #5970 bug class: a multi-entity delete
251
+ whose client-side aggregation silently truncated to only the
252
+ last response instead of all N. memtrust's own delete_many() in
253
+ base.py is what constructs that N-entity scenario against this
254
+ single-id delete() call.
255
+ """
256
+ timer = self._timed()
257
+ try:
258
+ resp = self._http.delete(f"/v1/memories/{memory_id}/")
259
+ resp.raise_for_status()
260
+ data = resp.json() if resp.content else {}
261
+ except httpx.HTTPError as exc:
262
+ raise BackendAPIError(self.name, str(exc)) from exc
263
+ return DeleteResult(
264
+ success=True, memory_id=memory_id, latency_ms=timer.elapsed_ms(), raw=data
265
+ )
266
+
267
+ def close(self) -> None:
268
+ self._http.close()
269
+
270
+
271
+ class Mem0SelfHostedAdapter(MemoryBackendAdapter):
272
+ """Adapter for the self-hosted Mem0 OSS `server/` FastAPI wrapper.
273
+
274
+ See this module's docstring for the confidence level and exactly what
275
+ was, and was not, confirmed against the real `mem0ai/mem0` source.
276
+
277
+ Unlike `Mem0Adapter`, configuration is gated on a base URL
278
+ (`MEM0_SELFHOSTED_BASE_URL`), not an API key, because the self-hosted
279
+ server ships with no auth by default -- the same reasoning
280
+ docs/methodology.md gives for `MemPalaceAdapter`'s
281
+ `MEMPALACE_STORAGE_PATH`. An optional `MEM0_SELFHOSTED_API_KEY` is
282
+ sent as an `X-API-Key` header for deployments that do front the
283
+ server with auth (env's `AUTH_DISABLED` unset).
284
+ """
285
+
286
+ name = "mem0_selfhosted"
287
+ env_var = "MEM0_SELFHOSTED_BASE_URL"
288
+ supports_update = True
289
+
290
+ def __init__(
291
+ self,
292
+ base_url: str | None = None,
293
+ api_key: str | None = None,
294
+ timeout: float = 30.0,
295
+ ) -> None:
296
+ resolved_base_url = base_url or os.environ.get(self.env_var)
297
+ if not resolved_base_url:
298
+ raise BackendNotConfiguredError(self.name, self.env_var)
299
+ resolved_api_key = api_key or os.environ.get("MEM0_SELFHOSTED_API_KEY")
300
+ headers = {"Content-Type": "application/json"}
301
+ if resolved_api_key:
302
+ headers["X-API-Key"] = resolved_api_key
303
+ self._http = httpx.Client(base_url=resolved_base_url, headers=headers, timeout=timeout)
304
+
305
+ def store(
306
+ self,
307
+ session_id: str,
308
+ content: str,
309
+ metadata: dict[str, str] | None = None,
310
+ mode: str | None = None,
311
+ ) -> StoreResult:
312
+ # Same no-op convention as Mem0Adapter.store() above -- the
313
+ # self-hosted server has no documented operating-mode variant
314
+ # either, so `mode` is accepted for interface uniformity and
315
+ # otherwise ignored.
316
+ del mode
317
+ timer = self._timed()
318
+ payload: dict[str, object] = {
319
+ "messages": [{"role": "user", "content": content}],
320
+ "user_id": session_id,
321
+ }
322
+ if metadata:
323
+ payload["metadata"] = metadata
324
+ try:
325
+ resp = self._http.post("/memories", json=payload)
326
+ resp.raise_for_status()
327
+ data = resp.json()
328
+ except httpx.HTTPError as exc:
329
+ raise BackendAPIError(self.name, str(exc)) from exc
330
+ memory_id = _extract_memory_id(data)
331
+ # See ExtractionSignal in base.py -- mem0ai/mem0#5178: a store()
332
+ # call can complete without raising and still carry no usable
333
+ # memory id anywhere in the response, which is byte-identical to a
334
+ # genuine successful store unless flagged explicitly here.
335
+ extraction_signal = (
336
+ ExtractionSignal.EMPTY_EXTRACTION if not memory_id else ExtractionSignal.FACTS_EXTRACTED
337
+ )
338
+ return StoreResult(
339
+ memory_id=memory_id,
340
+ latency_ms=timer.elapsed_ms(),
341
+ raw=data,
342
+ extraction_signal=extraction_signal,
343
+ )
344
+
345
+ def query(
346
+ self,
347
+ session_id: str,
348
+ query: str,
349
+ top_k: int = 5,
350
+ run_id: str | None = None,
351
+ agent_id: str | None = None,
352
+ threshold: float | None = None,
353
+ ) -> QueryResult:
354
+ """Search self-hosted Mem0 memories.
355
+
356
+ `run_id`/`agent_id` default to `None` (omitted from the filter
357
+ entirely), but a caller may deliberately pass `""` -- this
358
+ adapter always includes the key in `filters` when the value is
359
+ not `None` (an `is not None` check, not a truthy check), so an
360
+ empty string is preserved through to the server rather than
361
+ silently dropped the way the server's own deprecated top-level
362
+ `user_id`/`run_id`/`agent_id` merge path drops falsy values (see
363
+ module docstring, mem0ai/mem0#5973). This is what lets the
364
+ contradiction/entity-scoping evals construct the exact filter
365
+ shape that issue describes and observe how the self-hosted
366
+ server actually responds to it, instead of memtrust masking the
367
+ input before it ever reaches the vendor.
368
+
369
+ `threshold` is passed straight through to `SearchRequest.threshold`
370
+ -- confirmed present on the self-hosted server's search model --
371
+ which is what makes mem0ai/mem0#4453 (threshold inversion)
372
+ reachable through this adapter; `Mem0Adapter` (hosted) has no
373
+ equivalent parameter today.
374
+ """
375
+ timer = self._timed()
376
+ filters: dict[str, str] = {"user_id": session_id}
377
+ if run_id is not None:
378
+ filters["run_id"] = run_id
379
+ if agent_id is not None:
380
+ filters["agent_id"] = agent_id
381
+ payload: dict[str, object] = {"query": query, "filters": filters, "top_k": top_k}
382
+ if threshold is not None:
383
+ payload["threshold"] = threshold
384
+ try:
385
+ resp = self._http.post("/search", json=payload)
386
+ resp.raise_for_status()
387
+ data = resp.json()
388
+ except httpx.HTTPError as exc:
389
+ raise BackendAPIError(self.name, str(exc)) from exc
390
+
391
+ raw_results = data.get("results", data if isinstance(data, list) else [])
392
+ records = [
393
+ MemoryRecord(
394
+ memory_id=str(item.get("id", "")),
395
+ content=str(item.get("memory", item.get("text", ""))),
396
+ score=item.get("score"),
397
+ created_at=item.get("created_at"),
398
+ metadata=item.get("metadata") or {},
399
+ raw=item,
400
+ )
401
+ for item in raw_results
402
+ ]
403
+ # Same reasoning as Mem0Adapter.query() above: the self-hosted
404
+ # search response has no documented per-result conflict marker
405
+ # either, so this is recorded as NOT_APPLICABLE rather than
406
+ # guessed. See evals/contradiction.py for how the eval derives
407
+ # its own signal from the raw record set regardless.
408
+ conflict_signal = ConflictSignal.NOT_APPLICABLE
409
+ return QueryResult(
410
+ records=records,
411
+ conflict_signal=conflict_signal,
412
+ latency_ms=timer.elapsed_ms(),
413
+ raw=data,
414
+ )
415
+
416
+ def update(self, session_id: str, memory_id: str, content: str) -> UpdateResult:
417
+ timer = self._timed()
418
+ try:
419
+ resp = self._http.put(f"/memories/{memory_id}", json={"text": content})
420
+ resp.raise_for_status()
421
+ data = resp.json()
422
+ except httpx.HTTPError as exc:
423
+ raise BackendAPIError(self.name, str(exc)) from exc
424
+ return UpdateResult(
425
+ memory_id=memory_id, acknowledged=True, latency_ms=timer.elapsed_ms(), raw=data
426
+ )
427
+
428
+ def delete(self, memory_id: str) -> DeleteResult:
429
+ """Delete a memory via the self-hosted server's unprefixed
430
+ `DELETE /memories/{id}` route -- same reasoning as `Mem0Adapter.delete()`
431
+ above, against the self-hosted route shape instead of the hosted
432
+ `/v1/...` one.
433
+ """
434
+ timer = self._timed()
435
+ try:
436
+ resp = self._http.delete(f"/memories/{memory_id}")
437
+ resp.raise_for_status()
438
+ data = resp.json() if resp.content else {}
439
+ except httpx.HTTPError as exc:
440
+ raise BackendAPIError(self.name, str(exc)) from exc
441
+ return DeleteResult(
442
+ success=True, memory_id=memory_id, latency_ms=timer.elapsed_ms(), raw=data
443
+ )
444
+
445
+ def close(self) -> None:
446
+ self._http.close()
447
+
448
+
449
+ def _extract_memory_id(data: object) -> str:
450
+ if isinstance(data, dict):
451
+ if "id" in data:
452
+ return str(data["id"])
453
+ results = data.get("results")
454
+ if isinstance(results, list) and results and isinstance(results[0], dict):
455
+ return str(results[0].get("id", ""))
456
+ return ""