visionai-sdk-python 2.2.0.dev4__tar.gz → 2.2.0.dev5__tar.gz

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 (63) hide show
  1. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/PKG-INFO +4 -3
  2. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/README.md +3 -2
  3. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/async_client.py +5 -1
  4. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/auth/credentials.py +2 -1
  5. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/client.py +5 -1
  6. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/relay/__init__.py +4 -0
  7. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/relay/protocol.py +27 -2
  8. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python.egg-info/PKG-INFO +4 -3
  9. visionai_sdk_python-2.2.0.dev5/src/visionai_sdk_python.egg-info/scm_version.json +8 -0
  10. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/tests/relay/test_protocol.py +30 -0
  11. visionai_sdk_python-2.2.0.dev4/src/visionai_sdk_python.egg-info/scm_version.json +0 -8
  12. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/.github/dependabot.yml +0 -0
  13. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/.github/workflows/ci.yml +0 -0
  14. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/.github/workflows/publish.yml +0 -0
  15. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/.gitignore +0 -0
  16. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/pyproject.toml +0 -0
  17. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/setup.cfg +0 -0
  18. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/__init__.py +0 -0
  19. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/_base.py +0 -0
  20. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/_jwt_verifier.py +0 -0
  21. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/_request_attribution.py +0 -0
  22. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/_source_header.py +0 -0
  23. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/auth/__init__.py +0 -0
  24. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/auth/_mixin.py +0 -0
  25. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/auth/async_resource.py +0 -0
  26. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/auth/models.py +0 -0
  27. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/auth/resource.py +0 -0
  28. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/constants.py +0 -0
  29. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/endpoints.py +0 -0
  30. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/exceptions.py +0 -0
  31. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/instrumentation.py +0 -0
  32. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/py.typed +0 -0
  33. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/relay/examples/__init__.py +0 -0
  34. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/relay/examples/error_connection.json +0 -0
  35. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/relay/examples/error_request.json +0 -0
  36. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/relay/examples/heartbeat.json +0 -0
  37. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/relay/examples/register.json +0 -0
  38. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/relay/examples/registered.json +0 -0
  39. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/relay/examples/request.json +0 -0
  40. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/relay/examples/response.json +0 -0
  41. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/relay/examples/response_text.json +0 -0
  42. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/vlm/__init__.py +0 -0
  43. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/vlm/_mixin.py +0 -0
  44. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/vlm/async_resource.py +0 -0
  45. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/vlm/models.py +0 -0
  46. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/vlm/preprocess.py +0 -0
  47. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python/vlm/resource.py +0 -0
  48. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python.egg-info/SOURCES.txt +0 -0
  49. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python.egg-info/dependency_links.txt +0 -0
  50. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python.egg-info/requires.txt +0 -0
  51. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python.egg-info/scm_file_list.json +0 -0
  52. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/src/visionai_sdk_python.egg-info/top_level.txt +0 -0
  53. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/tests/__init__.py +0 -0
  54. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/tests/constants.py +0 -0
  55. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/tests/relay/__init__.py +0 -0
  56. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/tests/test__base.py +0 -0
  57. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/tests/test_async_client.py +0 -0
  58. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/tests/test_client.py +0 -0
  59. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/tests/test_credentials.py +0 -0
  60. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/tests/test_instrumentation.py +0 -0
  61. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/tests/test_request.py +0 -0
  62. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/tests/test_vlm_preprocess.py +0 -0
  63. {visionai_sdk_python-2.2.0.dev4 → visionai_sdk_python-2.2.0.dev5}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: visionai-sdk-python
3
- Version: 2.2.0.dev4
3
+ Version: 2.2.0.dev5
4
4
  Summary: VisionAI SDK for Python
5
5
  Author-email: Tony Yang <tonyyang@linkervision.com>
6
6
  Requires-Python: >=3.11
@@ -148,7 +148,8 @@ client = Client(auth_url="...", vlm_url="...", credential=cred) # vlm.chat() u
148
148
 
149
149
  token = cred.get_token().token # for a WebSocket upgrade or another library
150
150
 
151
- # Any other HTTP call with the same token; a 401 gets one fresh token and one retry
151
+ # Any other HTTP call with the same token, sent to the host you name; a 401 gets one
152
+ # fresh token and one retry
152
153
  nodes = client.request("GET", "https://vs.example.com/mgmt/nodes").json()
153
154
  ```
154
155
 
@@ -168,7 +169,7 @@ authentication wins. `vlm_url` is optional for clients that only authenticate.
168
169
 
169
170
  `visionai_sdk_python.relay` holds the wire format between platform-relay and
170
171
  platform-edge-agent: the frame models, the constants and `parse_agent_frame()` /
171
- `parse_relay_frame()` / `dump_frame()`. The spec is platform-service's
172
+ `parse_relay_frame()` / `dump_frame()` (which refuses a frame over `MAX_FRAME_BYTES`). The spec is platform-service's
172
173
  `docs/adr/0001-relay-protocol.md`; both sides import this module instead of keeping a
173
174
  copy. The ADR's JSON examples ship with it (`visionai_sdk_python.relay.examples`:
174
175
  `example(name)` / `example_text(name)`), so every implementation tests against the same
@@ -130,7 +130,8 @@ client = Client(auth_url="...", vlm_url="...", credential=cred) # vlm.chat() u
130
130
 
131
131
  token = cred.get_token().token # for a WebSocket upgrade or another library
132
132
 
133
- # Any other HTTP call with the same token; a 401 gets one fresh token and one retry
133
+ # Any other HTTP call with the same token, sent to the host you name; a 401 gets one
134
+ # fresh token and one retry
134
135
  nodes = client.request("GET", "https://vs.example.com/mgmt/nodes").json()
135
136
  ```
136
137
 
@@ -150,7 +151,7 @@ authentication wins. `vlm_url` is optional for clients that only authenticate.
150
151
 
151
152
  `visionai_sdk_python.relay` holds the wire format between platform-relay and
152
153
  platform-edge-agent: the frame models, the constants and `parse_agent_frame()` /
153
- `parse_relay_frame()` / `dump_frame()`. The spec is platform-service's
154
+ `parse_relay_frame()` / `dump_frame()` (which refuses a frame over `MAX_FRAME_BYTES`). The spec is platform-service's
154
155
  `docs/adr/0001-relay-protocol.md`; both sides import this module instead of keeping a
155
156
  copy. The ADR's JSON examples ship with it (`visionai_sdk_python.relay.examples`:
156
157
  `example(name)` / `example_text(name)`), so every implementation tests against the same
@@ -58,7 +58,11 @@ class AsyncClient(_BaseClient):
58
58
  async def request(self, method: str, url: str, **kwargs) -> httpx.Response:
59
59
  """Send any request with this client's bearer token.
60
60
 
61
- ``url`` is a full URL (the client has two bases); ``kwargs`` go to httpx.
61
+ ``url`` is a full URL (the client has two bases); ``kwargs`` go to httpx. The
62
+ bearer token goes to whatever host ``url`` names, so ``url`` must come from
63
+ configuration or code, never from a caller's input; redirects are not
64
+ followed unless asked, and httpx drops the header on a cross-origin redirect.
65
+
62
66
  With a ``credential``, a 401 buys one retry with the credential's current
63
67
  token, which it replaces when the rejected one is old enough to have been
64
68
  revoked (``REPLACE_COOLDOWN_S``); a 401 on the retry raises. Without a
@@ -237,7 +237,8 @@ class AsyncClientCredentials(_Credentials):
237
237
 
238
238
  Pass it to ``AsyncClient(credential=...)`` and use ``await get_token()`` for
239
239
  requests the SDK does not make itself, such as a WebSocket upgrade. One
240
- instance serves any number of clients and callers.
240
+ instance serves any number of clients and callers on one event loop; its
241
+ lock and ``httpx.AsyncClient`` bind to that loop.
241
242
  """
242
243
 
243
244
  def __init__(
@@ -58,7 +58,11 @@ class Client(_BaseClient):
58
58
  def request(self, method: str, url: str, **kwargs) -> httpx.Response:
59
59
  """Send any request with this client's bearer token.
60
60
 
61
- ``url`` is a full URL (the client has two bases); ``kwargs`` go to httpx.
61
+ ``url`` is a full URL (the client has two bases); ``kwargs`` go to httpx. The
62
+ bearer token goes to whatever host ``url`` names, so ``url`` must come from
63
+ configuration or code, never from a caller's input; redirects are not
64
+ followed unless asked, and httpx drops the header on a cross-origin redirect.
65
+
62
66
  With a ``credential``, a 401 buys one retry with the credential's current
63
67
  token, which it replaces when the rejected one is old enough to have been
64
68
  revoked (``REPLACE_COOLDOWN_S``); a 401 on the retry raises. Without a
@@ -9,6 +9,7 @@ from .protocol import (
9
9
  HEARTBEAT_INTERVAL_S,
10
10
  MAX_BODY_BYTES,
11
11
  MAX_FRAME_BYTES,
12
+ MAX_MESSAGE_CHARS,
12
13
  REGISTER_DEADLINE_S,
13
14
  RELAY_TIMEOUT_S,
14
15
  STALE_AFTER_S,
@@ -18,6 +19,7 @@ from .protocol import (
18
19
  Error,
19
20
  ErrorCode,
20
21
  Frame,
22
+ FrameTooLarge,
21
23
  Heartbeat,
22
24
  InvalidFrame,
23
25
  ModelEntry,
@@ -40,6 +42,7 @@ __all__ = [
40
42
  "HEARTBEAT_INTERVAL_S",
41
43
  "MAX_BODY_BYTES",
42
44
  "MAX_FRAME_BYTES",
45
+ "MAX_MESSAGE_CHARS",
43
46
  "REGISTER_DEADLINE_S",
44
47
  "RELAY_TIMEOUT_S",
45
48
  "STALE_AFTER_S",
@@ -49,6 +52,7 @@ __all__ = [
49
52
  "Error",
50
53
  "ErrorCode",
51
54
  "Frame",
55
+ "FrameTooLarge",
52
56
  "Heartbeat",
53
57
  "InvalidFrame",
54
58
  "ModelEntry",
@@ -28,6 +28,8 @@ MAX_BODY_BYTES = 15 * 1024 * 1024
28
28
  # Timeouts nest: the agent waits for VS, the relay waits a little longer for the agent.
29
29
  AGENT_TIMEOUT_S = 100
30
30
  RELAY_TIMEOUT_S = 110.0
31
+ # An `error` echoes parts of the peer's frame; cut them so the error fits a frame.
32
+ MAX_MESSAGE_CHARS = 200
31
33
 
32
34
  # Platform workspace ids: lowercase hyphenated UUIDs.
33
35
  UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
@@ -58,6 +60,7 @@ class ErrorCode(StrEnum):
58
60
  PATH_NOT_ALLOWED = "path_not_allowed"
59
61
  UPSTREAM_UNREACHABLE = "upstream_unreachable"
60
62
  UPSTREAM_TIMEOUT = "upstream_timeout"
63
+ UPSTREAM_TOO_LARGE = "upstream_too_large"
61
64
 
62
65
 
63
66
  class Frame(BaseModel):
@@ -133,13 +136,30 @@ _relay_frame = TypeAdapter(Annotated[RelayFrame, Field(discriminator="type")])
133
136
 
134
137
 
135
138
  class InvalidFrame(ValueError):
136
- """A frame the receiver cannot read; the text is safe to send back in an ``error``."""
139
+ """A frame the receiver cannot read; the text is safe to send back in an ``error``.
140
+
141
+ Parts of the text come from the peer's frame, so it is cut at ``MAX_MESSAGE_CHARS``
142
+ and a ``request_id`` longer than that is dropped.
143
+ """
137
144
 
138
145
  def __init__(self, message: str, request_id: str | None = None) -> None:
146
+ if len(message) > MAX_MESSAGE_CHARS:
147
+ message = message[: MAX_MESSAGE_CHARS - 1] + "…"
148
+ if request_id is not None and len(request_id) > MAX_MESSAGE_CHARS:
149
+ request_id = None
150
+ message += " (request_id too long, dropped)"
139
151
  super().__init__(message)
140
152
  self.request_id = request_id
141
153
 
142
154
 
155
+ class FrameTooLarge(ValueError):
156
+ """The encoded frame exceeds ``MAX_FRAME_BYTES``; the receiver would close with 1009."""
157
+
158
+ def __init__(self, size: int) -> None:
159
+ super().__init__(f"frame is {size} bytes; the limit is {MAX_FRAME_BYTES}")
160
+ self.size = size
161
+
162
+
143
163
  def parse_agent_frame(text: str) -> AgentFrame:
144
164
  """A frame the relay received from an agent."""
145
165
  return _parse(text, _agent_frame)
@@ -175,4 +195,9 @@ def _parse(text: str, adapter: TypeAdapter):
175
195
 
176
196
 
177
197
  def dump_frame(frame: BaseModel) -> str:
178
- return frame.model_dump_json(exclude_none=True)
198
+ """The frame as JSON text, or ``FrameTooLarge`` when a receiver would refuse it."""
199
+ text = frame.model_dump_json(exclude_none=True)
200
+ size = len(text.encode())
201
+ if size > MAX_FRAME_BYTES:
202
+ raise FrameTooLarge(size)
203
+ return text
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: visionai-sdk-python
3
- Version: 2.2.0.dev4
3
+ Version: 2.2.0.dev5
4
4
  Summary: VisionAI SDK for Python
5
5
  Author-email: Tony Yang <tonyyang@linkervision.com>
6
6
  Requires-Python: >=3.11
@@ -148,7 +148,8 @@ client = Client(auth_url="...", vlm_url="...", credential=cred) # vlm.chat() u
148
148
 
149
149
  token = cred.get_token().token # for a WebSocket upgrade or another library
150
150
 
151
- # Any other HTTP call with the same token; a 401 gets one fresh token and one retry
151
+ # Any other HTTP call with the same token, sent to the host you name; a 401 gets one
152
+ # fresh token and one retry
152
153
  nodes = client.request("GET", "https://vs.example.com/mgmt/nodes").json()
153
154
  ```
154
155
 
@@ -168,7 +169,7 @@ authentication wins. `vlm_url` is optional for clients that only authenticate.
168
169
 
169
170
  `visionai_sdk_python.relay` holds the wire format between platform-relay and
170
171
  platform-edge-agent: the frame models, the constants and `parse_agent_frame()` /
171
- `parse_relay_frame()` / `dump_frame()`. The spec is platform-service's
172
+ `parse_relay_frame()` / `dump_frame()` (which refuses a frame over `MAX_FRAME_BYTES`). The spec is platform-service's
172
173
  `docs/adr/0001-relay-protocol.md`; both sides import this module instead of keeping a
173
174
  copy. The ADR's JSON examples ship with it (`visionai_sdk_python.relay.examples`:
174
175
  `example(name)` / `example_text(name)`), so every implementation tests against the same
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "2.2.0.dev5",
3
+ "distance": 0,
4
+ "node": "g300feb4397722a72a04551325a2fc4d0d8c0e3f3",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-22"
8
+ }
@@ -5,7 +5,10 @@ import json
5
5
  import pytest
6
6
 
7
7
  from visionai_sdk_python.relay import (
8
+ MAX_FRAME_BYTES,
9
+ MAX_MESSAGE_CHARS,
8
10
  Error,
11
+ FrameTooLarge,
9
12
  Heartbeat,
10
13
  InvalidFrame,
11
14
  Register,
@@ -173,3 +176,30 @@ def test_workspace_id_format(value, ok):
173
176
  def test_every_shipped_example_is_valid_json_with_a_type():
174
177
  for name in NAMES:
175
178
  assert isinstance(json.loads(example(name))["type"], str), name
179
+
180
+
181
+ def test_an_error_message_is_cut_so_it_fits_a_frame():
182
+ with pytest.raises(InvalidFrame) as exc:
183
+ parse_agent_frame(json.dumps({"type": "x" * 1_000_000}))
184
+
185
+ assert len(str(exc.value)) == MAX_MESSAGE_CHARS
186
+ assert str(exc.value).endswith("…")
187
+
188
+
189
+ def test_an_oversized_request_id_is_dropped_from_the_error():
190
+ frame = {"type": "request", "request_id": "r" * 1_000_000}
191
+ with pytest.raises(InvalidFrame) as exc:
192
+ parse_relay_frame(json.dumps(frame))
193
+
194
+ assert exc.value.request_id is None
195
+ assert str(exc.value).endswith("(request_id too long, dropped)")
196
+
197
+
198
+ def test_dump_frame_refuses_a_frame_the_receiver_would_close_on():
199
+ frame = Response(request_id="r1", status=200, body="x" * MAX_FRAME_BYTES)
200
+
201
+ with pytest.raises(FrameTooLarge) as exc:
202
+ dump_frame(frame)
203
+
204
+ assert exc.value.size > MAX_FRAME_BYTES
205
+ assert dump_frame(Response(request_id="r1", status=200, body="x"))
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "2.2.0.dev4",
3
- "distance": 0,
4
- "node": "gc851da12b0ae901b04a22a963d81b8fba6fe5f26",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-10-21"
8
- }