bylaw-python 0.4.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.
bylaw_python/config.py ADDED
@@ -0,0 +1,95 @@
1
+ # Ledgix ALCV — Configuration
2
+ # Environment-driven configuration via pydantic-settings
3
+
4
+ from __future__ import annotations
5
+
6
+ from pydantic_settings import BaseSettings, SettingsConfigDict
7
+
8
+
9
+ class VaultConfig(BaseSettings):
10
+ """Configuration for connecting to the ALCV Vault.
11
+
12
+ Values are loaded from environment variables prefixed with ``LEDGIX_``,
13
+ e.g. ``LEDGIX_VAULT_URL``, or can be passed directly to the constructor.
14
+ """
15
+
16
+ model_config = SettingsConfigDict(
17
+ env_prefix="LEDGIX_",
18
+ env_file=".env",
19
+ env_file_encoding="utf-8",
20
+ extra="ignore",
21
+ )
22
+
23
+ vault_url: str = "http://localhost:8000"
24
+ """Base URL of the ALCV Vault server."""
25
+
26
+ vault_api_key: str = ""
27
+ """API key sent as ``X-Vault-API-Key`` header for Shim→Vault auth."""
28
+
29
+ vault_timeout: float = 30.0
30
+ """HTTP request timeout in seconds."""
31
+
32
+ verify_jwt: bool = True
33
+ """Whether to verify A-JWTs returned by the Vault using its JWKS endpoint."""
34
+
35
+ jwt_issuer: str = "alcv-vault"
36
+ """Expected issuer for Vault A-JWTs."""
37
+
38
+ jwt_audience: str = "ledgix-sdk"
39
+ """Expected audience for Vault A-JWTs."""
40
+
41
+ agent_id: str = "default-agent"
42
+ """Identifier for the agent using this SDK instance."""
43
+
44
+ session_id: str = ""
45
+ """Optional session identifier for grouping related clearance requests."""
46
+
47
+ review_poll_interval: float = 2.0
48
+ """Polling interval in seconds while waiting for manual review."""
49
+
50
+ review_timeout: float = 900.0
51
+ """Maximum wait time in seconds for a pending manual review decision."""
52
+
53
+ review_mode: str = "block"
54
+ """How to handle pending manual reviews. ``"block"`` (default): poll until
55
+ a decision arrives or timeout expires. ``"detach"``: return a
56
+ :class:`~bylaw_python.PendingApproval` immediately so the caller can
57
+ resume later via :meth:`~bylaw_python.PendingApproval.wait_async`."""
58
+
59
+ max_retries: int = 3
60
+ """Number of retry attempts for transient failures (connection errors, 5xx responses)."""
61
+
62
+ retry_base_delay: float = 0.5
63
+ """Base delay in seconds for exponential backoff between retries (full jitter applied)."""
64
+
65
+ decision_cache_enabled: bool = False
66
+ """Enable the in-process decision cache. Off by default — opt-in for safety.
67
+ When enabled, approved decisions are memoized; subsequent identical tool
68
+ calls skip the LLM judge and call /mint-token for a fresh A-JWT instead.
69
+ """
70
+
71
+ decision_cache_ttl_seconds: float = 60.0
72
+ """TTL (seconds) for cached decision envelopes."""
73
+
74
+ decision_cache_max_entries: int = 1000
75
+ """Maximum number of decision envelopes to keep in memory."""
76
+
77
+ principal_id: str | None = None
78
+ """Advisory OIDC ``sub`` of the human on whose behalf the agent acts.
79
+ Sent as ``human_principal`` in every clearance request. Can be overridden
80
+ per-call via ``on_behalf_of`` argument. Env: ``LEDGIX_PRINCIPAL_ID``."""
81
+
82
+ jwks_ttl_seconds: int = 300
83
+ """How long (seconds) the cached JWKS is considered fresh before a key-miss
84
+ triggers a refetch. Default 5 minutes matches the Vault's rotation cadence.
85
+ Env: ``LEDGIX_JWKS_TTL_SECONDS``."""
86
+
87
+ replay_cache_size: int = 10_000
88
+ """Maximum number of consumed A-JWT jtis held in the in-process replay
89
+ cache. When the limit is reached the oldest entries are evicted (LRU-TTL).
90
+ Env: ``LEDGIX_REPLAY_CACHE_SIZE``."""
91
+
92
+ max_token_lifetime_seconds: float = 330.0
93
+ """TTL for entries in the replay cache (seconds). Should be at least
94
+ ``VAULT_JWT_TTL + 30`` to cover clock skew. Default 330 = 5 min TTL + 30s.
95
+ Env: ``LEDGIX_MAX_TOKEN_LIFETIME_SECONDS``."""
@@ -0,0 +1,145 @@
1
+ # Ledgix ALCV — Client-side counterparty hints
2
+ #
3
+ # Mirrors vault/internal/counterparty: best-effort extraction of the
4
+ # destination provider/URI/account from a tool name + tool_args dict.
5
+ # The Vault re-runs its own extractor chain, so this is a hint to
6
+ # pre-populate the wire fields when the SDK has unambiguous signal.
7
+ #
8
+ # Caller-supplied destination_* always wins on both sides of the wire.
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+ from urllib.parse import urlparse
14
+
15
+
16
+ _PROVIDER_HOST_PREFIXES = ("www.", "api.", "api-")
17
+
18
+
19
+ def _provider_from_host(host: str) -> str:
20
+ """Strip common gateway prefixes so regional subdomains group together.
21
+ Does not attempt eTLD+1 parsing — provider taxonomy is curated upstream."""
22
+ host = host.lower().split(":", 1)[0]
23
+ for prefix in _PROVIDER_HOST_PREFIXES:
24
+ if host.startswith(prefix):
25
+ return host[len(prefix):]
26
+ return host
27
+
28
+
29
+ def _string_arg(tool_args: dict[str, Any], key: str) -> str:
30
+ value = tool_args.get(key)
31
+ return value if isinstance(value, str) else ""
32
+
33
+
34
+ def _stripe(tool_name: str, tool_args: dict[str, Any]) -> dict[str, str] | None:
35
+ if "stripe" not in tool_name:
36
+ return None
37
+ out: dict[str, str] = {
38
+ "destination_uri": "https://api.stripe.com",
39
+ "destination_provider": "stripe",
40
+ }
41
+ api_key = _string_arg(tool_args, "api_key")
42
+ if api_key.startswith("sk_") and len(api_key) >= 12:
43
+ out["destination_account_ref"] = api_key[:12]
44
+ elif api_key.startswith("sk_"):
45
+ out["destination_account_ref"] = api_key
46
+ account = _string_arg(tool_args, "account")
47
+ if account:
48
+ out["destination_account_ref"] = account
49
+ return out
50
+
51
+
52
+ def _twilio(tool_name: str, tool_args: dict[str, Any]) -> dict[str, str] | None:
53
+ if "twilio" not in tool_name:
54
+ return None
55
+ out: dict[str, str] = {
56
+ "destination_uri": "https://api.twilio.com",
57
+ "destination_provider": "twilio",
58
+ }
59
+ sid = _string_arg(tool_args, "account_sid")
60
+ if sid:
61
+ out["destination_account_ref"] = sid
62
+ return out
63
+
64
+
65
+ def _slack(tool_name: str, tool_args: dict[str, Any]) -> dict[str, str] | None:
66
+ if "slack" not in tool_name:
67
+ return None
68
+ out: dict[str, str] = {
69
+ "destination_uri": "https://slack.com/api",
70
+ "destination_provider": "slack",
71
+ }
72
+ team = _string_arg(tool_args, "team_id") or _string_arg(tool_args, "workspace")
73
+ if team:
74
+ out["destination_account_ref"] = team
75
+ return out
76
+
77
+
78
+ def _bedrock(tool_name: str, tool_args: dict[str, Any]) -> dict[str, str] | None:
79
+ if "bedrock" not in tool_name:
80
+ return None
81
+ out: dict[str, str] = {
82
+ "destination_uri": "https://bedrock-runtime.amazonaws.com",
83
+ "destination_provider": "aws-bedrock",
84
+ }
85
+ model_id = _string_arg(tool_args, "model_id") or _string_arg(tool_args, "model")
86
+ if model_id:
87
+ out["destination_account_ref"] = model_id
88
+ return out
89
+
90
+
91
+ def _openai(tool_name: str, tool_args: dict[str, Any]) -> dict[str, str] | None:
92
+ if "openai" not in tool_name and "gpt" not in tool_name:
93
+ return None
94
+ out: dict[str, str] = {
95
+ "destination_uri": "https://api.openai.com",
96
+ "destination_provider": "openai",
97
+ }
98
+ org = _string_arg(tool_args, "organization") or _string_arg(tool_args, "org_id")
99
+ if org:
100
+ out["destination_account_ref"] = org
101
+ return out
102
+
103
+
104
+ def _anthropic(tool_name: str, tool_args: dict[str, Any]) -> dict[str, str] | None:
105
+ if "anthropic" not in tool_name and "claude" not in tool_name:
106
+ return None
107
+ out: dict[str, str] = {
108
+ "destination_uri": "https://api.anthropic.com",
109
+ "destination_provider": "anthropic",
110
+ }
111
+ org = _string_arg(tool_args, "organization")
112
+ if org:
113
+ out["destination_account_ref"] = org
114
+ return out
115
+
116
+
117
+ def _generic_http(tool_name: str, tool_args: dict[str, Any]) -> dict[str, str] | None:
118
+ for key in ("url", "endpoint", "uri", "host"):
119
+ raw = _string_arg(tool_args, key)
120
+ if not raw:
121
+ continue
122
+ parsed = urlparse(raw)
123
+ if not parsed.netloc:
124
+ continue
125
+ return {
126
+ "destination_uri": raw,
127
+ "destination_provider": _provider_from_host(parsed.netloc),
128
+ }
129
+ return None
130
+
131
+
132
+ _EXTRACTORS = (_stripe, _twilio, _slack, _bedrock, _openai, _anthropic, _generic_http)
133
+
134
+
135
+ def extract(tool_name: str, tool_args: dict[str, Any] | None) -> dict[str, str]:
136
+ """Return any inferred destination_* fields. Empty dict on no match."""
137
+ if not tool_name:
138
+ return {}
139
+ name_lower = tool_name.lower()
140
+ args = tool_args or {}
141
+ for ex in _EXTRACTORS:
142
+ result = ex(name_lower, args)
143
+ if result:
144
+ return result
145
+ return {}