ctxmesh 0.1.0b1__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.
ctxmesh/__init__.py ADDED
@@ -0,0 +1,105 @@
1
+ """ctxmesh — the ctxmesh Python SDK.
2
+
3
+ Typed, optional sugar over the launcher's language-agnostic localhost platform
4
+ plane (ADR 0002). Every capability the SDK exposes is *also* a raw launcher
5
+ endpoint; the SDK never adds a capability the plane does not have — it only
6
+ removes the raw-HTTP boilerplate and applies the run context.
7
+
8
+ Surface:
9
+
10
+ from ctxmesh import agent
11
+
12
+ client = agent.from_env() # reads the launcher-injected env
13
+
14
+ client.memory.get(); client.memory.put(entries)
15
+ client.memory.append(entry); client.memory.search(query)
16
+
17
+ client.tools.list() # live discovery manifest (:2999)
18
+ client.tools.call(name, **args) # invoke a bound MCP tool
19
+
20
+ client.feedback.score(trace_id, name, value, comment=None) # :2995
21
+
22
+ client.model.chat(model, messages, **opts) # $MODEL_GATEWAY_URL; emits LLM span
23
+
24
+ # knowledge-base retrieval (M68, ADR 0061 Fork 3):
25
+ client.knowledge.search("what is X?") # POST :2998/knowledge/search
26
+
27
+ # multimodal content-parts helpers (M68, ADR 0061 Fork 5):
28
+ from ctxmesh import text_part, image_url, content
29
+ msgs = [{"role": "user", "content": content(
30
+ text_part("What is in this image?"),
31
+ image_url("https://example.com/photo.jpg"),
32
+ )}]
33
+
34
+ # step-tracing helpers for a custom loop (the M10 core). Bind the inbound
35
+ # request so the tree roots under the launcher's agent.invoke span:
36
+ with client.trace.request_context(request.headers):
37
+ with client.trace.step("plan") as step: # CHAIN span
38
+ plan = client.model.chat(model, messages) # nested LLM span
39
+ with client.trace.tool("search", args) as t: # TOOL span (child)
40
+ t.set_output(client.tools.call("search", **args))
41
+ step.set_output(plan)
42
+ """
43
+
44
+ from ctxmesh import agent
45
+ from ctxmesh._approval import pause_for_approval
46
+ from ctxmesh._multimodal import content, image_url, text_part
47
+ from ctxmesh.client import Client
48
+ from ctxmesh.config import PlaneConfig
49
+ from ctxmesh.errors import (
50
+ ApprovalRequiredError,
51
+ ConfigError,
52
+ CtxmeshError,
53
+ EndpointError,
54
+ GuardrailBlockedError,
55
+ NotInPodError,
56
+ )
57
+ from ctxmesh.knowledge import KnowledgeClient
58
+ from ctxmesh.managed import (
59
+ DEFAULT_MAX_STEPS,
60
+ ManagedConfig,
61
+ ManagedResult,
62
+ mint_conversation_id,
63
+ run_managed_loop,
64
+ )
65
+ from ctxmesh.model import ChatResponse, ModelClient
66
+ from ctxmesh.runs import Run, RunEvent, RunsClient
67
+ from ctxmesh.serve import InvokeRequest, serve
68
+ from ctxmesh.skills import Skill
69
+ from ctxmesh.trace import SpanHandle, TraceClient
70
+
71
+ __all__ = [
72
+ "agent",
73
+ "Client",
74
+ "PlaneConfig",
75
+ "CtxmeshError",
76
+ "ConfigError",
77
+ "NotInPodError",
78
+ "EndpointError",
79
+ "GuardrailBlockedError",
80
+ "ModelClient",
81
+ "ChatResponse",
82
+ "Skill",
83
+ "TraceClient",
84
+ "SpanHandle",
85
+ "run_managed_loop",
86
+ "mint_conversation_id",
87
+ "ManagedConfig",
88
+ "ManagedResult",
89
+ "DEFAULT_MAX_STEPS",
90
+ "pause_for_approval",
91
+ "ApprovalRequiredError",
92
+ "RunsClient",
93
+ "Run",
94
+ "RunEvent",
95
+ "serve",
96
+ "InvokeRequest",
97
+ # M68: knowledge-base retrieval
98
+ "KnowledgeClient",
99
+ # M68: multimodal content-parts helpers (ADR 0061 Fork 5)
100
+ "text_part",
101
+ "image_url",
102
+ "content",
103
+ ]
104
+
105
+ __version__ = "0.1.0-beta.1"
ctxmesh/_approval.py ADDED
@@ -0,0 +1,123 @@
1
+ """Request-scoped human-in-the-loop approvals (ADR 0034 §HITL, m32.4).
2
+
3
+ A run can gate a sensitive step on human approval. The agent calls
4
+ :func:`pause_for_approval(key, summary)`; if ``key`` is in the set of approvals GRANTED for this
5
+ run, the call returns and the step proceeds — otherwise it raises
6
+ :class:`ctxmesh.errors.ApprovalRequiredError`, which the managed loop turns into a
7
+ ``requires_action`` (approval) outcome. When the approver resolves it, the run is re-invoked with
8
+ the approved key in the granted set, so the same ``pause_for_approval`` call now proceeds.
9
+
10
+ The granted set is held in a :class:`contextvars.ContextVar` (PEP 567), **not** a module global —
11
+ the same no-cross-bleed guarantee as the run capability (:mod:`ctxmesh._capability`): each inbound
12
+ request runs in its own execution context, so concurrent runs never observe each other's approvals.
13
+ The only way to bind approvals is :func:`approval_scope`, which RESETS the ContextVar on exit; there
14
+ is no process-wide setter.
15
+ """
16
+
17
+ from contextlib import contextmanager
18
+ from contextvars import ContextVar
19
+ from typing import FrozenSet, Iterable, Iterator, Mapping, Optional
20
+
21
+ from ctxmesh.errors import ApprovalRequiredError
22
+
23
+ # The approvals granted for the CURRENT run (a set of step keys). Empty ⇒ nothing approved yet, so
24
+ # every pause_for_approval raises.
25
+ _granted_approvals: "ContextVar[FrozenSet[str]]" = ContextVar(
26
+ "ctxmesh_granted_approvals", default=frozenset()
27
+ )
28
+
29
+ # ── The stateless approval VOUCHER (ADR 0074 §3, m82.4) ──────────────────────────────────────────
30
+ #
31
+ # require-approval is enforced at the egress WIRE, not just here in the loop: a tool call for a
32
+ # require-approval tool is FORWARDED by the sidecar only when the request carries a valid, signed
33
+ # X-Ctxmesh-Approval voucher (a short-lived token bound to {runId, toolName} the BFF minted on a
34
+ # human's approval). The managed loop's pause_for_approval is now the PRESENTATION UX — necessary
35
+ # but no longer sufficient — and the SDK must RELAY the voucher on the tool-call retry so the
36
+ # sidecar forwards it.
37
+ #
38
+ # The voucher arrives on the RESUMED run's inbound /invoke headers (the BFF stamped
39
+ # X-Ctxmesh-Approval after approval-grant). We hold it request-scoped in a ContextVar and relay it
40
+ # on every outbound tool call — the EXACT sibling of the run-capability relay
41
+ # (:mod:`ctxmesh._capability`) and the record toggle (:mod:`ctxmesh._record`): a launcher-internal
42
+ # header the SDK forwards but never originates. The sidecar's {tool, run} binding means a voucher
43
+ # only unlocks the ONE approved tool; relaying it on every call is safe (a mismatched tool 403s).
44
+
45
+ # The header the BFF stamps on a resumed run and the SDK relays on each tool-call egress — must
46
+ # match runcap.ApprovalHeaderName on the Go side (internal/runcap) + hdrApproval (internal/bff).
47
+ # Case-insensitive on the wire.
48
+ APPROVAL_HEADER = "X-Ctxmesh-Approval"
49
+
50
+ # The request-scoped approval voucher. default=None ⇒ a run with no granted require-approval tool:
51
+ # the tool client relays no voucher and a require-approval tool gets the sidecar's 403.
52
+ _approval_voucher: "ContextVar[Optional[str]]" = ContextVar(
53
+ "ctxmesh_approval_voucher", default=None
54
+ )
55
+
56
+
57
+ @contextmanager
58
+ def approval_scope(approvals: Optional[Iterable[str]]) -> Iterator[None]:
59
+ """Bind the set of GRANTED approval keys for the duration of the block, then reset it.
60
+
61
+ Request-scoped: set on entry, RESET on exit, so a reused worker thread can never leak a prior
62
+ run's approvals. ``None`` binds the empty set (nothing approved).
63
+ """
64
+ token = _granted_approvals.set(frozenset(approvals or ()))
65
+ try:
66
+ yield
67
+ finally:
68
+ _granted_approvals.reset(token)
69
+
70
+
71
+ def pause_for_approval(key: str, summary: str) -> None:
72
+ """Gate the current step on human approval (human-in-the-loop, m32.4).
73
+
74
+ If ``key`` has been approved for this run (the approver resolved a prior pause), this returns
75
+ and the step proceeds. Otherwise it raises :class:`~ctxmesh.errors.ApprovalRequiredError`, which
76
+ the managed loop surfaces as a ``requires_action`` (approval) outcome carrying ``key`` +
77
+ ``summary`` (a human-readable description of what needs approving). The run resumes on approval.
78
+
79
+ ``key`` is a STABLE identifier for this decision point (e.g. ``"send-email"``) — the same key
80
+ must be used across the initial call and the resumed re-invoke so the approval matches.
81
+ """
82
+ if key in _granted_approvals.get():
83
+ return
84
+ raise ApprovalRequiredError(
85
+ f"approval required for {key!r}: {summary}", key=key, summary=summary
86
+ )
87
+
88
+
89
+ def current_approval_voucher() -> Optional[str]:
90
+ """Return the approval voucher bound to the CURRENT request context, or ``None``.
91
+
92
+ The tool client relays this on each outbound MCP tool call (the egress sidecar verifies it for a
93
+ require-approval tool). ``None`` outside a resumed/approved run — the sidecar then returns its
94
+ 403 ``approval_required`` for a require-approval tool.
95
+ """
96
+ return _approval_voucher.get()
97
+
98
+
99
+ def _extract_voucher(headers: Optional[Mapping[str, str]]) -> Optional[str]:
100
+ """Pull the approval voucher out of inbound *headers* case-insensitively (HTTP header case is
101
+ not guaranteed), returning ``None`` when absent or blank."""
102
+ if not headers:
103
+ return None
104
+ target = APPROVAL_HEADER.lower()
105
+ for key, value in headers.items():
106
+ if key.lower() == target:
107
+ stripped = (value or "").strip()
108
+ return stripped or None
109
+ return None
110
+
111
+
112
+ @contextmanager
113
+ def voucher_scope(headers: Optional[Mapping[str, str]]) -> Iterator[None]:
114
+ """Bind the approval voucher extracted from inbound *headers* for the duration of the block,
115
+ then reset it.
116
+
117
+ Request-scoped (set on entry, RESET on exit) so a reused worker thread can never leak a prior
118
+ request's voucher. A missing/blank header binds ``None`` (no granted require-approval tool)."""
119
+ token = _approval_voucher.set(_extract_voucher(headers))
120
+ try:
121
+ yield
122
+ finally:
123
+ _approval_voucher.reset(token)
ctxmesh/_capability.py ADDED
@@ -0,0 +1,64 @@
1
+ """Request-scoped propagation of the run capability (ADR 0030 §3).
2
+
3
+ The run capability is the unforgeable token the BFF mints at ``/invoke`` identifying the
4
+ INVOKING user (its ``sub`` is that user's hashed identity). It arrives on the inbound
5
+ request headers, and the SDK must relay it on every outbound MCP tool call so the egress
6
+ sidecar can resolve *that* user's credential — never another user's.
7
+
8
+ It is held in a :class:`contextvars.ContextVar` (PEP 567), **not** a module global. Each
9
+ inbound request runs in its own execution context — a fresh thread under the entrypoint's
10
+ ``ThreadingHTTPServer``, or an ``asyncio`` task — so concurrent requests for different
11
+ users never observe each other's capability. There is no cross-user bleed by construction.
12
+
13
+ There is deliberately **no setter that mutates a process-wide value**: the only way to bind
14
+ a capability is :func:`capability_scope`, which is request-scoped and RESETS the ContextVar
15
+ on exit. :func:`current_capability` only ever reads the current context. This structural
16
+ absence of a global accessor is the no-bleed guarantee (a compromised/injected agent cannot
17
+ reach out and set another request's capability).
18
+ """
19
+
20
+ from contextlib import contextmanager
21
+ from contextvars import ContextVar
22
+ from typing import Iterator, Mapping, Optional
23
+
24
+ # The header the BFF mints into and the launcher passes through — MUST match
25
+ # runcap.HeaderName on the Go side (internal/runcap). Case-insensitive on the wire.
26
+ CAPABILITY_HEADER = "X-Ctxmesh-Run-Capability"
27
+
28
+ # The request-scoped capability. default=None ⇒ a run with no capability (unattended /
29
+ # minting-disabled) resolves as org/public only, never another user's grant.
30
+ _run_capability: "ContextVar[Optional[str]]" = ContextVar("ctxmesh_run_capability", default=None)
31
+
32
+
33
+ def current_capability() -> Optional[str]:
34
+ """Return the run capability bound to the CURRENT request context, or ``None``."""
35
+ return _run_capability.get()
36
+
37
+
38
+ def _extract(headers: Optional[Mapping[str, str]]) -> Optional[str]:
39
+ """Pull the capability out of inbound *headers* case-insensitively (HTTP header case
40
+ is not guaranteed), returning ``None`` when absent or blank."""
41
+ if not headers:
42
+ return None
43
+ target = CAPABILITY_HEADER.lower()
44
+ for key, value in headers.items():
45
+ if key.lower() == target:
46
+ stripped = (value or "").strip()
47
+ return stripped or None
48
+ return None
49
+
50
+
51
+ @contextmanager
52
+ def capability_scope(headers: Optional[Mapping[str, str]]) -> Iterator[None]:
53
+ """Bind the run capability extracted from inbound *headers* for the duration of the
54
+ block, then reset it.
55
+
56
+ Request-scoped: the ContextVar is set on entry and RESET on exit, so even if a worker
57
+ thread is later reused for another request it can never leak a prior request's
58
+ capability. A missing/blank header binds ``None`` (an unattended run).
59
+ """
60
+ token = _run_capability.set(_extract(headers))
61
+ try:
62
+ yield
63
+ finally:
64
+ _run_capability.reset(token)
ctxmesh/_checkpoint.py ADDED
@@ -0,0 +1,117 @@
1
+ """L7 supervisor-loop checkpoint (ADR 0091) — the SDK side of the durable delegate suspend/resume.
2
+
3
+ A depth-0 durable supervisor that delegates and suspends serializes its managed-loop state into an
4
+ opaque **payload** string (built here) and returns it in the ``delegate_waiting`` marker. The BFF
5
+ wraps that payload in a hashed **envelope** (``internal/run/checkpoint.go``) and stores it in the
6
+ run's cursor. On resume the worker injects the ENVELOPE back into the invoke body as
7
+ ``body["checkpoint"]``; this module re-verifies it (kind / version / hash — defense in depth; the
8
+ worker already verified before injecting) and extracts the payload's fields.
9
+
10
+ The payload carries ONLY loop state — never authorization (consent/approval/OBO), which is
11
+ re-derived server-side on resume (ADR 0091 fork 3). Verification is fail-safe: a corrupt /
12
+ version-skewed envelope or payload yields ``None`` and the caller runs fresh from the request input.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import json
19
+ import logging
20
+ from typing import Any, Dict, Optional
21
+
22
+ _log = logging.getLogger("ctxmesh.checkpoint")
23
+
24
+ #: The envelope contract shared with the Go store (internal/run/checkpoint.go). Kept in lockstep: a
25
+ #: bump must roll the SDKs first (a resume rejects an unknown version → fail-safe full re-invoke).
26
+ _ENVELOPE_KIND = "supervisor-loop"
27
+ _ENVELOPE_VERSION = 1
28
+
29
+ #: The payload's OWN schema version (independent of the envelope — lets the SDK evolve the
30
+ #: loop-state
31
+ #: shape without touching the store's envelope contract). A resume rejects an unknown payload
32
+ #: version.
33
+ PAYLOAD_VERSION = 1
34
+
35
+ #: Cap on the serialized payload size. The ``delegate_waiting`` marker rides the /invoke response,
36
+ #: which the BFF reads through a 4 MiB LimitReader (internal/bff/invoke.go) — an oversized marker is
37
+ #: silently TRUNCATED, so the loop measures the payload and falls back to blocking dispatch above
38
+ #: this
39
+ #: threshold (a loud, graceful M64 degradation) rather than emitting a truncated, unparseable
40
+ #: marker.
41
+ CHECKPOINT_MAX_BYTES = 1_500_000
42
+
43
+
44
+ def build_payload(
45
+ *,
46
+ messages: list,
47
+ step: int,
48
+ pending: list,
49
+ tools_called: list,
50
+ consent_required: list,
51
+ spotlight_token: str,
52
+ model_index: int,
53
+ tool_index: int,
54
+ ) -> str:
55
+ """Serialize the resumable managed-loop state into the opaque payload string.
56
+
57
+ Only the fields that cannot be reconstructed on resume are persisted; everything re-derivable
58
+ (tool manifest, history/memory/knowledge, conversation/message ids, approvals, the per-run
59
+ breaker) is intentionally omitted and rebuilt from headers/body/store on resume.
60
+ """
61
+ payload: Dict[str, Any] = {
62
+ "v": PAYLOAD_VERSION,
63
+ "messages": messages,
64
+ "step": step,
65
+ "pending": pending,
66
+ "tools_called": tools_called,
67
+ "consent_required": consent_required,
68
+ "spotlight_token": spotlight_token,
69
+ "model_index": model_index,
70
+ "tool_index": tool_index,
71
+ }
72
+ return json.dumps(payload, separators=(",", ":"))
73
+
74
+
75
+ def verify_and_extract(envelope: Any) -> Optional[Dict[str, Any]]:
76
+ """Verify a checkpoint ENVELOPE and return its payload fields, or ``None`` (fail-safe).
77
+
78
+ *envelope* is ``body["checkpoint"]`` as parsed from the invoke body — the worker injects the Go
79
+ envelope as a raw JSON object, so it normally arrives as a ``dict`` (a JSON string is tolerated
80
+ too). ``None`` is returned — never raised — for any envelope that is absent, malformed, of an
81
+ unknown kind/version, whose payload hash does not match, or whose payload is an unknown version;
82
+ the caller then runs the turn fresh from the request input (the SDK mirror of the worker's
83
+ full-re-invoke fail-safe).
84
+ """
85
+ if envelope is None:
86
+ return None
87
+ env = envelope
88
+ if isinstance(env, (str, bytes)):
89
+ try:
90
+ env = json.loads(env)
91
+ except (json.JSONDecodeError, ValueError):
92
+ _log.warning("L7 checkpoint: envelope is not valid JSON — running fresh (fail-safe)")
93
+ return None
94
+ if not isinstance(env, dict):
95
+ return None
96
+ if env.get("kind") != _ENVELOPE_KIND or env.get("version") != _ENVELOPE_VERSION:
97
+ _log.warning(
98
+ "L7 checkpoint: unrecognized envelope kind/version — running fresh (fail-safe)"
99
+ )
100
+ return None
101
+ payload_str = env.get("payload")
102
+ if not isinstance(payload_str, str):
103
+ return None
104
+ digest = hashlib.sha256(payload_str.encode()).hexdigest()
105
+ if digest != env.get("sha256"):
106
+ _log.warning(
107
+ "L7 checkpoint: payload hash mismatch (corruption) — running fresh (fail-safe)"
108
+ )
109
+ return None
110
+ try:
111
+ fields = json.loads(payload_str)
112
+ except (json.JSONDecodeError, ValueError):
113
+ return None
114
+ if not isinstance(fields, dict) or fields.get("v") != PAYLOAD_VERSION:
115
+ _log.warning("L7 checkpoint: unknown payload version — running fresh (fail-safe)")
116
+ return None
117
+ return fields
ctxmesh/_http.py ADDED
@@ -0,0 +1,229 @@
1
+ """Minimal stdlib HTTP helper shared by the plane clients.
2
+
3
+ The m10.2 clients depend on nothing but the standard library (``urllib``) so the
4
+ SDK stays lean enough to bundle into base-python and runs on the 3.9 target.
5
+ This module centralises the request/response plumbing and — critically — the
6
+ error translation: a non-2xx response or a transport failure becomes an
7
+ :class:`~ctxmesh.errors.EndpointError` carrying the status, never a swallowed
8
+ error (spec: "surface the launcher endpoint's error rather than swallowing").
9
+
10
+ 307/308 redirect handling
11
+ -------------------------
12
+ ``urllib`` does not re-POST a body on a 307 response — it raises
13
+ ``HTTPError 307``. Our MCP endpoint is registered at ``/mcp/`` (trailing slash)
14
+ and FastMCP/Starlette issues a 307 for ``POST /mcp`` (without the slash). The
15
+ :func:`request` helper therefore catches 307/308 explicitly and re-issues the
16
+ **same method, body, and headers** to the ``Location`` URL. Two guards apply:
17
+
18
+ * **Same-origin only** — the redirect target must share scheme, host, and port
19
+ with the original URL. Re-POSTing a body to a different host is a security
20
+ boundary violation; we raise :class:`~ctxmesh.errors.EndpointError` instead.
21
+ * **Bounded** — at most ``_MAX_REDIRECTS`` consecutive 307/308 hops before we
22
+ raise, preventing infinite redirect loops.
23
+
24
+ 301/302/303 are left to urllib's default handling (it converts the method to GET,
25
+ which is correct for those status codes and not expected on the localhost plane).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import urllib.error
32
+ import urllib.parse
33
+ import urllib.request
34
+ from typing import Any, Dict, Iterator, Optional, Tuple
35
+
36
+ from ctxmesh.errors import EndpointError
37
+
38
+ #: Default per-op timeout (seconds). Matches the launcher's own 2s Valkey bound
39
+ #: for the memory path; callers may override for slower endpoints.
40
+ DEFAULT_TIMEOUT = 5.0
41
+
42
+ #: SSE read timeout (seconds, FUNC-7). A long-lived event stream (runs.stream) may idle
43
+ #: between events for a slow model turn or a run parked at requires_action — far longer
44
+ #: than DEFAULT_TIMEOUT. The server heartbeats well within this window, so a live-but-idle
45
+ #: stream never trips it; a genuinely dead connection still errors after it (not never).
46
+ STREAM_READ_TIMEOUT = 120.0
47
+
48
+ #: Maximum number of 307/308 hops before we give up to prevent infinite loops.
49
+ _MAX_REDIRECTS = 3
50
+
51
+
52
+ def _origin(url: str) -> Tuple[str, str, int]:
53
+ """Return (scheme, host, port) for *url*, normalising the default port."""
54
+ parsed = urllib.parse.urlparse(url)
55
+ scheme = parsed.scheme.lower()
56
+ host = parsed.hostname or ""
57
+ # urllib.parse returns None for the default port; normalise to the scheme's.
58
+ if parsed.port is not None:
59
+ port = parsed.port
60
+ elif scheme == "https":
61
+ port = 443
62
+ else:
63
+ port = 80
64
+ return scheme, host, port
65
+
66
+
67
+ class Response:
68
+ """A thin view over an HTTP response the clients care about."""
69
+
70
+ __slots__ = ("status", "headers", "body")
71
+
72
+ def __init__(self, status: int, headers: Dict[str, str], body: bytes):
73
+ self.status = status
74
+ self.headers = headers
75
+ self.body = body
76
+
77
+ def json(self) -> Any:
78
+ """Decode the body as JSON, or raise EndpointError on malformed JSON."""
79
+ if not self.body:
80
+ return None
81
+ try:
82
+ return json.loads(self.body)
83
+ except json.JSONDecodeError as exc:
84
+ raise EndpointError(
85
+ f"endpoint returned malformed JSON: {exc}", status=self.status
86
+ ) from exc
87
+
88
+ def text(self) -> str:
89
+ return self.body.decode("utf-8", errors="replace")
90
+
91
+
92
+ def request(
93
+ method: str,
94
+ url: str,
95
+ *,
96
+ body: Optional[bytes] = None,
97
+ headers: Optional[Dict[str, str]] = None,
98
+ timeout: float = DEFAULT_TIMEOUT,
99
+ expect: Optional[Tuple[int, ...]] = None,
100
+ ) -> Response:
101
+ """Perform an HTTP request and return a :class:`Response`.
102
+
103
+ 307/308 redirects are followed by re-issuing the **same method and body**
104
+ to the ``Location`` URL. Cross-origin redirects and redirect loops (more
105
+ than ``_MAX_REDIRECTS`` hops) are refused with an :class:`EndpointError`.
106
+
107
+ Raises :class:`EndpointError` when:
108
+ * the transport fails (connection refused / timeout) — ``status`` is None; or
109
+ * a 307/308 redirect is cross-origin or exceeds the hop limit; or
110
+ * ``expect`` is given and the response status is not in it — ``status`` is
111
+ the actual code so the caller can distinguish 400 from 502.
112
+ """
113
+ original_origin = _origin(url)
114
+ current_url = url
115
+ hops = 0
116
+
117
+ while True:
118
+ req = urllib.request.Request(current_url, data=body, method=method) # noqa: S310
119
+ for key, value in (headers or {}).items():
120
+ req.add_header(key, value)
121
+
122
+ try:
123
+ with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
124
+ resp_headers = {k.lower(): v for k, v in resp.headers.items()}
125
+ response = Response(resp.status, resp_headers, resp.read())
126
+ except urllib.error.HTTPError as exc:
127
+ if exc.code in (307, 308):
128
+ # urllib raises HTTPError for 307/308 instead of following the
129
+ # redirect. Re-issue the same method and body to the Location.
130
+ location = exc.headers.get("Location") or exc.headers.get("location")
131
+ if not location:
132
+ raise EndpointError(
133
+ f"{method} {current_url} returned {exc.code} with no Location header",
134
+ status=exc.code,
135
+ ) from exc
136
+ # Resolve relative Location against the current URL.
137
+ redirect_url = urllib.parse.urljoin(current_url, location)
138
+ if _origin(redirect_url) != original_origin:
139
+ raise EndpointError(
140
+ f"{method} {current_url} returned {exc.code} to a "
141
+ f"cross-origin location {redirect_url!r}; refusing to "
142
+ f"re-POST body to a different host (security boundary)",
143
+ status=exc.code,
144
+ ) from exc
145
+ hops += 1
146
+ if hops > _MAX_REDIRECTS:
147
+ raise EndpointError(
148
+ f"{method} {url} exceeded {_MAX_REDIRECTS} redirects "
149
+ f"(last location: {redirect_url!r})",
150
+ status=exc.code,
151
+ ) from exc
152
+ current_url = redirect_url
153
+ continue
154
+
155
+ # A non-2xx with a response body (e.g. feedback 400/502). Read the
156
+ # body so the caller can see the endpoint's error message, and
157
+ # surface the status — never swallow it.
158
+ err_body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
159
+ raise EndpointError(
160
+ f"{method} {url} returned {exc.code}: {err_body.strip()}",
161
+ status=exc.code,
162
+ body=err_body,
163
+ ) from exc
164
+ except (urllib.error.URLError, OSError, TimeoutError) as exc:
165
+ # Transport-level failure: no HTTP status. Includes connection refused
166
+ # (endpoint not wired / down) and timeouts.
167
+ raise EndpointError(f"{method} {url} failed: {exc}", status=None) from exc
168
+
169
+ break # 2xx (or unexpected status) — exit the redirect loop
170
+
171
+ if expect is not None and response.status not in expect:
172
+ raise EndpointError(
173
+ f"{method} {url} returned {response.status} "
174
+ f"(expected {', '.join(str(s) for s in expect)}): {response.text().strip()}",
175
+ status=response.status,
176
+ body=response.text(),
177
+ )
178
+ return response
179
+
180
+
181
+ def stream(
182
+ method: str,
183
+ url: str,
184
+ *,
185
+ body: Optional[bytes] = None,
186
+ headers: Optional[Dict[str, str]] = None,
187
+ timeout: float = DEFAULT_TIMEOUT,
188
+ ) -> Iterator[str]:
189
+ """POST and yield the response body LINE BY LINE (for Server-Sent Events).
190
+
191
+ Unlike :func:`request` this does NOT buffer the whole body — it streams, so a token
192
+ stream is delivered as it arrives. No redirect following (streaming endpoints are
193
+ same-origin: the model gateway, the agent's own /invoke). Raises :class:`EndpointError`
194
+ on a non-2xx (with ``status`` + the error body) or a transport failure (``status`` None) —
195
+ never swallows the failure.
196
+ """
197
+ req = urllib.request.Request(url, data=body, method=method) # noqa: S310
198
+ for key, value in (headers or {}).items():
199
+ req.add_header(key, value)
200
+ try:
201
+ resp = urllib.request.urlopen(req, timeout=timeout) # noqa: S310
202
+ except urllib.error.HTTPError as exc:
203
+ err_body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
204
+ raise EndpointError(
205
+ f"{method} {url} returned {exc.code}: {err_body.strip()}",
206
+ status=exc.code,
207
+ body=err_body,
208
+ ) from exc
209
+ except (urllib.error.URLError, OSError, TimeoutError) as exc:
210
+ raise EndpointError(f"{method} {url} failed: {exc}", status=None) from exc
211
+
212
+ # The read loop is INSIDE its own try/except (FUNC-7): urllib's `timeout` is a
213
+ # per-socket-op timeout, so a mid-stream gap between SSE events raised a RAW
214
+ # socket TimeoutError here — outside the connect try/except above — killing any
215
+ # stream with a >timeout idle gap (a slow model turn, or a run parked at
216
+ # requires_action). Translate it (and any transport error) to EndpointError so the
217
+ # caller sees the SDK's typed error, never a bare TimeoutError. SSE callers pass a
218
+ # long read timeout (STREAM_READ_TIMEOUT) and the server heartbeats within it.
219
+ try:
220
+ with resp:
221
+ for raw in resp:
222
+ yield raw.decode("utf-8", errors="replace").rstrip("\r\n")
223
+ except (urllib.error.URLError, OSError, TimeoutError) as exc:
224
+ raise EndpointError(f"{method} {url} stream interrupted: {exc}", status=None) from exc
225
+
226
+
227
+ def json_body(value: Any) -> bytes:
228
+ """Serialise a value as compact JSON bytes (matches the launcher contract)."""
229
+ return json.dumps(value, separators=(",", ":")).encode("utf-8")