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 +71 -0
- runvault/adapters/__init__.py +16 -0
- runvault/adapters/langgraph.py +126 -0
- runvault/auth/__init__.py +0 -0
- runvault/auth/registration.py +63 -0
- runvault/client.py +112 -0
- runvault/context.py +43 -0
- runvault/exceptions/__init__.py +16 -0
- runvault/exceptions/auth.py +17 -0
- runvault/exceptions/base.py +24 -0
- runvault/exceptions/provider.py +24 -0
- runvault/exceptions/proxy.py +30 -0
- runvault/http/__init__.py +0 -0
- runvault/http/backend.py +116 -0
- runvault/http/transport.py +244 -0
- runvault/llm/__init__.py +0 -0
- runvault/llm/anthropic.py +109 -0
- runvault/llm/cohere.py +0 -0
- runvault/llm/google.py +66 -0
- runvault/llm/mistral.py +0 -0
- runvault/llm/openai.py +112 -0
- runvault/py.typed +0 -0
- runvault/runtime/__init__.py +0 -0
- runvault/runtime/agent.py +100 -0
- runvault-0.1.1.dist-info/METADATA +321 -0
- runvault-0.1.1.dist-info/RECORD +28 -0
- runvault-0.1.1.dist-info/WHEEL +4 -0
- runvault-0.1.1.dist-info/licenses/LICENSE +201 -0
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
|
runvault/http/backend.py
ADDED
|
@@ -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
|