runvault 0.1.1__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.
runvault/__init__.py ADDED
@@ -0,0 +1,71 @@
1
+ """RunVault SDK.
2
+
3
+ Core imports are eager. LLM factory functions are loaded lazily on first
4
+ access so importing sdk never pulls in langchain, openai, anthropic, etc.
5
+ unless the caller actually uses them.
6
+
7
+ Usage:
8
+
9
+ from runvault import RunVault, ChatOpenAI, get_rv
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib
15
+
16
+ from runvault.client import RunVault
17
+ from runvault.context import get_rv
18
+ from runvault.exceptions import (
19
+ AuthenticationError,
20
+ BudgetExceededError,
21
+ ConfigurationError,
22
+ ConnectionError,
23
+ LLMProviderError,
24
+ ProxyError,
25
+ RegistrationError,
26
+ RunVaultError,
27
+ TokenExpiredError,
28
+ )
29
+
30
+ __all__ = [
31
+ # Entry point
32
+ "RunVault",
33
+ # Context
34
+ "get_rv",
35
+ # Exceptions
36
+ "RunVaultError",
37
+ "AuthenticationError",
38
+ "BudgetExceededError",
39
+ "ConfigurationError",
40
+ "ConnectionError",
41
+ "LLMProviderError",
42
+ "ProxyError",
43
+ "RegistrationError",
44
+ "TokenExpiredError",
45
+ # LLM factories (lazy)
46
+ "ChatOpenAI",
47
+ "OpenAI",
48
+ "AsyncOpenAI",
49
+ "ChatAnthropic",
50
+ "Anthropic",
51
+ "AsyncAnthropic",
52
+ "ChatGoogleGenerativeAI",
53
+ ]
54
+
55
+ # Maps factory name → module path. Loaded on first attribute access.
56
+ _LAZY: dict[str, str] = {
57
+ "ChatOpenAI": "runvault.llm.openai",
58
+ "OpenAI": "runvault.llm.openai",
59
+ "AsyncOpenAI": "runvault.llm.openai",
60
+ "ChatAnthropic": "runvault.llm.anthropic",
61
+ "Anthropic": "runvault.llm.anthropic",
62
+ "AsyncAnthropic": "runvault.llm.anthropic",
63
+ "ChatGoogleGenerativeAI": "runvault.llm.google",
64
+ }
65
+
66
+
67
+ def __getattr__(name: str):
68
+ if name in _LAZY:
69
+ module = importlib.import_module(_LAZY[name])
70
+ return getattr(module, name)
71
+ raise AttributeError(f"module 'sdk' has no attribute {name!r}")
@@ -0,0 +1,16 @@
1
+ """Framework adapter registry.
2
+
3
+ Maps framework name strings (used in runvault.init(framework=...)) to their
4
+ wrap_* functions. Each wrap function accepts (agent, app) and calls
5
+ agent._bind_adapter() to attach the wrapper.
6
+
7
+ To add a new adapter:
8
+ 1. Create adapters/<framework>.py following langgraph.py as the reference.
9
+ 2. Add an entry here.
10
+ """
11
+
12
+ from runvault.adapters.langgraph import wrap_langgraph
13
+
14
+ ADAPTERS: dict[str, callable] = {
15
+ "langgraph": wrap_langgraph,
16
+ }
@@ -0,0 +1,126 @@
1
+ """RunVault LangGraph adapter.
2
+
3
+ Wraps a compiled LangGraph graph so that the RunVault Agent is set on the
4
+ ContextVar for the duration of each invocation. Nodes inside the graph
5
+ can then call get_rv() without any explicit state or parameter threading.
6
+
7
+ Usage:
8
+
9
+ from runvault import RunVault, ChatOpenAI
10
+
11
+ runvault = RunVault(api_key="rv_live_...", be_url="https://your-runvault-backend")
12
+ llm = ChatOpenAI(model="gpt-4o-mini")
13
+
14
+ agent = runvault.init(
15
+ framework="langgraph",
16
+ app=compiled_graph,
17
+ agent_id="research-v1",
18
+ name="Research Agent",
19
+ )
20
+
21
+ result = agent.invoke({"input": "..."})
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import TYPE_CHECKING, Any
27
+
28
+ from runvault.context import _current_agent
29
+ from runvault.exceptions import RunVaultError
30
+
31
+ if TYPE_CHECKING:
32
+ from runvault.runtime.agent import Agent
33
+
34
+
35
+ def _unwrap_rv_error(exc: BaseException) -> RunVaultError | None:
36
+ """Walk the exception cause chain and return the first RunVaultError found.
37
+
38
+ LLM SDKs (e.g. OpenAI) wrap exceptions raised inside httpx.send into
39
+ their own types (APIConnectionError). This recovers the original
40
+ RunVaultError so callers see a meaningful exception instead of a generic
41
+ connection error.
42
+ """
43
+ current: BaseException | None = exc
44
+ for _ in range(10):
45
+ if isinstance(current, RunVaultError):
46
+ return current
47
+ cause = getattr(current, "__cause__", None) or getattr(current, "__context__", None)
48
+ if cause is None or cause is current:
49
+ break
50
+ current = cause
51
+ return None
52
+
53
+
54
+ class _LangGraphWrapper:
55
+ """Sets the RunVault Agent on the ContextVar for each graph invocation."""
56
+
57
+ def __init__(self, agent: Agent, graph: Any) -> None:
58
+ self._agent = agent
59
+ self._graph = graph
60
+
61
+ def invoke(self, *args: Any, **kwargs: Any) -> Any:
62
+ """Invoke the graph synchronously with RunVault context active."""
63
+ token = _current_agent.set(self._agent)
64
+ try:
65
+ return self._graph.invoke(*args, **kwargs)
66
+ except Exception as exc:
67
+ rv_error = _unwrap_rv_error(exc)
68
+ if rv_error is not None:
69
+ raise rv_error
70
+ raise
71
+ finally:
72
+ _current_agent.reset(token)
73
+
74
+ def stream(self, *args: Any, **kwargs: Any):
75
+ """Stream the graph synchronously with RunVault context active."""
76
+ token = _current_agent.set(self._agent)
77
+ try:
78
+ yield from self._graph.stream(*args, **kwargs)
79
+ except Exception as exc:
80
+ rv_error = _unwrap_rv_error(exc)
81
+ if rv_error is not None:
82
+ raise rv_error
83
+ raise
84
+ finally:
85
+ _current_agent.reset(token)
86
+
87
+ async def ainvoke(self, *args: Any, **kwargs: Any) -> Any:
88
+ """Invoke the graph asynchronously with RunVault context active."""
89
+ token = _current_agent.set(self._agent)
90
+ try:
91
+ return await self._graph.ainvoke(*args, **kwargs)
92
+ except Exception as exc:
93
+ rv_error = _unwrap_rv_error(exc)
94
+ if rv_error is not None:
95
+ raise rv_error
96
+ raise
97
+ finally:
98
+ _current_agent.reset(token)
99
+
100
+ async def astream(self, *args: Any, **kwargs: Any):
101
+ """Stream the graph asynchronously with RunVault context active."""
102
+ token = _current_agent.set(self._agent)
103
+ try:
104
+ async for chunk in self._graph.astream(*args, **kwargs):
105
+ yield chunk
106
+ except Exception as exc:
107
+ rv_error = _unwrap_rv_error(exc)
108
+ if rv_error is not None:
109
+ raise rv_error
110
+ raise
111
+ finally:
112
+ _current_agent.reset(token)
113
+
114
+
115
+ def wrap_langgraph(agent: Agent, app: Any) -> None:
116
+ """Wrap a compiled LangGraph graph and bind it to the Agent.
117
+
118
+ Creates a _LangGraphWrapper and attaches it via agent._bind_adapter()
119
+ so agent.invoke() / agent.stream() / etc. delegate to the wrapper.
120
+
121
+ Args:
122
+ agent: The Agent returned by RunVault.init().
123
+ app: A compiled LangGraph graph (result of StateGraph.compile()).
124
+ """
125
+ wrapper = _LangGraphWrapper(agent=agent, graph=app)
126
+ agent._bind_adapter(wrapper)
File without changes
@@ -0,0 +1,63 @@
1
+ """Agent registration — POST /auth/agents/runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import uuid
7
+ from dataclasses import dataclass
8
+
9
+ log = logging.getLogger(__name__)
10
+
11
+
12
+ @dataclass
13
+ class AgentInfo:
14
+ id: uuid.UUID
15
+ agent_id: str
16
+ name: str
17
+ project_id: uuid.UUID
18
+ run_id: uuid.UUID
19
+ budget: float | None
20
+ budget_alert_threshold: float | None
21
+ llm_provider: str
22
+ proxy_url: str # base proxy URL returned by the backend on registration
23
+ created: bool
24
+
25
+
26
+ def register(
27
+ http,
28
+ api_key: str,
29
+ agent_id: str,
30
+ name: str,
31
+ budget: float | None = None,
32
+ budget_alert_threshold: float | None = None,
33
+ ) -> tuple[AgentInfo, str]:
34
+ """Call POST /auth/agents/runs and return (AgentInfo, jwt_token)."""
35
+ payload: dict = {"agent_id": agent_id, "name": name, "rv_api_key": api_key}
36
+ if budget is not None:
37
+ payload["budget"] = budget
38
+ if budget_alert_threshold is not None:
39
+ payload["budget_alert_threshold"] = budget_alert_threshold
40
+
41
+ data = http.post("/auth/agents/runs", payload)
42
+
43
+ token: str = data["token"]
44
+ info = AgentInfo(
45
+ id=uuid.UUID(data["agent_id"]),
46
+ agent_id=agent_id,
47
+ name=name,
48
+ project_id=uuid.UUID("00000000-0000-0000-0000-000000000000"),
49
+ run_id=uuid.UUID(data["run_id"]),
50
+ budget=budget,
51
+ budget_alert_threshold=budget_alert_threshold,
52
+ llm_provider=data["llm_provider"],
53
+ proxy_url=data["proxy_url"],
54
+ created=data["agent_created"],
55
+ )
56
+
57
+ log.info(
58
+ "RunVault agent %s: agent_id=%s id=%s run_id=%s provider=%s proxy=%s",
59
+ "registered" if info.created else "found existing",
60
+ agent_id, info.id, info.run_id, info.llm_provider, info.proxy_url,
61
+ )
62
+
63
+ return info, token
runvault/client.py ADDED
@@ -0,0 +1,112 @@
1
+ """RunVault SDK entry point.
2
+
3
+ Usage:
4
+
5
+ from runvault import RunVault, ChatOpenAI
6
+
7
+ rv = RunVault(
8
+ api_key="rv_live_...",
9
+ be_url="https://your-runvault-backend",
10
+ )
11
+ llm = ChatOpenAI(model="gpt-4o-mini")
12
+ graph = build_graph(llm)
13
+
14
+ agent = rv.init(framework="langgraph", app=graph,
15
+ agent_id="research-v1", name="Research Agent")
16
+ result = agent.invoke({"input": "..."})
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ from typing import Any
23
+
24
+ from runvault.adapters import ADAPTERS
25
+ from runvault.auth.registration import register
26
+ from runvault.http.backend import BackendClient
27
+ from runvault.runtime.agent import Agent
28
+
29
+ log = logging.getLogger(__name__)
30
+
31
+
32
+ class RunVault:
33
+ """RunVault SDK client.
34
+
35
+ Args:
36
+ api_key: Active RunVault project API key (rv_live_…). Always required.
37
+ be_url: Base URL of the RunVault backend. Always required.
38
+ timeout: HTTP request timeout in seconds for backend calls.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ api_key: str,
44
+ be_url: str,
45
+ timeout: int = 10,
46
+ ) -> None:
47
+ self._api_key = api_key
48
+ self._http = BackendClient(be_url=be_url, timeout=timeout)
49
+
50
+ def _register_agent(
51
+ self,
52
+ agent_id: str,
53
+ name: str,
54
+ budget: float | None = None,
55
+ budget_alert_threshold: float | None = None,
56
+ ) -> Agent:
57
+ """Register an agent and obtain a proxy JWT (internal).
58
+
59
+ Idempotent — the backend returns the existing agent record but always
60
+ issues a fresh run_id and JWT, scoping each execution for spend tracking.
61
+ Called by init(). Not part of the public API in v1.
62
+ """
63
+ info, token = register(
64
+ http=self._http,
65
+ api_key=self._api_key,
66
+ agent_id=agent_id,
67
+ name=name,
68
+ budget=budget,
69
+ budget_alert_threshold=budget_alert_threshold,
70
+ )
71
+ return Agent(info=info, token=token, http=self._http)
72
+
73
+ def init(
74
+ self,
75
+ framework: str,
76
+ app: Any,
77
+ agent_id: str,
78
+ name: str,
79
+ budget: float | None = None,
80
+ budget_alert_threshold: float | None = None,
81
+ ) -> Agent:
82
+ """Register an agent and wrap a framework graph in one call.
83
+
84
+ Args:
85
+ framework: Framework name (e.g. "langgraph").
86
+ app: Compiled graph or runnable to wrap.
87
+ agent_id: Stable identifier for this agent.
88
+ name: Human-readable name.
89
+ budget: Optional spending cap in USD.
90
+ budget_alert_threshold: Optional alert percentage (0–100).
91
+
92
+ Returns:
93
+ Agent ready to invoke.
94
+
95
+ Raises:
96
+ ValueError: If the framework is not supported.
97
+ """
98
+ try:
99
+ wrap = ADAPTERS[framework]
100
+ except KeyError:
101
+ raise ValueError(
102
+ f"Unknown framework {framework!r}. "
103
+ f"Supported: {sorted(ADAPTERS)}"
104
+ )
105
+ agent = self._register_agent(
106
+ agent_id=agent_id,
107
+ name=name,
108
+ budget=budget,
109
+ budget_alert_threshold=budget_alert_threshold,
110
+ )
111
+ wrap(agent, app)
112
+ return agent
runvault/context.py ADDED
@@ -0,0 +1,43 @@
1
+ """RunVault context variable.
2
+
3
+ Provides ambient access to the active Agent from inside framework nodes
4
+ without passing it through graph state.
5
+
6
+ Usage inside a node:
7
+
8
+ from runvault import get_rv
9
+
10
+ def my_node(state):
11
+ agent = get_rv()
12
+ # agent.info.id, agent.token, agent.budget_remaining(), etc.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from contextvars import ContextVar
18
+ from typing import TYPE_CHECKING
19
+
20
+ if TYPE_CHECKING:
21
+ from runvault.runtime.agent import Agent
22
+
23
+ _current_agent: ContextVar[Agent] = ContextVar("_current_agent")
24
+
25
+
26
+ def get_rv() -> Agent:
27
+ """Return the active Agent for the current execution context.
28
+
29
+ Set automatically by the framework adapter wrapper around each
30
+ graph invocation. Isolated per async task and per thread — concurrent
31
+ invocations on the same process never see each other's Agent.
32
+
33
+ Raises:
34
+ RuntimeError: If called outside a graph wrapped via runvault.init().
35
+ """
36
+ try:
37
+ return _current_agent.get()
38
+ except LookupError:
39
+ raise RuntimeError(
40
+ "No active RunVault agent. "
41
+ "Wrap your graph with runvault.init(framework=..., app=...) "
42
+ "before invoking it."
43
+ )
@@ -0,0 +1,16 @@
1
+ from .auth import AuthenticationError, ConfigurationError, ConnectionError, RegistrationError
2
+ from .base import RunVaultError
3
+ from .provider import LLMProviderError
4
+ from .proxy import BudgetExceededError, ProxyError, TokenExpiredError
5
+
6
+ __all__ = [
7
+ "RunVaultError",
8
+ "AuthenticationError",
9
+ "BudgetExceededError",
10
+ "ConfigurationError",
11
+ "ConnectionError",
12
+ "LLMProviderError",
13
+ "ProxyError",
14
+ "RegistrationError",
15
+ "TokenExpiredError",
16
+ ]
@@ -0,0 +1,17 @@
1
+ from .base import RunVaultError
2
+
3
+
4
+ class AuthenticationError(RunVaultError):
5
+ """API key is invalid, revoked, or has no linked LLM vault key."""
6
+
7
+
8
+ class RegistrationError(RunVaultError):
9
+ """Agent registration with the RunVault backend failed."""
10
+
11
+
12
+ class ConnectionError(RunVaultError):
13
+ """RunVault backend could not be reached (network error or timeout)."""
14
+
15
+
16
+ class ConfigurationError(RunVaultError):
17
+ """Required SDK configuration is missing or invalid (e.g. no proxy URL)."""
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class RunVaultError(Exception):
5
+ """Base class for all RunVault SDK exceptions.
6
+
7
+ Attributes:
8
+ status_code: HTTP status code from the backend or proxy, if available.
9
+ error_code: Machine-readable code (e.g. ``BUDGET_CAP_REACHED``).
10
+ user_string: Human-readable message safe to display to end users.
11
+ """
12
+
13
+ def __init__(
14
+ self,
15
+ message: str,
16
+ *,
17
+ status_code: int | None = None,
18
+ error_code: str | None = None,
19
+ user_string: str | None = None,
20
+ ) -> None:
21
+ super().__init__(message)
22
+ self.status_code = status_code
23
+ self.error_code = error_code
24
+ self.user_string = user_string if user_string is not None else message
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from .base import RunVaultError
4
+
5
+
6
+ class LLMProviderError(RunVaultError):
7
+ """The upstream LLM provider returned an error response.
8
+
9
+ This wraps provider HTTP errors so agents can catch all LLM failures
10
+ from a single exception type without importing provider-specific SDKs.
11
+
12
+ Attributes:
13
+ provider: Name of the provider (e.g. ``"openai"``, ``"anthropic"``).
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ message: str,
19
+ *,
20
+ provider: str | None = None,
21
+ **kwargs,
22
+ ) -> None:
23
+ super().__init__(message, **kwargs)
24
+ self.provider = provider
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from .base import RunVaultError
4
+
5
+
6
+ class BudgetExceededError(RunVaultError):
7
+ """Agent's spending budget is exhausted — the proxy denied the request.
8
+
9
+ Attributes:
10
+ remaining_usd: Balance remaining at time of denial (may be 0 or
11
+ slightly negative due to estimation rounding).
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ message: str,
17
+ *,
18
+ remaining_usd: float | None = None,
19
+ **kwargs,
20
+ ) -> None:
21
+ super().__init__(message, **kwargs)
22
+ self.remaining_usd = remaining_usd
23
+
24
+
25
+ class TokenExpiredError(RunVaultError):
26
+ """Proxy JWT has expired — call RunVault.init() again to get a new one."""
27
+
28
+
29
+ class ProxyError(RunVaultError):
30
+ """Generic proxy-level error (e.g. version mismatch, proxy misconfiguration)."""
File without changes
@@ -0,0 +1,116 @@
1
+ """BackendClient — single httpx transport for all SDK→backend REST calls.
2
+
3
+ All modules that need to talk to the RunVault backend (registration,
4
+ budget queries, spending records, payments) use this client. Error
5
+ parsing and typed exception raising are centralised here so callers
6
+ never have to touch raw HTTP.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import httpx
12
+
13
+ from runvault.exceptions import AuthenticationError, ConnectionError, RegistrationError
14
+
15
+
16
+ class BackendClient:
17
+ """Thin httpx wrapper for the RunVault backend.
18
+
19
+ Args:
20
+ be_url: Backend base URL (e.g. http://backend:30080).
21
+ timeout: Request timeout in seconds applied to connect + write.
22
+ Read timeout is left unlimited because some backend
23
+ calls (e.g. Lithic card issuance) may be slow.
24
+ """
25
+
26
+ def __init__(self, be_url: str, timeout: int = 10) -> None:
27
+ self._be_url = be_url.rstrip("/")
28
+ self._client = httpx.Client(
29
+ timeout=httpx.Timeout(connect=float(timeout), read=None,
30
+ write=float(timeout), pool=float(timeout)),
31
+ headers={"User-Agent": "runvault-python/2.0"},
32
+ )
33
+
34
+ def post(self, path: str, body: dict) -> dict:
35
+ """POST to the backend and return the parsed JSON body.
36
+
37
+ Raises a typed RunVaultError on any non-200 response, connection
38
+ failure, or timeout.
39
+ """
40
+ try:
41
+ resp = self._client.post(f"{self._be_url}{path}", json=body)
42
+ except httpx.ConnectError as exc:
43
+ raise ConnectionError(
44
+ f"Cannot reach RunVault backend at {self._be_url}: {exc}",
45
+ user_string="Cannot reach the RunVault backend. Check your network connection.",
46
+ ) from exc
47
+ except httpx.TimeoutException as exc:
48
+ raise ConnectionError(
49
+ f"Request to RunVault backend timed out: {exc}",
50
+ user_string="Request to RunVault backend timed out. Try again in a moment.",
51
+ ) from exc
52
+ return self._handle(resp)
53
+
54
+ def get(self, path: str) -> dict:
55
+ """GET from the backend and return the parsed JSON body."""
56
+ try:
57
+ resp = self._client.get(f"{self._be_url}{path}")
58
+ except httpx.ConnectError as exc:
59
+ raise ConnectionError(
60
+ f"Cannot reach RunVault backend at {self._be_url}: {exc}",
61
+ user_string="Cannot reach the RunVault backend. Check your network connection.",
62
+ ) from exc
63
+ except httpx.TimeoutException as exc:
64
+ raise ConnectionError(
65
+ f"Request to RunVault backend timed out: {exc}",
66
+ user_string="Request to RunVault backend timed out. Try again in a moment.",
67
+ ) from exc
68
+ return self._handle(resp)
69
+
70
+ def close(self) -> None:
71
+ self._client.close()
72
+
73
+ # ------------------------------------------------------------------
74
+ # Internal helpers
75
+ # ------------------------------------------------------------------
76
+
77
+ def _handle(self, resp: httpx.Response) -> dict:
78
+ if resp.status_code == 200:
79
+ return resp.json()
80
+ msg, code, user_str = self._parse_error(resp)
81
+ if resp.status_code == 401:
82
+ raise AuthenticationError(
83
+ msg or "Invalid or expired API key.",
84
+ status_code=401,
85
+ error_code=code or "INVALID_API_KEY",
86
+ user_string=user_str or "Invalid or expired API key.",
87
+ )
88
+ if resp.status_code == 400:
89
+ raise AuthenticationError(
90
+ msg or "Bad request.",
91
+ status_code=400,
92
+ error_code=code,
93
+ user_string=user_str or msg or "Bad request.",
94
+ )
95
+ raise RegistrationError(
96
+ msg or f"Backend error (HTTP {resp.status_code}).",
97
+ status_code=resp.status_code,
98
+ error_code=code,
99
+ user_string=user_str or f"RunVault backend returned an unexpected error.",
100
+ )
101
+
102
+ @staticmethod
103
+ def _parse_error(resp: httpx.Response) -> tuple[str, str | None, str | None]:
104
+ """Extract (internal_message, error_code, user_string) from an error response."""
105
+ try:
106
+ body = resp.json()
107
+ detail = body.get("detail", {})
108
+ if isinstance(detail, dict):
109
+ return (
110
+ detail.get("detail", resp.text),
111
+ detail.get("code"),
112
+ detail.get("user_string", resp.text),
113
+ )
114
+ return str(detail) or resp.text, None, str(detail) or resp.text
115
+ except Exception:
116
+ return resp.text, None, resp.text