opencode-python-sdk 0.0.1.dev1__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 (62) hide show
  1. opencode/.generated.json +39 -0
  2. opencode/__init__.py +221 -0
  3. opencode/_base_client.py +539 -0
  4. opencode/_client.py +255 -0
  5. opencode/_constants.py +27 -0
  6. opencode/_errors.py +547 -0
  7. opencode/_events.py +732 -0
  8. opencode/_exceptions.py +271 -0
  9. opencode/_models.py +54 -0
  10. opencode/_pagination.py +192 -0
  11. opencode/_pty.py +586 -0
  12. opencode/_qs.py +95 -0
  13. opencode/_resource.py +42 -0
  14. opencode/_rpc.py +489 -0
  15. opencode/_server.py +335 -0
  16. opencode/_streaming.py +248 -0
  17. opencode/_types.py +77 -0
  18. opencode/_unions.py +46 -0
  19. opencode/_version.py +8 -0
  20. opencode/lib/__init__.py +5 -0
  21. opencode/lib/_ready.py +91 -0
  22. opencode/lib/_session.py +231 -0
  23. opencode/py.typed +0 -0
  24. opencode/resources/__init__.py +364 -0
  25. opencode/resources/agent.py +179 -0
  26. opencode/resources/command.py +100 -0
  27. opencode/resources/config.py +240 -0
  28. opencode/resources/credential.py +384 -0
  29. opencode/resources/debug.py +178 -0
  30. opencode/resources/event.py +88 -0
  31. opencode/resources/experimental.py +765 -0
  32. opencode/resources/file.py +355 -0
  33. opencode/resources/form.py +100 -0
  34. opencode/resources/generate.py +110 -0
  35. opencode/resources/integration.py +1033 -0
  36. opencode/resources/location.py +162 -0
  37. opencode/resources/mcp.py +492 -0
  38. opencode/resources/message.py +152 -0
  39. opencode/resources/migration.py +118 -0
  40. opencode/resources/model.py +177 -0
  41. opencode/resources/permission.py +704 -0
  42. opencode/resources/plugin.py +250 -0
  43. opencode/resources/project.py +187 -0
  44. opencode/resources/provider.py +183 -0
  45. opencode/resources/pty.py +522 -0
  46. opencode/resources/reference.py +100 -0
  47. opencode/resources/rpc.py +113 -0
  48. opencode/resources/server.py +230 -0
  49. opencode/resources/session.py +3567 -0
  50. opencode/resources/shell.py +412 -0
  51. opencode/resources/skill.py +100 -0
  52. opencode/resources/vcs.py +500 -0
  53. opencode/resources/voice.py +113 -0
  54. opencode/resources/websearch.py +184 -0
  55. opencode/resources/worktree.py +336 -0
  56. opencode/testing.py +508 -0
  57. opencode/types/__init__.py +1462 -0
  58. opencode/types/_generated.py +8540 -0
  59. opencode_python_sdk-0.0.1.dev1.dist-info/METADATA +468 -0
  60. opencode_python_sdk-0.0.1.dev1.dist-info/RECORD +62 -0
  61. opencode_python_sdk-0.0.1.dev1.dist-info/WHEEL +4 -0
  62. opencode_python_sdk-0.0.1.dev1.dist-info/licenses/LICENSE +21 -0
opencode/testing.py ADDED
@@ -0,0 +1,508 @@
1
+ """An in-process fake OpenCode server for your own tests.
2
+
3
+ :class:`FakeServer` is an ``httpx`` transport that answers like an OpenCode server, keeping sessions, messages and
4
+ events in memory. Plug it into a client and test code that uses the SDK without starting ``opencode serve`` or
5
+ calling a model::
6
+
7
+ from opencode import Opencode
8
+ from opencode.testing import FakeServer
9
+
10
+ fake = FakeServer(reply="Done.")
11
+ client = Opencode.fake(fake) # or Opencode(transport=fake)
12
+
13
+ result = client.session.run("Fix the bug", agent="build")
14
+ assert result.text == "Done."
15
+ assert fake.prompts == ["Fix the bug"]
16
+
17
+ It covers sessions (create, get, list, remove, switch agent or model), prompts, waiting, interrupts, messages,
18
+ agents, MCP servers, server info and the event stream. Every payload is built from the generated models, so it
19
+ has the shape the real server sends. Other routes answer ``404``. Use :meth:`FakeServer.fail` to make a route fail.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import itertools
25
+ import json
26
+ import os
27
+ import re
28
+ import time
29
+ from collections.abc import Callable, Iterable, Mapping
30
+ from dataclasses import dataclass
31
+ from typing import Any
32
+
33
+ import httpx
34
+
35
+ from ._models import BaseModel
36
+ from .types import (
37
+ AgentInfo,
38
+ AgentListResponse,
39
+ LocationPublicRef,
40
+ McpListResponse,
41
+ McpServer,
42
+ ModelRef,
43
+ ServerConnectedEvent,
44
+ ServerInfo,
45
+ SessionCreatedEvent,
46
+ SessionIdleEvent,
47
+ SessionInboxUser,
48
+ SessionInfo,
49
+ SessionInterruptResponse,
50
+ SessionMessageAssistant,
51
+ SessionMessageAssistantText,
52
+ SessionMessageIdle,
53
+ SessionMessageUser,
54
+ TokenUsageInfo,
55
+ )
56
+
57
+ __all__ = ["DEFAULT_MODEL", "FakeServer"]
58
+
59
+ DEFAULT_MODEL = ModelRef(id="fake-model", provider_id="fake")
60
+ """Model reported for sessions created without one."""
61
+
62
+ Reply = str | Callable[[str], str]
63
+ """Assistant reply: fixed text, or a function from the prompt text to the reply."""
64
+
65
+
66
+ @dataclass
67
+ class _Failure:
68
+ method: str
69
+ path: re.Pattern[str]
70
+ status: int
71
+ body: object
72
+ exception: BaseException | None
73
+ times: int | None
74
+
75
+
76
+ class FakeServer(httpx.MockTransport):
77
+ """An in-memory OpenCode server, usable as the ``transport`` of :class:`opencode.Opencode` and
78
+ :class:`opencode.AsyncOpencode`.
79
+
80
+ Args:
81
+ reply: What the assistant answers to each prompt: text, or a function of the prompt text.
82
+ agents: Agent ids :meth:`opencode.Opencode.agent.list` reports. Add more later with :meth:`add_agent`.
83
+ mcp_servers: MCP server names mapped to their status (``connected``, ``pending``, ``failed``, ...).
84
+ directory: Project directory reported as every session's location.
85
+ cost: Cost in USD reported for each assistant reply.
86
+ tokens: Input and output tokens reported for each assistant reply.
87
+
88
+ Attributes:
89
+ sessions: Sessions by id.
90
+ messages: Messages of each session, oldest first.
91
+ events: Events the stream replays after ``server.connected``.
92
+ requests: Every request received, in order.
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ *,
98
+ reply: Reply = "OK",
99
+ agents: Iterable[str] = ("build", "plan"),
100
+ mcp_servers: Mapping[str, str] | None = None,
101
+ directory: str = "/fake/project",
102
+ cost: float = 0.001,
103
+ tokens: tuple[int, int] = (10, 5),
104
+ ) -> None:
105
+ super().__init__(self._handle)
106
+ self.reply: Reply = reply
107
+ self.directory = directory
108
+ self.cost = cost
109
+ self.tokens = tokens
110
+ self.sessions: dict[str, SessionInfo] = {}
111
+ self.messages: dict[str, list[BaseModel]] = {}
112
+ self.inbox: dict[str, list[SessionInboxUser]] = {}
113
+ self.events: list[BaseModel] = []
114
+ self.requests: list[httpx.Request] = []
115
+ self._agents: list[tuple[str, int]] = [(agent, 0) for agent in agents]
116
+ self._mcp: dict[str, tuple[str, int]] = {name: (status, 0) for name, status in (mcp_servers or {}).items()}
117
+ self._polls = {"agent": 0, "mcp": 0}
118
+ self._failures: list[_Failure] = []
119
+ self._ids = itertools.count(1)
120
+
121
+ # -- configuration --------------------------------------------------------------------------------------
122
+
123
+ def add_agent(self, agent: str, *, after_polls: int = 0) -> None:
124
+ """Lists ``agent`` once ``GET /api/agent`` has been called ``after_polls`` more times, like an agent that
125
+ loads after boot."""
126
+ self._agents.append((agent, self._polls["agent"] + after_polls))
127
+
128
+ def set_mcp_status(self, name: str, status: str = "connected", *, after_polls: int = 0) -> None:
129
+ """Reports MCP server ``name`` with ``status`` once ``GET /api/mcp`` has been called ``after_polls`` more
130
+ times."""
131
+ self._mcp[name] = (status, self._polls["mcp"] + after_polls)
132
+
133
+ def fail(
134
+ self,
135
+ method: str,
136
+ path: str,
137
+ *,
138
+ status: int = 500,
139
+ body: object = None,
140
+ exception: BaseException | None = None,
141
+ times: int | None = 1,
142
+ ) -> None:
143
+ """Makes matching requests fail.
144
+
145
+ Args:
146
+ method: HTTP method, such as ``POST``.
147
+ path: Regular expression matched against the whole request path, such as ``/api/session/[^/]+/prompt``.
148
+ status: Status of the error response.
149
+ body: JSON error body. Defaults to an untagged body, so the error class follows ``status``.
150
+ exception: Raise this from the transport instead of answering, for example ``httpx.ConnectError(...)``
151
+ or ``KeyboardInterrupt()``.
152
+ times: How many requests fail; ``None`` for all of them.
153
+ """
154
+ if body is None:
155
+ body = {"message": f"Injected failure ({status})"}
156
+ self._failures.append(_Failure(method.upper(), re.compile(path), status, body, exception, times))
157
+
158
+ @property
159
+ def prompts(self) -> list[str]:
160
+ """Text of every prompt received, in order."""
161
+ texts: list[str] = []
162
+ for request in self.requests:
163
+ if request.method == "POST" and request.url.path.endswith("/prompt"):
164
+ texts.append(json.loads(request.content)["text"])
165
+ return texts
166
+
167
+ # -- helpers --------------------------------------------------------------------------------------------
168
+
169
+ def _id(self, prefix: str) -> str:
170
+ return f"{prefix}_{next(self._ids):026d}"
171
+
172
+ @staticmethod
173
+ def _now() -> float:
174
+ return float(time.time_ns() // 1_000_000)
175
+
176
+ @property
177
+ def _location(self) -> LocationPublicRef:
178
+ return LocationPublicRef(directory=self.directory)
179
+
180
+ def _session(self, session_id: str) -> SessionInfo:
181
+ try:
182
+ return self.sessions[session_id]
183
+ except KeyError:
184
+ raise _ErrorResponse(
185
+ 404,
186
+ {
187
+ "_tag": "SessionNotFoundError",
188
+ "sessionID": session_id,
189
+ "message": f"Session not found: {session_id}",
190
+ },
191
+ ) from None
192
+
193
+ @staticmethod
194
+ def _body(request: httpx.Request) -> dict[str, Any]:
195
+ if not request.content:
196
+ return {}
197
+ try:
198
+ payload = json.loads(request.content)
199
+ except ValueError:
200
+ raise _invalid("Request body is not JSON") from None
201
+ if not isinstance(payload, dict):
202
+ raise _invalid("Request body must be an object")
203
+ return dict(payload) # pyright: ignore[reportUnknownArgumentType]
204
+
205
+ def _record(self, event: BaseModel) -> None:
206
+ self.events.append(event)
207
+
208
+ def _run(self, session: SessionInfo, text: str) -> None:
209
+ """Answers one prompt synchronously: assistant reply, idle marker, usage, events."""
210
+ reply = self.reply(text) if callable(self.reply) else self.reply
211
+ now = self._now()
212
+ input_tokens, output_tokens = self.tokens
213
+ usage = {"input": input_tokens, "output": output_tokens, "reasoning": 0, "cache": {"read": 0, "write": 0}}
214
+ self.messages[session.id] += [
215
+ SessionMessageAssistant.model_validate(
216
+ {
217
+ "id": self._id("msg"),
218
+ "type": "assistant",
219
+ "time": {"created": now, "completed": now},
220
+ "agent": session.agent or "build",
221
+ "model": (session.model or DEFAULT_MODEL).to_dict(),
222
+ "content": [SessionMessageAssistantText(type="text", text=reply).to_dict()],
223
+ "finish": "stop",
224
+ "cost": self.cost,
225
+ "tokens": usage,
226
+ }
227
+ ),
228
+ SessionMessageIdle.model_validate(
229
+ {"id": self._id("msg"), "type": "idle", "time": {"created": now}, "outcome": "succeeded"}
230
+ ),
231
+ ]
232
+ session.cost += self.cost
233
+ session.tokens = TokenUsageInfo.model_validate(
234
+ {
235
+ "input": session.tokens.input + input_tokens,
236
+ "output": session.tokens.output + output_tokens,
237
+ "reasoning": session.tokens.reasoning,
238
+ "cache": session.tokens.cache.to_dict(),
239
+ }
240
+ )
241
+ session.outcome = "succeeded"
242
+ session.time.updated = session.time.idle = now
243
+ self._record(
244
+ SessionIdleEvent.model_validate(
245
+ {"id": self._id("evt"), "created": now, "type": "session.idle", "data": {"sessionID": session.id}}
246
+ )
247
+ )
248
+
249
+ # -- routes ---------------------------------------------------------------------------------------------
250
+
251
+ def _server_info(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
252
+ info = ServerInfo.model_validate(
253
+ {
254
+ "version": "fake",
255
+ "pid": os.getpid(),
256
+ "urls": [str(request.url.copy_with(path="/"))],
257
+ "paths": {"tmp": "/tmp"},
258
+ }
259
+ )
260
+ return _json(info)
261
+
262
+ def _agent_list(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
263
+ self._polls["agent"] += 1
264
+ visible = [agent for agent, after in self._agents if self._polls["agent"] > after]
265
+ agents = [
266
+ AgentInfo.model_validate(
267
+ {
268
+ "id": agent,
269
+ "name": agent,
270
+ "mode": "primary",
271
+ "hidden": False,
272
+ "request": {"settings": {}, "headers": {}, "body": {}},
273
+ "permissions": [],
274
+ }
275
+ )
276
+ for agent in dict.fromkeys(visible)
277
+ ]
278
+ return _json(AgentListResponse(location=self._location, data=agents))
279
+
280
+ def _mcp_list(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
281
+ self._polls["mcp"] += 1
282
+ servers = [
283
+ McpServer.model_validate(
284
+ {"name": name, "status": _mcp_status(status if self._polls["mcp"] > after else "pending")}
285
+ )
286
+ for name, (status, after) in self._mcp.items()
287
+ ]
288
+ return _json(McpListResponse(location=self._location, data=servers))
289
+
290
+ def _session_create(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
291
+ body = self._body(request)
292
+ now = self._now()
293
+ session_id = body.get("id") or self._id("ses")
294
+ if session_id in self.sessions:
295
+ raise _ErrorResponse(409, {"_tag": "ConflictError", "message": f"Session already exists: {session_id}"})
296
+ session = SessionInfo.model_validate(
297
+ {
298
+ "id": session_id,
299
+ "parentID": body.get("parentID"),
300
+ "projectID": "prj_fake",
301
+ "agent": body.get("agent"),
302
+ "model": body.get("model") or DEFAULT_MODEL.to_dict(),
303
+ "cost": 0,
304
+ "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}},
305
+ "time": {"created": now, "updated": now},
306
+ "title": body.get("title"),
307
+ "metadata": body.get("metadata"),
308
+ "location": self._location.to_dict(),
309
+ }
310
+ )
311
+ self.sessions[session.id] = session
312
+ self.messages[session.id] = []
313
+ self.inbox[session.id] = []
314
+ self._record(
315
+ SessionCreatedEvent.model_validate(
316
+ {
317
+ "id": self._id("evt"),
318
+ "created": now,
319
+ "type": "session.created",
320
+ "durable": {"aggregate_id": session.id, "seq": 0, "version": 1},
321
+ "data": {
322
+ "sessionID": session.id,
323
+ "projectID": session.project_id,
324
+ "location": {"directory": self.directory},
325
+ "slug": session.id,
326
+ "title": session.title,
327
+ "agent": session.agent,
328
+ "version": "fake",
329
+ },
330
+ }
331
+ )
332
+ )
333
+ return _json({"data": session.to_dict()})
334
+
335
+ def _session_list(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
336
+ sessions = list(self.sessions.values())
337
+ return _page(request, [session.to_dict() for session in sessions], newest_first_default=True)
338
+
339
+ def _session_get(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
340
+ return _json({"data": self._session(match["id"]).to_dict()})
341
+
342
+ def _session_remove(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
343
+ self._session(match["id"])
344
+ del self.sessions[match["id"]]
345
+ self.messages.pop(match["id"], None)
346
+ self.inbox.pop(match["id"], None)
347
+ return httpx.Response(204)
348
+
349
+ def _session_agent(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
350
+ session = self._session(match["id"])
351
+ agent = self._body(request).get("agent")
352
+ if not isinstance(agent, str):
353
+ raise _invalid("agent is required", field="agent")
354
+ session.agent = agent
355
+ return httpx.Response(204)
356
+
357
+ def _session_model(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
358
+ session = self._session(match["id"])
359
+ session.model = ModelRef.model_validate(self._body(request).get("model"))
360
+ return httpx.Response(204)
361
+
362
+ def _session_prompt(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
363
+ session = self._session(match["id"])
364
+ body = self._body(request)
365
+ text = body.get("text")
366
+ if not isinstance(text, str):
367
+ raise _invalid("text is required", field="text")
368
+ now = self._now()
369
+ message_id = body.get("id") or self._id("msg")
370
+ payload = {key: body[key] for key in ("text", "files", "agents", "skills", "metadata") if key in body}
371
+ queued = SessionInboxUser.model_validate(
372
+ {
373
+ "id": message_id,
374
+ "sessionID": session.id,
375
+ "time": {"created": now},
376
+ "type": "user",
377
+ "payload": payload,
378
+ "delivery": body.get("delivery") or "queue",
379
+ }
380
+ )
381
+ if body.get("resume") is False:
382
+ self.inbox[session.id].append(queued)
383
+ else:
384
+ self.messages[session.id].append(
385
+ SessionMessageUser.model_validate(
386
+ {
387
+ "id": message_id,
388
+ "type": "user",
389
+ "time": {"created": now},
390
+ "text": text,
391
+ "metadata": body.get("metadata"),
392
+ }
393
+ )
394
+ )
395
+ self._run(session, text)
396
+ return _json({"data": queued.to_dict()})
397
+
398
+ def _session_wait(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
399
+ self._session(match["id"])
400
+ return httpx.Response(204)
401
+
402
+ def _session_interrupt(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
403
+ self._session(match["id"])
404
+ return _json(SessionInterruptResponse(interrupted=False))
405
+
406
+ def _message_list(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
407
+ self._session(match["id"])
408
+ wanted = request.url.params.get("type")
409
+ items = [
410
+ m.to_dict() for m in self.messages[match["id"]] if wanted is None or getattr(m, "type", None) == wanted
411
+ ]
412
+ return _page(request, items, newest_first_default=False)
413
+
414
+ def _event_stream(self, request: httpx.Request, match: re.Match[str]) -> httpx.Response:
415
+ connected = ServerConnectedEvent.model_validate({"id": self._id("evt"), "type": "server.connected", "data": {}})
416
+ chunks = [f"data: {json.dumps(event.to_dict())}\n\n" for event in [connected, *self.events]]
417
+ return httpx.Response(200, headers={"content-type": "text/event-stream"}, content="".join(chunks).encode())
418
+
419
+ _ROUTES: tuple[tuple[str, str, Callable[..., httpx.Response]], ...] = (
420
+ ("GET", r"/api/info", _server_info),
421
+ ("GET", r"/api/agent", _agent_list),
422
+ ("GET", r"/api/mcp", _mcp_list),
423
+ ("GET", r"/api/event", _event_stream),
424
+ ("POST", r"/api/session", _session_create),
425
+ ("GET", r"/api/session", _session_list),
426
+ ("GET", r"/api/session/(?P<id>[^/]+)", _session_get),
427
+ ("DELETE", r"/api/session/(?P<id>[^/]+)", _session_remove),
428
+ ("POST", r"/api/session/(?P<id>[^/]+)/agent", _session_agent),
429
+ ("POST", r"/api/session/(?P<id>[^/]+)/model", _session_model),
430
+ ("POST", r"/api/session/(?P<id>[^/]+)/prompt", _session_prompt),
431
+ ("POST", r"/api/session/(?P<id>[^/]+)/interrupt", _session_interrupt),
432
+ ("GET", r"/api/session/(?P<id>[^/]+)/message", _message_list),
433
+ ("POST", r"/api/experimental/session/(?P<id>[^/]+)/wait", _session_wait),
434
+ )
435
+
436
+ def _handle(self, request: httpx.Request) -> httpx.Response:
437
+ self.requests.append(request)
438
+ path = request.url.path
439
+ for failure in self._failures:
440
+ if failure.method == request.method and failure.path.fullmatch(path) and failure.times != 0:
441
+ if failure.times is not None:
442
+ failure.times -= 1
443
+ if failure.exception is not None:
444
+ raise failure.exception
445
+ return httpx.Response(failure.status, json=failure.body)
446
+ for method, pattern, handler in self._ROUTES:
447
+ match = re.fullmatch(pattern, path)
448
+ if match and method == request.method:
449
+ try:
450
+ return handler(self, request, match)
451
+ except _ErrorResponse as error:
452
+ return httpx.Response(error.status, json=error.body)
453
+ return httpx.Response(
454
+ 404, json={"_tag": "NotFoundError", "message": f"FakeServer does not implement {request.method} {path}"}
455
+ )
456
+
457
+
458
+ class _ErrorResponse(Exception):
459
+ def __init__(self, status: int, body: dict[str, object]) -> None:
460
+ super().__init__(body.get("message"))
461
+ self.status = status
462
+ self.body = body
463
+
464
+
465
+ def _mcp_status(status: str) -> dict[str, object]:
466
+ if status == "failed":
467
+ return {"status": status, "error": "Fake MCP server failed to start"}
468
+ return {"status": status}
469
+
470
+
471
+ def _invalid(message: str, *, field: str | None = None) -> _ErrorResponse:
472
+ return _ErrorResponse(400, {"_tag": "InvalidRequestError", "message": message, "field": field})
473
+
474
+
475
+ def _json(payload: BaseModel | dict[str, Any]) -> httpx.Response:
476
+ return httpx.Response(200, json=payload.to_dict() if isinstance(payload, BaseModel) else payload)
477
+
478
+
479
+ def _page(request: httpx.Request, items: list[dict[str, Any]], *, newest_first_default: bool) -> httpx.Response:
480
+ """Cursor pagination over ``items`` (oldest first). Cursors encode the order and offset, like the server's."""
481
+ params = request.url.params
482
+ cursor = params.get("cursor")
483
+ if cursor is not None:
484
+ if "order" in params:
485
+ return httpx.Response(
486
+ 400, json={"_tag": "InvalidRequestError", "message": "order cannot be combined with cursor"}
487
+ )
488
+ order, _, offset_text = cursor.partition(":")
489
+ if order not in ("asc", "desc") or not offset_text.isdigit():
490
+ return httpx.Response(400, json={"_tag": "InvalidCursorError", "message": f"Invalid cursor: {cursor}"})
491
+ offset = int(offset_text)
492
+ else:
493
+ order = params.get("order") or ("desc" if newest_first_default else "asc")
494
+ offset = 0
495
+ limit = int(params.get("limit") or 50)
496
+ ordered = list(reversed(items)) if order == "desc" else items
497
+ data = ordered[offset : offset + limit]
498
+ end = offset + len(data)
499
+ return httpx.Response(
500
+ 200,
501
+ json={
502
+ "data": data,
503
+ "cursor": {
504
+ "previous": f"{order}:{max(offset - limit, 0)}" if offset > 0 else None,
505
+ "next": f"{order}:{end}" if end < len(ordered) else None,
506
+ },
507
+ },
508
+ )