runkite-runner 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """Runkite Python Runner SDK - Execute LangGraph agents against the Runkite control plane."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as: python -m runkite_runner --config langgraph.json"""
2
+
3
+ from .worker import main
4
+
5
+ main()
runkite_runner/a2a.py ADDED
@@ -0,0 +1,133 @@
1
+ """Agent-to-Agent (A2A) delegation client: agent calls agent via the same
2
+ Agent Protocol API.
3
+
4
+ `call_agent` is what a running agent's own node code calls to invoke
5
+ another agent as a sub-task -- it POSTs to the control plane's
6
+ `/internal/a2a/runs` endpoint (see internal/api/a2a.go for the full
7
+ server-side design: auth propagation, recursion limits, cost
8
+ attribution via root_run_id).
9
+
10
+ Deliberately takes the graph node's own `config` dict, not separate
11
+ run_id/user parameters -- every value this needs (the current run_id to
12
+ set as parent_run_id, the authenticated user to forward as
13
+ on_behalf_of) is already there, set by `build_run_config` in worker.py
14
+ for every run. A node function just calls
15
+ ``await call_agent(config, "other_agent", {"messages": [...]})``.
16
+
17
+ Operational note: with ``wait=True`` (the default), the parent run's
18
+ worker slot stays occupied until the child finishes. A runner process
19
+ with ``concurrency=1`` therefore deadlocks on nested A2A -- the child
20
+ job cannot be dequeued until the parent frees its slot. Use
21
+ concurrency >= 2 (or ``wait=False`` + poll) for any graph that
22
+ delegates synchronously.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ from typing import Any
29
+
30
+ import httpx
31
+
32
+ from .tls_utils import httpx_tls_kwargs
33
+
34
+
35
+ class A2AError(Exception):
36
+ """Raised when the control plane rejects a delegation call --
37
+ e.g. HTTP 400 for a recursion depth limit exceeded (see
38
+ ErrA2ADepthExceeded in internal/api/server.go), or 404 for an
39
+ unknown parent_run_id."""
40
+
41
+
42
+ async def call_agent(
43
+ config: dict,
44
+ agent_id: str,
45
+ input: dict[str, Any],
46
+ *,
47
+ wait: bool = True,
48
+ thread_id: str | None = None,
49
+ run_config: dict[str, Any] | None = None,
50
+ control_plane_url: str | None = None,
51
+ timeout: float | None = None,
52
+ ) -> dict[str, Any]:
53
+ """Invoke another agent as a sub-task from within a running agent's
54
+ own node code.
55
+
56
+ Args:
57
+ config: the RunnableConfig LangGraph passes to every node --
58
+ must be the same `config` the calling node itself received
59
+ (or its unmodified `configurable` sub-dict), so run_id/user
60
+ can be forwarded correctly.
61
+ agent_id: which agent to delegate to.
62
+ input: the sub-agent's input, same shape as a normal run's input.
63
+ wait: block until the sub-agent's run reaches a terminal status
64
+ and return its result (default). False fires the sub-run
65
+ and returns immediately with just the created run's data --
66
+ the caller is then responsible for polling/streaming it
67
+ itself, same as any other async run.
68
+ thread_id: an existing thread to run on, e.g. to continue a
69
+ specific sub-conversation. Defaults to a fresh thread per
70
+ call -- most delegation calls are one-shot sub-tasks, not
71
+ multi-turn conversations with the sub-agent.
72
+ run_config: passed through as the sub-run's own `config`
73
+ (distinct from this function's own `config` argument, which
74
+ is the CALLER's LangGraph RunnableConfig, not the sub-run's).
75
+ control_plane_url: defaults to RUNKITE_HTTP_URL, same env var
76
+ convention as every other control-plane HTTP call in this
77
+ runner.
78
+ timeout: httpx request timeout in seconds. None (default) means
79
+ no timeout -- a `wait=True` call blocks for however long the
80
+ sub-agent actually takes, which the caller controls via its
81
+ own input, not an arbitrary client-side cutoff.
82
+
83
+ Returns:
84
+ When wait=True: {"run": {...}, "values": {...}} -- the sub-run's
85
+ final state and output, same shape as the client-facing
86
+ `/runs/{id}/wait` response.
87
+ When wait=False: just the created run object, status "pending".
88
+
89
+ Raises:
90
+ A2AError: the control plane rejected the call (e.g. recursion
91
+ depth exceeded, unknown parent_run_id).
92
+ RuntimeError: config has no run_id -- this wasn't called from
93
+ within an actual graph node's execution (or with a config
94
+ that build_run_config never touched).
95
+ """
96
+ configurable = config.get("configurable", {}) if config else {}
97
+ parent_run_id = configurable.get("run_id")
98
+ if not parent_run_id:
99
+ raise RuntimeError(
100
+ "call_agent: config has no configurable.run_id -- must be called "
101
+ "with the RunnableConfig a graph node itself received, not a "
102
+ "hand-built or empty one"
103
+ )
104
+
105
+ body: dict[str, Any] = {
106
+ "agent_id": agent_id,
107
+ "input": input,
108
+ "parent_run_id": parent_run_id,
109
+ "wait": wait,
110
+ }
111
+ if thread_id:
112
+ body["thread_id"] = thread_id
113
+ if run_config:
114
+ body["config"] = run_config
115
+
116
+ user = configurable.get("langgraph_auth_user")
117
+ if user is not None and hasattr(user, "to_dict"):
118
+ on_behalf_of = user.to_dict()
119
+ if on_behalf_of:
120
+ body["on_behalf_of"] = on_behalf_of
121
+
122
+ base_url = control_plane_url or os.environ.get("RUNKITE_HTTP_URL", "http://localhost:2026")
123
+ headers: dict[str, str] = {}
124
+ runner_token = os.environ.get("RUNNER_TOKEN")
125
+ if runner_token:
126
+ headers["X-Runner-Kind"] = "python-langgraph"
127
+ headers["X-Runner-Token"] = runner_token
128
+
129
+ async with httpx.AsyncClient(timeout=timeout, **httpx_tls_kwargs()) as client:
130
+ resp = await client.post(f"{base_url}/internal/a2a/runs", json=body, headers=headers)
131
+ if resp.status_code >= 400:
132
+ raise A2AError(f"call_agent({agent_id!r}) failed: {resp.status_code} {resp.text}")
133
+ return resp.json()
@@ -0,0 +1,214 @@
1
+ """Checkpoint dual mode.
2
+
3
+ Direct mode (default when POSTGRES_DSN is set -- production, shared DB with
4
+ the control plane): the runner holds its own connection and writes
5
+ checkpoints with LangGraph's native AsyncPostgresSaver. Zero added latency,
6
+ survives runner restarts. Correct only when the control plane also uses
7
+ POSTGRES_DSN against the same database; MySQL/Mongo/SQLite control planes
8
+ must unset POSTGRES_DSN on the runner (see README Checkpoint dual mode).
9
+
10
+ Local mode (no POSTGRES_DSN -- zero-dependency dev default): falls back to
11
+ LangGraph's in-memory MemorySaver. This is honestly ephemeral -- state does
12
+ NOT survive a runner restart. That's an accepted trade-off for the
13
+ zero-dependency default (same spirit as the control plane's own in-process
14
+ transport), not a hidden gap. Proxy mode (opaque-blob checkpoints via the
15
+ control plane's HTTP API, for non-Python runners or runners without DB
16
+ credentials) is not implemented by this Python runner -- it always has
17
+ direct DB access when Postgres is available, so proxy mode has no benefit
18
+ here; it exists in the protocol for other-language runners.
19
+
20
+ Connection pooling (runner-side concurrency): a runner process can now
21
+ process multiple jobs at once (see worker.py's --concurrency), so `start`
22
+ takes a `pool_size` and builds an `AsyncConnectionPool` instead of the
23
+ single connection `AsyncPostgresSaver.from_conn_string` opens -- otherwise
24
+ every concurrent job's checkpoint I/O would serialize on that one
25
+ connection's internal lock (correct, but not actually parallel).
26
+ `AsyncPostgresSaver.__init__`'s `conn` parameter accepts a pool directly
27
+ (`Conn = AsyncConnection | AsyncConnectionPool` in langgraph's own
28
+ `checkpoint/postgres/_ainternal.py`) -- confirmed it checks out a
29
+ connection per operation via that same module's `get_connection` helper,
30
+ so this is a supported usage, not a hack.
31
+
32
+ Concurrent-startup migration race (found live): `AsyncPostgresSaver.setup()` runs `CREATE TABLE IF NOT EXISTS` DDL
33
+ for its own `checkpoint_migrations` table, which is not actually race-free
34
+ on a truly fresh database -- the same class of bug this project's own
35
+ `internal/state/postgres/postgres.go` had and fixed with a session advisory
36
+ lock. When 2+ runner replicas start simultaneously against a fresh
37
+ Postgres, one `setup()` call can lose that race and crash with
38
+ `duplicate key value violates unique constraint "pg_type_typname_nsp_index"`.
39
+
40
+ Fixed here with an advisory lock too, but `pg_try_advisory_lock` polled in
41
+ a loop, NOT a blocking `pg_advisory_lock` -- tried the blocking version
42
+ first and hit a real deadlock, not just a slower path: `setup()` also runs
43
+ `CREATE INDEX CONCURRENTLY`, which must wait for every *other* backend's
44
+ in-flight statement in the whole database to finish before it can proceed
45
+ (a Postgres-wide barrier, unrelated to which table the other statement
46
+ touches). A losing replica blocked inside a single `SELECT
47
+ pg_advisory_lock(...)` call counts as an in-flight statement -- so the
48
+ winner's `CREATE INDEX CONCURRENTLY` waits on the losers, and the losers
49
+ wait on the winner to finish `setup()` and unlock. Circular wait,
50
+ confirmed live via `pg_stat_activity`/`pg_locks` (losers stuck on
51
+ `Lock/advisory`, winner stuck on `Lock/virtualxid` waiting on them).
52
+ Polling `pg_try_advisory_lock` avoids this because each poll is its own
53
+ complete, instantly-committed statement -- a losing replica is never
54
+ mid-statement between polls, so it never blocks the winner's
55
+ `CREATE INDEX CONCURRENTLY`.
56
+ """
57
+
58
+ import asyncio
59
+ import logging
60
+
61
+ logger = logging.getLogger("runkite.runner")
62
+
63
+ # Distinct from the Go control plane's own schema-init advisory lock key
64
+ # (894127001, internal/state/postgres/postgres.go) -- unrelated schemas
65
+ # (runner checkpoint tables vs. control-plane state tables), no reason for
66
+ # one to block the other.
67
+ _CHECKPOINT_SETUP_ADVISORY_LOCK_KEY = 894127002
68
+ _LOCK_POLL_INTERVAL_S = 0.2
69
+ _LOCK_POLL_TIMEOUT_S = 60.0
70
+
71
+
72
+ class CheckpointerManager:
73
+ """Owns the runner's single shared checkpointer for its whole lifetime.
74
+
75
+ One checkpointer instance is created at worker startup and attached to
76
+ every loaded graph (overriding whatever checkpointer, if any, the
77
+ graph module itself compiled with) -- so checkpoint mode is a runner
78
+ concern, not something agent authors need to configure in their own
79
+ graph.py. Agent authors get this for free: zero changes to graph code.
80
+ """
81
+
82
+ def __init__(self):
83
+ self._checkpointer = None
84
+ self._pool = None # AsyncConnectionPool, kept open for the runner's lifetime (postgres mode only)
85
+ self._dsn: str | None = None
86
+ self._pool_size: int = 4
87
+ self._attached: list = [] # graphs whose .checkpointer we own
88
+ self.mode = "none"
89
+
90
+ async def start(self, postgres_dsn: str | None, pool_size: int = 4):
91
+ if postgres_dsn:
92
+ import psycopg
93
+
94
+ self._dsn = postgres_dsn
95
+ self._pool_size = pool_size
96
+ await self._open_pool()
97
+
98
+ # Serialize setup() across concurrently-starting runner replicas so
99
+ # its CREATE TABLE IF NOT EXISTS / CREATE INDEX CONCURRENTLY DDL
100
+ # can't race on a fresh DB (see module docstring). Deliberately a
101
+ # standalone connection, NOT checked out from self._pool: setup()
102
+ # itself checks out a connection from that same pool internally,
103
+ # and with --concurrency 1 (pool max_size=1) holding the lock from
104
+ # inside the pool would starve setup()'s own checkout of the only
105
+ # connection available.
106
+ async with await psycopg.AsyncConnection.connect(postgres_dsn, autocommit=True) as lock_conn:
107
+ waited = 0.0
108
+ while True:
109
+ row = await (
110
+ await lock_conn.execute(
111
+ "SELECT pg_try_advisory_lock(%s)", (_CHECKPOINT_SETUP_ADVISORY_LOCK_KEY,)
112
+ )
113
+ ).fetchone()
114
+ if row and row[0]:
115
+ break
116
+ if waited >= _LOCK_POLL_TIMEOUT_S:
117
+ raise TimeoutError(
118
+ "timed out waiting for checkpoint setup() advisory lock "
119
+ f"(held by another runner replica for over {_LOCK_POLL_TIMEOUT_S}s)"
120
+ )
121
+ await asyncio.sleep(_LOCK_POLL_INTERVAL_S)
122
+ waited += _LOCK_POLL_INTERVAL_S
123
+ try:
124
+ await self._checkpointer.setup()
125
+ finally:
126
+ await lock_conn.execute("SELECT pg_advisory_unlock(%s)", (_CHECKPOINT_SETUP_ADVISORY_LOCK_KEY,))
127
+ self.mode = "direct-postgres"
128
+ logger.info(
129
+ "checkpoint mode: direct (postgres, pool_size=%s) -- LangGraph tables on "
130
+ "POSTGRES_DSN; requires the control plane to use the same Postgres database "
131
+ "(Supported profile). If the control plane is MySQL/Mongo/SQLite, unset "
132
+ "POSTGRES_DSN on this runner and set RUNKITE_HTTP_URL for store proxy mode.",
133
+ pool_size,
134
+ )
135
+ else:
136
+ from langgraph.checkpoint.memory import MemorySaver
137
+
138
+ self._checkpointer = MemorySaver()
139
+ self.mode = "memory"
140
+ logger.warning(
141
+ "checkpoint mode: in-memory (no POSTGRES_DSN set) -- "
142
+ "thread state will NOT survive a runner restart. "
143
+ "Set POSTGRES_DSN for production persistence."
144
+ )
145
+
146
+ async def _open_pool(self) -> None:
147
+ from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
148
+ from psycopg.rows import dict_row
149
+
150
+ from . import pg_pool
151
+
152
+ # Same connection kwargs from_conn_string used
153
+ # (autocommit/prepare_threshold/row_factory) -- AsyncPostgresSaver
154
+ # expects dict-row results, not psycopg's tuple default.
155
+ self._pool = pg_pool.make(
156
+ self._dsn,
157
+ max_size=self._pool_size,
158
+ conn_kwargs={"prepare_threshold": 0, "row_factory": dict_row},
159
+ )
160
+ await self._pool.open()
161
+ self._checkpointer = AsyncPostgresSaver(conn=self._pool)
162
+ for graph in self._attached:
163
+ graph.checkpointer = self._checkpointer
164
+
165
+ async def recreate_pool(self) -> None:
166
+ """Drop a wedged pool (idle overnight / laptop sleep) and open a new one.
167
+
168
+ Rebinds every previously attach()'d graph so LangGraph does not keep
169
+ calling through the closed pool.
170
+ """
171
+ if self._dsn is None:
172
+ return
173
+ old = self._pool
174
+ self._pool = None
175
+ if old is not None:
176
+ try:
177
+ await old.close()
178
+ except Exception:
179
+ logger.exception("error closing wedged checkpoint pool")
180
+ await self._open_pool()
181
+
182
+ async def recover_if_wedged(self) -> None:
183
+ """Cheap pre-job probe: if getconn hangs/fails, recreate once.
184
+
185
+ AsyncPostgresSaver owns the pool reference internally, so unlike
186
+ store.abatch we cannot catch PoolTimeout inside each checkpoint
187
+ op -- probe here before astream so an overnight-wedged pool does
188
+ not burn a full run on a 30s timeout mid-graph.
189
+ """
190
+ if self._pool is None:
191
+ return
192
+ from psycopg_pool import PoolTimeout
193
+
194
+ try:
195
+ async with self._pool.connection(timeout=5.0) as conn:
196
+ await conn.execute("SELECT 1")
197
+ except PoolTimeout:
198
+ logger.warning("checkpoint pool timed out on health probe; recreating pool")
199
+ await self.recreate_pool()
200
+ except Exception:
201
+ logger.warning("checkpoint pool health probe failed; recreating pool", exc_info=True)
202
+ await self.recreate_pool()
203
+
204
+ async def stop(self):
205
+ if self._pool is not None:
206
+ await self._pool.close()
207
+ self._pool = None
208
+
209
+ def attach(self, graph):
210
+ """Override a compiled graph's checkpointer with the shared one."""
211
+ if graph not in self._attached:
212
+ self._attached.append(graph)
213
+ graph.checkpointer = self._checkpointer
214
+ return graph
@@ -0,0 +1,62 @@
1
+ """In-runner mode for the Custom routes platform extension.
2
+
3
+ Loads a user-defined ASGI app (FastAPI, Starlette, or any other ASGI
4
+ framework) from langgraph.json's "custom_app" section and serves it via
5
+ uvicorn, running as a concurrent asyncio task alongside the runner's own
6
+ gRPC worker loop (see run_worker in worker.py) -- same process, same event
7
+ loop, similar to dropping a file into your project.
8
+
9
+ The control plane reverse-proxies /custom/* to wherever this ends up
10
+ listening (see internal/config.CustomRoutesEntry / cmd/serve.go's
11
+ initCustomRoutesProxy) -- from the control plane's side, in-runner mode and
12
+ a separately-run sidecar are the exact same mechanism, just different
13
+ processes hosting the target URL.
14
+
15
+ WSGI frameworks (e.g. Flask) aren't directly supported -- uvicorn only
16
+ serves ASGI. Wrap a WSGI app with an adapter (e.g. a2wsgi.WSGIMiddleware)
17
+ if that's the framework of choice; this loader doesn't care what kind of
18
+ object it gets back as long as it's ASGI-callable.
19
+ """
20
+
21
+ import importlib.util
22
+ import logging
23
+ import sys
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ logger = logging.getLogger("runkite.runner")
28
+
29
+
30
+ def load_asgi_app(config_dir: Path, module_ref: str) -> Any:
31
+ """Loads an ASGI app object from a "path/to/module.py:app_symbol" ref --
32
+ the same "path:symbol" convention langgraph.json's "graphs" section
33
+ already uses for agent graphs."""
34
+ file_path, export_name = module_ref.split(":", 1)
35
+ abs_path = (config_dir / file_path).resolve()
36
+
37
+ spec = importlib.util.spec_from_file_location("runkite_custom_app", str(abs_path))
38
+ if spec is None or spec.loader is None:
39
+ raise ValueError(f"Cannot load custom app module: {abs_path}")
40
+
41
+ module = importlib.util.module_from_spec(spec)
42
+ sys.modules[spec.name] = module
43
+ spec.loader.exec_module(module)
44
+
45
+ return getattr(module, export_name)
46
+
47
+
48
+ async def serve_custom_app(app: Any, host: str, port: int) -> None:
49
+ """Runs an ASGI app via uvicorn until cancelled. Meant to run as a
50
+ concurrent asyncio task (asyncio.create_task) alongside the gRPC
51
+ worker's own poll loop -- both share the process and event loop, so a
52
+ slow/blocking route handler in the user's app can, in principle, delay
53
+ the worker's own async work. That's an inherent trade-off of "in-runner,
54
+ same process" mode; use sidecar mode instead for routes that need
55
+ independent scaling or isolation.
56
+ """
57
+ import uvicorn
58
+
59
+ config = uvicorn.Config(app, host=host, port=port, log_level="info", access_log=True)
60
+ server = uvicorn.Server(config)
61
+ logger.info(f"Custom routes (in-runner mode): serving on http://{host}:{port}")
62
+ await server.serve()
@@ -0,0 +1,82 @@
1
+ """Portable identity helpers for custom-route ASGI apps.
2
+
3
+ The control plane authenticates the caller, then injects X-Runkite-*
4
+ headers on the reverse-proxied request (see internal/customroutes).
5
+ Custom apps SHOULD trust these headers rather than re-parsing JWT /
6
+ API keys — and MUST NOT trust them on any path that bypasses the
7
+ control plane.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from collections.abc import Mapping
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ HEADER_IDENTITY = "x-runkite-identity"
18
+ HEADER_TENANT_ID = "x-runkite-tenant-id"
19
+ HEADER_PERMISSIONS = "x-runkite-permissions"
20
+ HEADER_DISPLAY_NAME = "x-runkite-display-name"
21
+ HEADER_USER_JSON = "x-runkite-user"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class CustomUser:
26
+ """Identity forwarded by the control plane into a custom route."""
27
+
28
+ identity: str
29
+ tenant_id: str = "default"
30
+ permissions: tuple[str, ...] = ()
31
+ display_name: str = ""
32
+ extra: dict[str, Any] = field(default_factory=dict)
33
+
34
+ @property
35
+ def is_authenticated(self) -> bool:
36
+ return bool(self.identity)
37
+
38
+
39
+ def _header(headers: Mapping[str, str], name: str) -> str:
40
+ # ASGI / Starlette headers are lower-case; also accept canonical form.
41
+ if hasattr(headers, "get"):
42
+ v = headers.get(name) or headers.get(name.title()) or headers.get(name.upper())
43
+ if v is not None:
44
+ return v if isinstance(v, str) else str(v)
45
+ return ""
46
+
47
+
48
+ def user_from_headers(headers: Mapping[str, str]) -> CustomUser | None:
49
+ """Build a CustomUser from X-Runkite-* headers. Returns None if absent."""
50
+ raw = _header(headers, HEADER_USER_JSON)
51
+ if raw:
52
+ try:
53
+ data = json.loads(raw)
54
+ if isinstance(data, dict) and data.get("identity"):
55
+ perms = data.get("permissions") or []
56
+ if isinstance(perms, str):
57
+ perms = [p for p in perms.split(",") if p]
58
+ return CustomUser(
59
+ identity=str(data["identity"]),
60
+ tenant_id=str(data.get("tenant_id") or "default"),
61
+ permissions=tuple(str(p) for p in perms),
62
+ display_name=str(data.get("display_name") or ""),
63
+ )
64
+ except json.JSONDecodeError:
65
+ pass
66
+
67
+ identity = _header(headers, HEADER_IDENTITY)
68
+ if not identity:
69
+ return None
70
+ perms_raw = _header(headers, HEADER_PERMISSIONS)
71
+ perms = tuple(p for p in (perms_raw.split(",") if perms_raw else []) if p)
72
+ return CustomUser(
73
+ identity=identity,
74
+ tenant_id=_header(headers, HEADER_TENANT_ID) or "default",
75
+ permissions=perms,
76
+ display_name=_header(headers, HEADER_DISPLAY_NAME),
77
+ )
78
+
79
+
80
+ def user_from_request(request: Any) -> CustomUser | None:
81
+ """Starlette/FastAPI convenience: user_from_headers(request.headers)."""
82
+ return user_from_headers(request.headers)
@@ -0,0 +1,76 @@
1
+ """Thin HTTP helpers for custom-route handlers talking back to the CP.
2
+
3
+ Custom apps are not in-process with the graph runtime. Use these to call
4
+ public Agent Protocol endpoints (store, threads, runs) with the caller's
5
+ Bearer token forwarded from the inbound request.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+ from urllib.parse import quote
12
+
13
+ import httpx
14
+
15
+
16
+ class ControlPlaneClient:
17
+ """Minimal sync client for store/run/thread lookups from custom routes."""
18
+
19
+ def __init__(self, base_url: str, authorization: str | None = None, timeout: float = 30.0):
20
+ self.base_url = base_url.rstrip("/")
21
+ self._headers: dict[str, str] = {}
22
+ if authorization:
23
+ self._headers["Authorization"] = authorization
24
+ self._timeout = timeout
25
+
26
+ @classmethod
27
+ def from_request(cls, request: Any, base_url: str | None = None) -> ControlPlaneClient:
28
+ """Build a client that reuses the caller's Authorization header.
29
+
30
+ base_url defaults to the Host the custom app was reached through
31
+ (works when the app is reverse-proxied by the same CP). For
32
+ sidecar mode, pass the control plane URL explicitly.
33
+ """
34
+ import os
35
+
36
+ auth = None
37
+ if hasattr(request, "headers"):
38
+ auth = request.headers.get("authorization") or request.headers.get("Authorization")
39
+ url = base_url or os.environ.get("RUNKITE_HTTP_URL") or os.environ.get("RUNKITE_URL")
40
+ if not url and hasattr(request, "base_url"):
41
+ # Starlette: request.base_url is the public origin as seen by
42
+ # the app (often the CP origin when StripPrefix-proxied).
43
+ url = str(request.base_url).rstrip("/")
44
+ if not url:
45
+ raise ValueError("control plane base_url required (pass base_url= or set RUNKITE_HTTP_URL)")
46
+ return cls(url, authorization=auth)
47
+
48
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
49
+ with httpx.Client(base_url=self.base_url, headers=self._headers, timeout=self._timeout) as client:
50
+ resp = client.request(method, path, **kwargs)
51
+ resp.raise_for_status()
52
+ if resp.status_code == 204 or not resp.content:
53
+ return None
54
+ return resp.json()
55
+
56
+ def get_thread(self, thread_id: str) -> dict:
57
+ return self._request("GET", f"/threads/{quote(thread_id)}")
58
+
59
+ def get_run(self, thread_id: str, run_id: str) -> dict:
60
+ return self._request("GET", f"/threads/{quote(thread_id)}/runs/{quote(run_id)}")
61
+
62
+ def store_get(self, namespace: list[str], key: str) -> dict | None:
63
+ ns = ",".join(namespace)
64
+ try:
65
+ return self._request("GET", "/store/items", params={"namespace": ns, "key": key})
66
+ except httpx.HTTPStatusError as e:
67
+ if e.response.status_code == 404:
68
+ return None
69
+ raise
70
+
71
+ def store_put(self, namespace: list[str], key: str, value: dict) -> dict:
72
+ return self._request(
73
+ "PUT",
74
+ "/store/items",
75
+ json={"namespace": namespace, "key": key, "value": value},
76
+ )