codespar 0.10.2__tar.gz → 0.12.0__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 (40) hide show
  1. {codespar-0.10.2 → codespar-0.12.0}/CHANGELOG.md +46 -0
  2. {codespar-0.10.2 → codespar-0.12.0}/PKG-INFO +5 -2
  3. {codespar-0.10.2 → codespar-0.12.0}/pyproject.toml +8 -1
  4. {codespar-0.10.2 → codespar-0.12.0}/src/codespar/__init__.py +3 -1
  5. {codespar-0.10.2 → codespar-0.12.0}/src/codespar/_async_client.py +29 -1
  6. {codespar-0.10.2 → codespar-0.12.0}/src/codespar/_async_session.py +87 -26
  7. {codespar-0.10.2 → codespar-0.12.0}/src/codespar/_http.py +53 -2
  8. {codespar-0.10.2 → codespar-0.12.0}/src/codespar/_sync_client.py +91 -32
  9. {codespar-0.10.2 → codespar-0.12.0}/src/codespar/errors.py +4 -0
  10. codespar-0.12.0/src/codespar/mandate.py +424 -0
  11. {codespar-0.10.2 → codespar-0.12.0}/src/codespar/types.py +1 -1
  12. codespar-0.12.0/tests/_fixtures/canonical.v3.fixture.json +31 -0
  13. codespar-0.12.0/tests/_fixtures/canonical.v4.fixture.json +32 -0
  14. {codespar-0.10.2 → codespar-0.12.0}/tests/_fixtures/shop_canonical.json +1 -1
  15. codespar-0.12.0/tests/test_mandate.py +223 -0
  16. codespar-0.12.0/tests/test_mandate_strict.py +321 -0
  17. {codespar-0.10.2 → codespar-0.12.0}/tests/test_session.py +84 -0
  18. codespar-0.12.0/tests/test_timeout.py +237 -0
  19. {codespar-0.10.2 → codespar-0.12.0}/.gitignore +0 -0
  20. {codespar-0.10.2 → codespar-0.12.0}/README.md +0 -0
  21. {codespar-0.10.2 → codespar-0.12.0}/examples/README.md +0 -0
  22. {codespar-0.10.2 → codespar-0.12.0}/examples/async_basic.py +0 -0
  23. {codespar-0.10.2 → codespar-0.12.0}/examples/connect_link.py +0 -0
  24. {codespar-0.10.2 → codespar-0.12.0}/examples/ecommerce_checkout.py +0 -0
  25. {codespar-0.10.2 → codespar-0.12.0}/examples/mocks_round_trip.py +0 -0
  26. {codespar-0.10.2 → codespar-0.12.0}/examples/pix_payment.py +0 -0
  27. {codespar-0.10.2 → codespar-0.12.0}/examples/proxy_execute.py +0 -0
  28. {codespar-0.10.2 → codespar-0.12.0}/src/codespar/_presets.py +0 -0
  29. {codespar-0.10.2 → codespar-0.12.0}/src/codespar/py.typed +0 -0
  30. {codespar-0.10.2 → codespar-0.12.0}/src/codespar/tool_result_codes.py +0 -0
  31. {codespar-0.10.2 → codespar-0.12.0}/tests/__init__.py +0 -0
  32. {codespar-0.10.2 → codespar-0.12.0}/tests/_fixtures/mocks_canonical.json +0 -0
  33. {codespar-0.10.2 → codespar-0.12.0}/tests/test_base_url_resolution.py +0 -0
  34. {codespar-0.10.2 → codespar-0.12.0}/tests/test_config.py +0 -0
  35. {codespar-0.10.2 → codespar-0.12.0}/tests/test_error_code_precedence.py +0 -0
  36. {codespar-0.10.2 → codespar-0.12.0}/tests/test_forward_mocks.py +0 -0
  37. {codespar-0.10.2 → codespar-0.12.0}/tests/test_mocks_types.py +0 -0
  38. {codespar-0.10.2 → codespar-0.12.0}/tests/test_shop.py +0 -0
  39. {codespar-0.10.2 → codespar-0.12.0}/tests/test_streaming.py +0 -0
  40. {codespar-0.10.2 → codespar-0.12.0}/tests/test_tool_result_codes.py +0 -0
@@ -1,5 +1,51 @@
1
1
  # codespar (Python SDK) — CHANGELOG
2
2
 
3
+ ## 0.12.0
4
+
5
+ `codespar.mandate` is strict, in parity with `@codespar/sdk/mandate` 0.16.11
6
+ and `codespar mandate verify` 0.17.0 (codespar-core#123, p2-identity#6).
7
+
8
+ ### Changed
9
+
10
+ - `verify_mandate_token(...).verified` is `True` only when both Ed25519
11
+ signatures are carried and verify, the agent's under the key the token
12
+ names, and the token is not expired. It used to be `True` with the agent
13
+ signature stripped, with a signature left unchecked (no key given), and
14
+ after `expires_at`. `verify_mandate_token(t, agent_public_key=...)` alone is
15
+ now `verified=False` with `failures == ["issuer_sig_unchecked"]`.
16
+ - The key is the one the signed `agent_kid` names; an envelope `kid` that
17
+ differs is refused (`kid_mismatch`).
18
+ - `__version__` says `0.12.0`; it had said `0.10.2` through 0.11.0.
19
+
20
+ ### Added
21
+
22
+ - `agent_did_document=`: the agent's parsed did:web document; only the
23
+ `verificationMethod` whose `id` equals the signed `agent_kid` is used, so a
24
+ retired key listed beside the active one does not verify a token naming the
25
+ active one. Mutually exclusive with `agent_public_key` (`TypeError`).
26
+ - `now=` (UNIX seconds) for the expiry check; default is the current time.
27
+ - `MandateVerification.failures`, `.expired`, `.issued_at`.
28
+ - V4 tokens (`format_version: 4`, signed `issued_at`), byte-locked against the
29
+ enterprise fixture. An unknown later format raises
30
+ `MandateDecodeError("mandate_format_unsupported")`.
31
+
32
+ ## 0.11.0
33
+
34
+ Offline V3 mandate verification lands as the `codespar.mandate`
35
+ submodule, in parity with `@codespar/sdk/mandate` and the CLI. See
36
+ [codespar/codespar-core#114](https://github.com/codespar/codespar-core/pull/114).
37
+
38
+ ### Added
39
+
40
+ - `codespar.mandate`: `decode_mandate_token` and
41
+ `reconstruct_signing_string` are pure stdlib; `verify_ed25519` and
42
+ `verify_mandate_token` verify the V3 dual Ed25519 signatures and
43
+ lazily import `cryptography`, raising a clear install hint when it is
44
+ absent. New optional extra: `pip install codespar[verify]`. The base
45
+ package keeps its single runtime dependency (httpx).
46
+ - The canonical signing string is byte-locked against the platform's
47
+ frozen fixture, shared with the TS SDK and CLI tests.
48
+
3
49
  ## 0.10.0
4
50
 
5
51
  The hosted test-mode SDK surface lands across the `codespar` Python
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: codespar
3
- Version: 0.10.2
3
+ Version: 0.12.0
4
4
  Summary: Python SDK for CodeSpar — commerce infrastructure for AI agents in Latin America.
5
5
  Project-URL: Homepage, https://codespar.dev
6
6
  Project-URL: Documentation, https://docs.codespar.dev
@@ -22,11 +22,14 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
22
  Requires-Python: >=3.10
23
23
  Requires-Dist: httpx>=0.27.0
24
24
  Provides-Extra: dev
25
+ Requires-Dist: cryptography>=42; extra == 'dev'
25
26
  Requires-Dist: mypy>=1.11; extra == 'dev'
26
27
  Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
27
28
  Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
28
29
  Requires-Dist: pytest>=8.0; extra == 'dev'
29
30
  Requires-Dist: ruff>=0.6; extra == 'dev'
31
+ Provides-Extra: verify
32
+ Requires-Dist: cryptography>=42; extra == 'verify'
30
33
  Description-Content-Type: text/markdown
31
34
 
32
35
  # codespar — Python SDK
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codespar"
7
- version = "0.10.2"
7
+ version = "0.12.0"
8
8
  description = "Python SDK for CodeSpar — commerce infrastructure for AI agents in Latin America."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -30,10 +30,17 @@ dependencies = [
30
30
  ]
31
31
 
32
32
  [project.optional-dependencies]
33
+ # Offline V3 mandate verification (codespar.mandate) needs Ed25519, which is
34
+ # not in the stdlib. Opt in with `pip install codespar[verify]`; the base
35
+ # package keeps its single runtime dependency (httpx).
36
+ verify = [
37
+ "cryptography>=42",
38
+ ]
33
39
  dev = [
34
40
  "pytest>=8.0",
35
41
  "pytest-asyncio>=0.23",
36
42
  "pytest-httpx>=0.30",
43
+ "cryptography>=42",
37
44
  "ruff>=0.6",
38
45
  "mypy>=1.11",
39
46
  ]
@@ -35,6 +35,7 @@ from .errors import (
35
35
  ConfigError,
36
36
  NotConnectedError,
37
37
  StreamError,
38
+ TimeoutError,
38
39
  )
39
40
  from .tool_result_codes import (
40
41
  APPROVAL_REQUIRED,
@@ -140,7 +141,7 @@ from .types import (
140
141
  WizardAction,
141
142
  )
142
143
 
143
- __version__ = "0.10.2"
144
+ __version__ = "0.12.0"
144
145
 
145
146
  __all__ = [
146
147
  "APPROVAL_REQUIRED",
@@ -249,6 +250,7 @@ __all__ = [
249
250
  "StreamError",
250
251
  # Streaming events
251
252
  "StreamEvent",
253
+ "TimeoutError",
252
254
  "Tool",
253
255
  "ToolCallRecord",
254
256
  "ToolNotMockedOutput",
@@ -21,7 +21,7 @@ from ._async_session import (
21
21
  build_session_info,
22
22
  wait_for_connections,
23
23
  )
24
- from ._http import DEFAULT_BASE_URL, request_json
24
+ from ._http import DEFAULT_BASE_URL, normalize_timeout, request_json
25
25
  from ._presets import preset_to_servers
26
26
  from .errors import ApiError, ConfigError
27
27
  from .types import SessionConfig
@@ -57,6 +57,18 @@ class AsyncCodeSpar:
57
57
  session = await cs.create("user_123", preset="brazilian")
58
58
  result = await session.send("charge R$500 via Pix")
59
59
  print(result.message)
60
+
61
+ Timeout UNIT: every ``timeout`` here is in **seconds** (httpx
62
+ convention) — e.g. ``timeout=30`` is 30 seconds. The TypeScript SDK
63
+ (``@codespar/sdk``) uses **milliseconds** for the equivalent option.
64
+ Do not copy a numeric timeout between the two SDKs without
65
+ converting (30s in Python == ``timeout: 30000`` in TS).
66
+
67
+ Cancellation: there is no AbortSignal in Python. Cancel an in-flight
68
+ call by cancelling the awaiting asyncio task (e.g. asyncio.timeout()
69
+ or task.cancel()); httpx tears the connection down. Note this only
70
+ closes the connection — it does not undo an upstream side effect the
71
+ backend already dispatched.
60
72
  """
61
73
 
62
74
  def __init__(
@@ -77,6 +89,9 @@ class AsyncCodeSpar:
77
89
  raise ConfigError(
78
90
  "project_id must match ^prj_[A-Za-z0-9]{16}$ (e.g. 'prj_...')."
79
91
  )
92
+ # Fail fast on a misconfigured client-default timeout, same
93
+ # rule the per-call paths use (parity with TS validateTimeout).
94
+ timeout = normalize_timeout(timeout) or 60.0
80
95
  self._api_key = api_key
81
96
  self._base_url = _resolve_base_url(base_url).rstrip("/")
82
97
  self._project_id = project_id
@@ -112,6 +127,18 @@ class AsyncCodeSpar:
112
127
  await cs.create("user_123", preset="brazilian")
113
128
  await cs.create("user_123", SessionConfig(preset="brazilian"))
114
129
  """
130
+ # Extract before _resolve_config so it doesn't reach the allowed-kwarg check.
131
+ raw_timeout = kwargs.pop("timeout", None)
132
+ timeout: float | None
133
+ if raw_timeout is None:
134
+ timeout = None
135
+ elif isinstance(raw_timeout, bool) or not isinstance(raw_timeout, (int, float)):
136
+ raise ConfigError(
137
+ f"create(): 'timeout' must be a number of seconds, got {type(raw_timeout).__name__}"
138
+ )
139
+ else:
140
+ timeout = float(raw_timeout)
141
+
115
142
  resolved = self._resolve_config(config, kwargs)
116
143
  servers = resolved.servers or preset_to_servers(resolved.preset)
117
144
  project_id = resolved.project_id or self._project_id
@@ -133,6 +160,7 @@ class AsyncCodeSpar:
133
160
  api_key=self._api_key,
134
161
  project_id=project_id,
135
162
  body=body,
163
+ timeout=timeout,
136
164
  )
137
165
  if not isinstance(data, dict):
138
166
  raise ApiError("create: malformed response", status=0, body=data)
@@ -18,7 +18,7 @@ from typing import Any
18
18
  import httpx
19
19
 
20
20
  from ._http import request_json, stream_sse, stream_sse_get
21
- from .errors import ApiError, ConfigError
21
+ from .errors import ApiError, ConfigError, TimeoutError
22
22
  from .types import (
23
23
  AssistantTextEvent,
24
24
  AuthConfig,
@@ -397,7 +397,13 @@ class AsyncSession:
397
397
 
398
398
  # ── execution ───────────────────────────────────────────────────────
399
399
 
400
- async def execute(self, tool_name: str, params: dict[str, Any]) -> ToolResult:
400
+ async def execute(
401
+ self,
402
+ tool_name: str,
403
+ params: dict[str, Any],
404
+ *,
405
+ timeout: float | None = None,
406
+ ) -> ToolResult:
401
407
  """Call a specific tool by name. Always returns a ToolResult, even on error."""
402
408
  start = _now_ms()
403
409
  try:
@@ -408,6 +414,7 @@ class AsyncSession:
408
414
  api_key=self._api_key,
409
415
  project_id=self._project_id,
410
416
  body={"tool": tool_name, "input": params},
417
+ timeout=timeout,
411
418
  )
412
419
  except ApiError as exc:
413
420
  return ToolResult(
@@ -444,6 +451,8 @@ class AsyncSession:
444
451
  self,
445
452
  use_case: str,
446
453
  options: DiscoverOptions | None = None,
454
+ *,
455
+ timeout: float | None = None,
447
456
  ) -> DiscoverResult:
448
457
  """
449
458
  Search the catalog for a tool that matches a free-form use case.
@@ -459,7 +468,7 @@ class AsyncSession:
459
468
  params["country"] = options.country
460
469
  if options.limit is not None:
461
470
  params["limit"] = options.limit
462
- result = await self.execute("codespar_discover", params)
471
+ result = await self.execute("codespar_discover", params, timeout=timeout)
463
472
  if not result.success:
464
473
  raise ApiError(
465
474
  f"discover failed: {result.error or 'unknown'}",
@@ -473,6 +482,8 @@ class AsyncSession:
473
482
  async def connection_wizard(
474
483
  self,
475
484
  options: ConnectionWizardOptions,
485
+ *,
486
+ timeout: float | None = None,
476
487
  ) -> ConnectionWizardResult:
477
488
  """
478
489
  Surface the connection wizard for a server (or list every
@@ -497,7 +508,7 @@ class AsyncSession:
497
508
  params["environment"] = options.environment
498
509
  if options.return_to is not None:
499
510
  params["return_to"] = options.return_to
500
- result = await self.execute("codespar_manage_connections", params)
511
+ result = await self.execute("codespar_manage_connections", params, timeout=timeout)
501
512
  if not result.success:
502
513
  raise ApiError(
503
514
  f"connection_wizard failed: {result.error or 'unknown'}",
@@ -512,7 +523,7 @@ class AsyncSession:
512
523
  )
513
524
  return _parse_wizard_result(result.data)
514
525
 
515
- async def charge(self, args: ChargeArgs) -> ChargeResult:
526
+ async def charge(self, args: ChargeArgs, *, timeout: float | None = None) -> ChargeResult:
516
527
  """
517
528
  Create an INBOUND charge — the buyer pays the merchant. Typed
518
529
  wrapper around ``execute("codespar_charge", {...})``. Distinct
@@ -538,7 +549,7 @@ class AsyncSession:
538
549
  }
539
550
  if args.due_date is not None:
540
551
  params["due_date"] = args.due_date
541
- result = await self.execute("codespar_charge", params)
552
+ result = await self.execute("codespar_charge", params, timeout=timeout)
542
553
  if not result.success:
543
554
  raise ApiError(
544
555
  f"charge failed: {result.error or 'unknown'}",
@@ -549,7 +560,7 @@ class AsyncSession:
549
560
  raise ApiError("charge: malformed response", status=0, body=result.data)
550
561
  return _parse_charge_result(result.data)
551
562
 
552
- async def ship(self, args: ShipArgs) -> ShipResult:
563
+ async def ship(self, args: ShipArgs, *, timeout: float | None = None) -> ShipResult:
553
564
  """
554
565
  Generate a shipping label OR fetch tracking status. Typed
555
566
  wrapper around ``execute("codespar_ship", {...})``. Routes to
@@ -606,7 +617,7 @@ class AsyncSession:
606
617
  params["tracking_code"] = args.tracking_code
607
618
  if args.metadata is not None:
608
619
  params["metadata"] = args.metadata
609
- result = await self.execute("codespar_ship", params)
620
+ result = await self.execute("codespar_ship", params, timeout=timeout)
610
621
  if not result.success:
611
622
  raise ApiError(
612
623
  f"ship failed: {result.error or 'unknown'}",
@@ -617,7 +628,7 @@ class AsyncSession:
617
628
  raise ApiError("ship: malformed response", status=0, body=result.data)
618
629
  return _parse_ship_result(result.data)
619
630
 
620
- async def ledger(self, args: LedgerArgs) -> LedgerResult:
631
+ async def ledger(self, args: LedgerArgs, *, timeout: float | None = None) -> LedgerResult:
621
632
  """
622
633
  Post a double-entry journal entry, read an account's balances,
623
634
  or create an account on the tenant's self-hosted Lerian Midaz
@@ -657,7 +668,7 @@ class AsyncSession:
657
668
  params["type"] = args.type
658
669
  if args.metadata is not None:
659
670
  params["metadata"] = args.metadata
660
- result = await self.execute("codespar_ledger", params)
671
+ result = await self.execute("codespar_ledger", params, timeout=timeout)
661
672
  if not result.success:
662
673
  raise ApiError(
663
674
  f"ledger failed: {result.error or 'unknown'}",
@@ -668,7 +679,7 @@ class AsyncSession:
668
679
  raise ApiError("ledger: malformed response", status=0, body=result.data)
669
680
  return _parse_ledger_result(result.data)
670
681
 
671
- async def issue(self, args: IssueArgs) -> IssueResult:
682
+ async def issue(self, args: IssueArgs, *, timeout: float | None = None) -> IssueResult:
672
683
  """
673
684
  Issue a virtual/physical card, control (freeze/unfreeze/cancel)
674
685
  one, or read its status on the tenant's Pomelo card-issuing
@@ -694,7 +705,7 @@ class AsyncSession:
694
705
  params["shipping_address"] = args.shipping_address
695
706
  if args.metadata is not None:
696
707
  params["metadata"] = args.metadata
697
- result = await self.execute("codespar_issue", params)
708
+ result = await self.execute("codespar_issue", params, timeout=timeout)
698
709
  if not result.success:
699
710
  raise ApiError(
700
711
  f"issue failed: {result.error or 'unknown'}",
@@ -705,7 +716,7 @@ class AsyncSession:
705
716
  raise ApiError("issue: malformed response", status=0, body=result.data)
706
717
  return _parse_issue_result(result.data)
707
718
 
708
- async def shop(self, args: ShopArgs) -> ShopResult:
719
+ async def shop(self, args: ShopArgs, *, timeout: float | None = None) -> ShopResult:
709
720
  """
710
721
  Buy-side shopping: catalog search → async checkout → Pix mint.
711
722
  Typed wrapper around ``execute("codespar_shop", {...})``. The
@@ -778,7 +789,7 @@ class AsyncSession:
778
789
  else: # checkout_status
779
790
  if args.checkout_session_id is not None:
780
791
  params["checkout_session_id"] = args.checkout_session_id
781
- result = await self.execute("codespar_shop", params)
792
+ result = await self.execute("codespar_shop", params, timeout=timeout)
782
793
  if not result.success:
783
794
  raise ApiError(
784
795
  f"shop failed: {result.error or 'unknown'}",
@@ -790,7 +801,10 @@ class AsyncSession:
790
801
  return _parse_shop_result(result.data, args.action)
791
802
 
792
803
  async def verification_status(
793
- self, tool_call_id: str
804
+ self,
805
+ tool_call_id: str,
806
+ *,
807
+ timeout: float | None = None,
794
808
  ) -> VerificationStatusResult:
795
809
  """
796
810
  Async KYC poll for a meta-tool ``codespar_kyc`` call. Correlates
@@ -817,6 +831,7 @@ class AsyncSession:
817
831
  f"/v1/tool-calls/{quote(tool_call_id, safe='')}/verification-status",
818
832
  api_key=self._api_key,
819
833
  project_id=self._project_id,
834
+ timeout=timeout,
820
835
  )
821
836
  if not isinstance(data, dict):
822
837
  raise ApiError(
@@ -847,6 +862,7 @@ class AsyncSession:
847
862
  *,
848
863
  on_update: Callable[[VerificationStatusResult], Awaitable[None] | None]
849
864
  | None = None,
865
+ timeout: float | None = None,
850
866
  ) -> VerificationStatusResult:
851
867
  """
852
868
  SSE-streamed sibling of ``verification_status``. Opens
@@ -872,6 +888,7 @@ class AsyncSession:
872
888
  f"/v1/tool-calls/{quote(tool_call_id, safe='')}/verification-status/stream",
873
889
  api_key=self._api_key,
874
890
  project_id=self._project_id,
891
+ timeout=timeout,
875
892
  ):
876
893
  if event_name in ("snapshot", "update"):
877
894
  if not isinstance(raw, dict):
@@ -908,7 +925,12 @@ class AsyncSession:
908
925
  )
909
926
  return last
910
927
 
911
- async def payment_status(self, tool_call_id: str) -> PaymentStatusResult:
928
+ async def payment_status(
929
+ self,
930
+ tool_call_id: str,
931
+ *,
932
+ timeout: float | None = None,
933
+ ) -> PaymentStatusResult:
912
934
  """
913
935
  Async settlement check for a meta-tool payment call. Correlates
914
936
  a ``tool_call_id`` (the ``tc_xxx`` returned by ``execute``) back
@@ -927,6 +949,7 @@ class AsyncSession:
927
949
  f"/v1/tool-calls/{quote(tool_call_id, safe='')}/payment-status",
928
950
  api_key=self._api_key,
929
951
  project_id=self._project_id,
952
+ timeout=timeout,
930
953
  )
931
954
  if not isinstance(data, dict):
932
955
  raise ApiError(
@@ -957,6 +980,7 @@ class AsyncSession:
957
980
  *,
958
981
  on_update: Callable[[PaymentStatusResult], Awaitable[None] | None]
959
982
  | None = None,
983
+ timeout: float | None = None,
960
984
  ) -> PaymentStatusResult:
961
985
  """
962
986
  SSE-streamed sibling of ``payment_status``. Opens
@@ -978,6 +1002,7 @@ class AsyncSession:
978
1002
  f"/v1/tool-calls/{quote(tool_call_id, safe='')}/payment-status/stream",
979
1003
  api_key=self._api_key,
980
1004
  project_id=self._project_id,
1005
+ timeout=timeout,
981
1006
  ):
982
1007
  if event_name in ("snapshot", "update"):
983
1008
  if not isinstance(raw, dict):
@@ -1016,7 +1041,12 @@ class AsyncSession:
1016
1041
 
1017
1042
  # ── proxy ───────────────────────────────────────────────────────────
1018
1043
 
1019
- async def proxy_execute(self, request: ProxyRequest) -> ProxyResult:
1044
+ async def proxy_execute(
1045
+ self,
1046
+ request: ProxyRequest,
1047
+ *,
1048
+ timeout: float | None = None,
1049
+ ) -> ProxyResult:
1020
1050
  """
1021
1051
  Raw HTTP proxy to a connected server's upstream API. Auth is
1022
1052
  injected by the backend — never send provider keys here.
@@ -1035,6 +1065,7 @@ class AsyncSession:
1035
1065
  "params": request.params,
1036
1066
  "headers": request.headers,
1037
1067
  },
1068
+ timeout=timeout,
1038
1069
  )
1039
1070
  if not isinstance(data, dict):
1040
1071
  raise ApiError("proxy_execute: malformed response", status=0, body=data)
@@ -1048,7 +1079,12 @@ class AsyncSession:
1048
1079
 
1049
1080
  # ── natural-language ────────────────────────────────────────────────
1050
1081
 
1051
- async def send(self, message: str) -> SendResult:
1082
+ async def send(
1083
+ self,
1084
+ message: str,
1085
+ *,
1086
+ timeout: float | None = None,
1087
+ ) -> SendResult:
1052
1088
  """Send a natural-language message. Blocks until the agent loop finishes."""
1053
1089
  data = await request_json(
1054
1090
  self._client,
@@ -1057,12 +1093,18 @@ class AsyncSession:
1057
1093
  api_key=self._api_key,
1058
1094
  project_id=self._project_id,
1059
1095
  body={"message": message},
1096
+ timeout=timeout,
1060
1097
  )
1061
1098
  if not isinstance(data, dict):
1062
1099
  raise ApiError("send: malformed response", status=0, body=data)
1063
1100
  return _parse_send_result(data)
1064
1101
 
1065
- async def send_stream(self, message: str) -> AsyncIterator[StreamEvent]:
1102
+ async def send_stream(
1103
+ self,
1104
+ message: str,
1105
+ *,
1106
+ timeout: float | None = None,
1107
+ ) -> AsyncIterator[StreamEvent]:
1066
1108
  """
1067
1109
  Stream a natural-language turn. Yields events as they arrive.
1068
1110
 
@@ -1081,6 +1123,7 @@ class AsyncSession:
1081
1123
  api_key=self._api_key,
1082
1124
  project_id=self._project_id,
1083
1125
  body={"message": message},
1126
+ timeout=timeout,
1084
1127
  ):
1085
1128
  event = _parse_stream_event(raw)
1086
1129
  if event is not None:
@@ -1088,7 +1131,13 @@ class AsyncSession:
1088
1131
 
1089
1132
  # ── Connect Links ───────────────────────────────────────────────────
1090
1133
 
1091
- async def authorize(self, server_id: str, config: AuthConfig) -> AuthResult:
1134
+ async def authorize(
1135
+ self,
1136
+ server_id: str,
1137
+ config: AuthConfig,
1138
+ *,
1139
+ timeout: float | None = None,
1140
+ ) -> AuthResult:
1092
1141
  """
1093
1142
  Start a Connect Link OAuth flow. Returns the URL your UI should
1094
1143
  open for the end user; CodeSpar's callback stores tokens and
@@ -1106,6 +1155,7 @@ class AsyncSession:
1106
1155
  "redirect_uri": config.redirect_uri,
1107
1156
  "scopes": config.scopes,
1108
1157
  },
1158
+ timeout=timeout,
1109
1159
  )
1110
1160
  if not isinstance(data, dict):
1111
1161
  raise ApiError("authorize: malformed response", status=0, body=data)
@@ -1117,7 +1167,11 @@ class AsyncSession:
1117
1167
 
1118
1168
  # ── connections ─────────────────────────────────────────────────────
1119
1169
 
1120
- async def connections(self) -> list[ServerConnection]:
1170
+ async def connections(
1171
+ self,
1172
+ *,
1173
+ timeout: float | None = None,
1174
+ ) -> list[ServerConnection]:
1121
1175
  """List server connections and refresh the internal tools cache."""
1122
1176
  try:
1123
1177
  data = await request_json(
@@ -1126,8 +1180,9 @@ class AsyncSession:
1126
1180
  f"/v1/sessions/{self.id}/connections",
1127
1181
  api_key=self._api_key,
1128
1182
  project_id=self._project_id,
1183
+ timeout=timeout,
1129
1184
  )
1130
- except ApiError:
1185
+ except (ApiError, TimeoutError):
1131
1186
  return list(self._cached_connections or [])
1132
1187
  if not isinstance(data, dict):
1133
1188
  return list(self._cached_connections or [])
@@ -1162,17 +1217,23 @@ class AsyncSession:
1162
1217
 
1163
1218
  # ── lifecycle ───────────────────────────────────────────────────────
1164
1219
 
1165
- async def close(self) -> None:
1220
+ async def close(
1221
+ self,
1222
+ *,
1223
+ timeout: float | None = None,
1224
+ ) -> None:
1166
1225
  """Close the session on the backend. Safe to call multiple times.
1167
- Best-effort — a 4xx/5xx here shouldn't crash the caller. The
1168
- backend cleans up stale sessions on a timer anyway."""
1169
- with contextlib.suppress(ApiError):
1226
+ Best-effort — a 4xx/5xx OR a backend timeout here shouldn't
1227
+ crash the caller. The backend cleans up stale sessions on a
1228
+ timer anyway."""
1229
+ with contextlib.suppress(ApiError, TimeoutError):
1170
1230
  await request_json(
1171
1231
  self._client,
1172
1232
  "DELETE",
1173
1233
  f"/v1/sessions/{self.id}",
1174
1234
  api_key=self._api_key,
1175
1235
  project_id=self._project_id,
1236
+ timeout=timeout,
1176
1237
  )
1177
1238
 
1178
1239