banto 5.2.2__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.
- banto/__init__.py +33 -0
- banto/__main__.py +560 -0
- banto/backend.py +45 -0
- banto/broker.py +314 -0
- banto/broker_cli.py +40 -0
- banto/broker_client.py +69 -0
- banto/broker_mcp.py +54 -0
- banto/broker_setup.py +121 -0
- banto/chatgpt.py +186 -0
- banto/config.json +43 -0
- banto/ecdsa_p256.py +127 -0
- banto/guard.py +887 -0
- banto/keychain.py +260 -0
- banto/kimaru_evaluation.py +366 -0
- banto/kimaru_material.py +278 -0
- banto/lease.py +292 -0
- banto/lease_cli.py +150 -0
- banto/mcp_server.py +764 -0
- banto/pricing.json +75 -0
- banto/profiles.py +147 -0
- banto/register_popup.py +1383 -0
- banto/rotation_capabilities.json +361 -0
- banto/sealed_cli.py +70 -0
- banto/sealed_signer.py +468 -0
- banto/sync/__init__.py +13 -0
- banto/sync/audit.py +46 -0
- banto/sync/browser_batch.py +320 -0
- banto/sync/browser_issuer.py +831 -0
- banto/sync/browser_recorder.py +472 -0
- banto/sync/capabilities.py +173 -0
- banto/sync/cli.py +4076 -0
- banto/sync/cloudflare_token_rotator.py +397 -0
- banto/sync/config.py +247 -0
- banto/sync/drivers/__init__.py +97 -0
- banto/sync/drivers/alibaba.py +114 -0
- banto/sync/drivers/aws_sm.py +113 -0
- banto/sync/drivers/aws_ssm.py +96 -0
- banto/sync/drivers/azure.py +81 -0
- banto/sync/drivers/azure_devops.py +100 -0
- banto/sync/drivers/base.py +29 -0
- banto/sync/drivers/bitbucket.py +132 -0
- banto/sync/drivers/circleci.py +99 -0
- banto/sync/drivers/cloudflare.py +61 -0
- banto/sync/drivers/deno.py +73 -0
- banto/sync/drivers/digitalocean.py +75 -0
- banto/sync/drivers/docker.py +66 -0
- banto/sync/drivers/flyio.py +66 -0
- banto/sync/drivers/gcp.py +81 -0
- banto/sync/drivers/github.py +57 -0
- banto/sync/drivers/gitlab.py +84 -0
- banto/sync/drivers/hasura.py +127 -0
- banto/sync/drivers/heroku.py +61 -0
- banto/sync/drivers/huawei.py +109 -0
- banto/sync/drivers/jdcloud.py +95 -0
- banto/sync/drivers/kubernetes.py +105 -0
- banto/sync/drivers/laravel_forge.py +141 -0
- banto/sync/drivers/local.py +130 -0
- banto/sync/drivers/naver.py +106 -0
- banto/sync/drivers/netlify.py +68 -0
- banto/sync/drivers/nhn.py +122 -0
- banto/sync/drivers/railway.py +72 -0
- banto/sync/drivers/render.py +172 -0
- banto/sync/drivers/sakura.py +117 -0
- banto/sync/drivers/supabase.py +76 -0
- banto/sync/drivers/tencent.py +109 -0
- banto/sync/drivers/terraform.py +137 -0
- banto/sync/drivers/vercel.py +164 -0
- banto/sync/drivers/volcengine.py +97 -0
- banto/sync/google_rotator.py +637 -0
- banto/sync/history.py +208 -0
- banto/sync/incident_report.py +159 -0
- banto/sync/notifiers/__init__.py +34 -0
- banto/sync/notifiers/base.py +43 -0
- banto/sync/notifiers/datadog.py +66 -0
- banto/sync/notifiers/pagerduty.py +73 -0
- banto/sync/notifiers/slack.py +57 -0
- banto/sync/notifiers/teams.py +59 -0
- banto/sync/openai_rotator.py +526 -0
- banto/sync/propagation.py +269 -0
- banto/sync/setup.py +239 -0
- banto/sync/smoke_presets.py +153 -0
- banto/sync/stripe_webhook_rotator.py +516 -0
- banto/sync/sync.py +277 -0
- banto/sync/sync_state.py +116 -0
- banto/sync/validate.py +337 -0
- banto/sync/vercel_inventory.py +185 -0
- banto/sync/web.py +1881 -0
- banto/sync/x_oauth_pkce.py +294 -0
- banto/sync/x_oauth_rotator.py +284 -0
- banto/sync/xai_rotator.py +542 -0
- banto/vault.py +364 -0
- banto/widgets.py +566 -0
- banto-5.2.2.dist-info/METADATA +713 -0
- banto-5.2.2.dist-info/RECORD +98 -0
- banto-5.2.2.dist-info/WHEEL +5 -0
- banto-5.2.2.dist-info/entry_points.txt +3 -0
- banto-5.2.2.dist-info/licenses/LICENSE +93 -0
- banto-5.2.2.dist-info/top_level.txt +1 -0
banto/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Copyright 2025-2026 AllNew LLC
|
|
2
|
+
# Licensed under LicenseRef-Dual (see LICENSE)
|
|
3
|
+
"""
|
|
4
|
+
banto - Local-first secret management for developers.
|
|
5
|
+
|
|
6
|
+
Named after the bantō (番頭), the head clerk of Edo-period merchant houses
|
|
7
|
+
who held the keys to the storehouse and managed the books.
|
|
8
|
+
|
|
9
|
+
Core: secure key storage via macOS Keychain with multi-platform sync.
|
|
10
|
+
Optional modules:
|
|
11
|
+
- budget: LLM cost gating with hold/settle pattern
|
|
12
|
+
- lease: dynamic secrets with TTL and auto-revocation
|
|
13
|
+
|
|
14
|
+
Default backend: macOS Keychain. Custom backends (1Password, env vars, etc.)
|
|
15
|
+
can be plugged in via the SecretBackend protocol.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .backend import SecretBackend
|
|
19
|
+
from .guard import CostGuard, BudgetExceededError
|
|
20
|
+
from .keychain import KeychainStore, KeyNotFoundError
|
|
21
|
+
from .profiles import ProfileManager
|
|
22
|
+
from .vault import SecureVault
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"SecureVault",
|
|
26
|
+
"SecretBackend",
|
|
27
|
+
"CostGuard",
|
|
28
|
+
"BudgetExceededError",
|
|
29
|
+
"KeychainStore",
|
|
30
|
+
"KeyNotFoundError",
|
|
31
|
+
"ProfileManager",
|
|
32
|
+
]
|
|
33
|
+
__version__ = "5.2.2"
|
banto/__main__.py
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
# Copyright 2025-2026 AllNew LLC
|
|
2
|
+
# Licensed under LicenseRef-Dual (see LICENSE)
|
|
3
|
+
"""
|
|
4
|
+
CLI entry point: python -m banto <command>
|
|
5
|
+
|
|
6
|
+
Commands:
|
|
7
|
+
status Show budget status
|
|
8
|
+
store <provider> Store an API key in Keychain
|
|
9
|
+
register [provider] Open browser popup to store an API key
|
|
10
|
+
register-asc Open App Store Connect credential registration popup
|
|
11
|
+
list List stored provider keys
|
|
12
|
+
check <model> ... Dry-run budget check for a model
|
|
13
|
+
init Copy default config to ~/.config/banto/
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import getpass
|
|
17
|
+
import json
|
|
18
|
+
import shutil
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .guard import CostGuard, CONFIG_DIR
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _is_json(args: list[str]) -> bool:
|
|
26
|
+
"""Check if --json flag is present."""
|
|
27
|
+
return "--json" in args
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _json_out(data: dict | list) -> None:
|
|
31
|
+
"""Print JSON to stdout and exit."""
|
|
32
|
+
print(json.dumps(data, indent=2, ensure_ascii=False, default=str))
|
|
33
|
+
from .keychain import KeychainStore, _validate_provider
|
|
34
|
+
from .sync.config import SyncConfig
|
|
35
|
+
from .vault import SecureVault
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _validate_cli_bounds(
|
|
39
|
+
*,
|
|
40
|
+
tokens: int | None = None,
|
|
41
|
+
seconds: int | None = None,
|
|
42
|
+
n: int | None = None,
|
|
43
|
+
budget: float | None = None,
|
|
44
|
+
label: str = "",
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Validate CLI numeric arguments are within reasonable bounds."""
|
|
47
|
+
if tokens is not None and not (0 <= tokens <= 100_000_000):
|
|
48
|
+
print(
|
|
49
|
+
f"Error: {label}tokens must be between 0 and 100,000,000, got {tokens}",
|
|
50
|
+
file=sys.stderr,
|
|
51
|
+
)
|
|
52
|
+
sys.exit(1)
|
|
53
|
+
if seconds is not None and not (0 <= seconds <= 86400):
|
|
54
|
+
print(
|
|
55
|
+
f"Error: seconds must be between 0 and 86,400, got {seconds}",
|
|
56
|
+
file=sys.stderr,
|
|
57
|
+
)
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
if n is not None and not (1 <= n <= 10_000):
|
|
60
|
+
print(
|
|
61
|
+
f"Error: n must be between 1 and 10,000, got {n}",
|
|
62
|
+
file=sys.stderr,
|
|
63
|
+
)
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
if budget is not None and not (0 <= budget <= 1_000_000):
|
|
66
|
+
print(
|
|
67
|
+
f"Error: budget amount must be between 0 and 1,000,000, got {budget}",
|
|
68
|
+
file=sys.stderr,
|
|
69
|
+
)
|
|
70
|
+
sys.exit(1)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_PROFILE_DESCRIPTIONS = {
|
|
74
|
+
"quality": "Use premium models for best results",
|
|
75
|
+
"balanced": "Mix of quality and cost efficiency",
|
|
76
|
+
"budget": "Prioritize cost savings",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cmd_status(args: list[str]) -> None:
|
|
81
|
+
vault = SecureVault(caller="cli")
|
|
82
|
+
if not vault.budget_enabled:
|
|
83
|
+
if _is_json(args):
|
|
84
|
+
_json_out({"budget_enabled": False})
|
|
85
|
+
return
|
|
86
|
+
print("Budget: disabled (no monthly_limit_usd in config)")
|
|
87
|
+
print("\nEnable with: banto budget 100")
|
|
88
|
+
return
|
|
89
|
+
guard = vault._guard
|
|
90
|
+
assert guard is not None
|
|
91
|
+
if _is_json(args):
|
|
92
|
+
s = guard.get_remaining_budget()
|
|
93
|
+
s["recommended_profile"] = guard.recommend_profile()
|
|
94
|
+
_json_out(s)
|
|
95
|
+
return
|
|
96
|
+
s = guard.get_remaining_budget()
|
|
97
|
+
print(f"Month: {s['month']}")
|
|
98
|
+
print(f"Used: ${s['used_usd']:.2f}")
|
|
99
|
+
print(f"Remaining: ${s['remaining_usd']:.2f}")
|
|
100
|
+
print(f"Limit: ${s['monthly_limit_usd']:.2f}")
|
|
101
|
+
print(f"Entries: {s['entry_count']}")
|
|
102
|
+
|
|
103
|
+
# Voided timeout holds
|
|
104
|
+
voided_count = s.get("voided_timeout_count", 0)
|
|
105
|
+
if voided_count > 0:
|
|
106
|
+
voided_usd = s.get("voided_timeout_usd", 0)
|
|
107
|
+
print(f"Stale holds voided: {voided_count} (${voided_usd:.2f} released)")
|
|
108
|
+
|
|
109
|
+
# Recommended profile
|
|
110
|
+
profile = guard.recommend_profile()
|
|
111
|
+
limit = s["monthly_limit_usd"]
|
|
112
|
+
if limit > 0:
|
|
113
|
+
remaining_pct = int((limit - s["used_usd"]) / limit * 100)
|
|
114
|
+
print(f"\nRecommended profile: {profile} "
|
|
115
|
+
f"({remaining_pct}% budget remaining)")
|
|
116
|
+
else:
|
|
117
|
+
print(f"\nRecommended profile: {profile} (no limit set)")
|
|
118
|
+
print(f" -> {_PROFILE_DESCRIPTIONS[profile]}")
|
|
119
|
+
|
|
120
|
+
# Provider breakdown
|
|
121
|
+
by_provider = s.get("by_provider", {})
|
|
122
|
+
if by_provider:
|
|
123
|
+
print("\nBy provider:")
|
|
124
|
+
for p, info in sorted(by_provider.items()):
|
|
125
|
+
used = info["used_usd"]
|
|
126
|
+
limit = info["limit_usd"]
|
|
127
|
+
if limit is not None:
|
|
128
|
+
print(f" {p:20s} ${used:.2f} / ${limit:.2f} (${info['remaining_usd']:.2f} remaining)")
|
|
129
|
+
else:
|
|
130
|
+
print(f" {p:20s} ${used:.2f}")
|
|
131
|
+
|
|
132
|
+
# Model breakdown
|
|
133
|
+
by_model = s.get("by_model", {})
|
|
134
|
+
if by_model:
|
|
135
|
+
print("\nBy model:")
|
|
136
|
+
for m, info in sorted(by_model.items()):
|
|
137
|
+
used = info["used_usd"]
|
|
138
|
+
limit = info["limit_usd"]
|
|
139
|
+
if limit is not None:
|
|
140
|
+
print(f" {m:35s} ${used:.2f} / ${limit:.2f} (${info['remaining_usd']:.2f} remaining)")
|
|
141
|
+
else:
|
|
142
|
+
print(f" {m:35s} ${used:.2f}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def cmd_store(args: list[str]) -> None:
|
|
146
|
+
if not args:
|
|
147
|
+
print("Usage: banto store <provider>")
|
|
148
|
+
print("Example: banto store openai")
|
|
149
|
+
sys.exit(1)
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
provider = _validate_provider(args[0])
|
|
153
|
+
except ValueError as e:
|
|
154
|
+
print(str(e), file=sys.stderr)
|
|
155
|
+
sys.exit(1)
|
|
156
|
+
config = SyncConfig.load()
|
|
157
|
+
keychain = KeychainStore(service_prefix=config.keychain_service)
|
|
158
|
+
|
|
159
|
+
if keychain.exists(provider):
|
|
160
|
+
overwrite = input(
|
|
161
|
+
f"Key for '{provider}' already exists. Overwrite? (y/N): "
|
|
162
|
+
)
|
|
163
|
+
if overwrite.strip().lower() != "y":
|
|
164
|
+
print("Cancelled.")
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
api_key = getpass.getpass(f"Enter API key for '{provider}': ")
|
|
168
|
+
if not api_key:
|
|
169
|
+
print("Empty input. Cancelled.")
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
if keychain.store(provider, api_key):
|
|
173
|
+
print(f"Stored '{provider}' in Keychain.")
|
|
174
|
+
else:
|
|
175
|
+
print(f"Failed to store '{provider}'.", file=sys.stderr)
|
|
176
|
+
sys.exit(1)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def cmd_delete(args: list[str]) -> None:
|
|
180
|
+
if not args:
|
|
181
|
+
print("Usage: banto delete <provider>")
|
|
182
|
+
sys.exit(1)
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
provider = _validate_provider(args[0])
|
|
186
|
+
except ValueError as e:
|
|
187
|
+
print(str(e), file=sys.stderr)
|
|
188
|
+
sys.exit(1)
|
|
189
|
+
config = SyncConfig.load()
|
|
190
|
+
keychain = KeychainStore(service_prefix=config.keychain_service)
|
|
191
|
+
|
|
192
|
+
if not keychain.exists(provider):
|
|
193
|
+
print(f"No key found for '{provider}'.")
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
confirm = input(f"Delete key for '{provider}'? (y/N): ")
|
|
197
|
+
if confirm.strip().lower() != "y":
|
|
198
|
+
print("Cancelled.")
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
if keychain.delete(provider):
|
|
202
|
+
print(f"Deleted '{provider}' from Keychain.")
|
|
203
|
+
else:
|
|
204
|
+
print(f"Failed to delete '{provider}'.", file=sys.stderr)
|
|
205
|
+
sys.exit(1)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def cmd_list(args: list[str]) -> None:
|
|
209
|
+
vault = SecureVault(caller="cli")
|
|
210
|
+
providers = vault.list_providers()
|
|
211
|
+
budget = vault.get_budget_status()
|
|
212
|
+
|
|
213
|
+
if _is_json(args):
|
|
214
|
+
_json_out({"providers": providers, "budget": budget})
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
if budget.get("budget_enabled", True):
|
|
218
|
+
print(f"\nBudget: ${budget['used_usd']:.2f} / ${budget['monthly_limit_usd']:.2f} "
|
|
219
|
+
f"(${budget['remaining_usd']:.2f} remaining)\n")
|
|
220
|
+
else:
|
|
221
|
+
print("\nBudget: disabled\n")
|
|
222
|
+
|
|
223
|
+
if providers:
|
|
224
|
+
print("Stored keys:")
|
|
225
|
+
for p in providers:
|
|
226
|
+
print(f" + {p}")
|
|
227
|
+
else:
|
|
228
|
+
print("No keys stored. Use 'banto store <provider>' to add one.")
|
|
229
|
+
print()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def cmd_check(args: list[str]) -> None:
|
|
233
|
+
if not args:
|
|
234
|
+
print("Usage: banto check <model> [--tokens <in> <out>] [--n <count>]")
|
|
235
|
+
sys.exit(1)
|
|
236
|
+
|
|
237
|
+
model = args[0]
|
|
238
|
+
kwargs: dict = {}
|
|
239
|
+
|
|
240
|
+
i = 1
|
|
241
|
+
while i < len(args):
|
|
242
|
+
if args[i] == "--tokens" and i + 2 < len(args):
|
|
243
|
+
in_tok = int(args[i + 1])
|
|
244
|
+
out_tok = int(args[i + 2])
|
|
245
|
+
_validate_cli_bounds(tokens=in_tok, label="input_")
|
|
246
|
+
_validate_cli_bounds(tokens=out_tok, label="output_")
|
|
247
|
+
kwargs["input_tokens"] = in_tok
|
|
248
|
+
kwargs["output_tokens"] = out_tok
|
|
249
|
+
i += 3
|
|
250
|
+
elif args[i] == "--n" and i + 1 < len(args):
|
|
251
|
+
n_val = int(args[i + 1])
|
|
252
|
+
_validate_cli_bounds(n=n_val)
|
|
253
|
+
kwargs["n"] = n_val
|
|
254
|
+
i += 2
|
|
255
|
+
elif args[i] == "--seconds" and i + 1 < len(args):
|
|
256
|
+
sec_val = int(args[i + 1])
|
|
257
|
+
_validate_cli_bounds(seconds=sec_val)
|
|
258
|
+
kwargs["seconds"] = sec_val
|
|
259
|
+
i += 2
|
|
260
|
+
elif args[i] == "--quality" and i + 1 < len(args):
|
|
261
|
+
kwargs["quality"] = args[i + 1]
|
|
262
|
+
i += 2
|
|
263
|
+
elif args[i] == "--size" and i + 1 < len(args):
|
|
264
|
+
kwargs["size"] = args[i + 1]
|
|
265
|
+
i += 2
|
|
266
|
+
elif args[i] == "--json":
|
|
267
|
+
i += 1
|
|
268
|
+
else:
|
|
269
|
+
print(f"Unknown option: {args[i]}", file=sys.stderr)
|
|
270
|
+
sys.exit(1)
|
|
271
|
+
|
|
272
|
+
guard = CostGuard(caller="cli")
|
|
273
|
+
cost = guard.estimate_cost(model=model, **kwargs)
|
|
274
|
+
budget = guard.get_remaining_budget()
|
|
275
|
+
allowed = cost <= budget["remaining_usd"]
|
|
276
|
+
|
|
277
|
+
if _is_json(args):
|
|
278
|
+
_json_out({"model": model, "estimated_usd": cost,
|
|
279
|
+
"remaining_usd": budget["remaining_usd"], "allowed": allowed})
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
print(f"Model: {model}")
|
|
283
|
+
print(f"Estimated: ${cost:.4f}")
|
|
284
|
+
print(f"Remaining: ${budget['remaining_usd']:.2f}")
|
|
285
|
+
if cost <= budget["remaining_usd"]:
|
|
286
|
+
print("Result: ALLOWED")
|
|
287
|
+
else:
|
|
288
|
+
print("Result: BLOCKED (over budget)")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def cmd_profile(args: list[str]) -> None:
|
|
292
|
+
"""Show or set the active model profile."""
|
|
293
|
+
vault = SecureVault(caller="cli")
|
|
294
|
+
profiles = vault.get_profiles()
|
|
295
|
+
|
|
296
|
+
if _is_json(args):
|
|
297
|
+
_json_out(profiles)
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
if not args or (len(args) == 1 and args[0] == "--json"):
|
|
301
|
+
# Show all profiles
|
|
302
|
+
print("Model profiles:\n")
|
|
303
|
+
for name, info in profiles.items():
|
|
304
|
+
marker = " *" if info["active"] else " "
|
|
305
|
+
desc = _PROFILE_DESCRIPTIONS.get(name, "")
|
|
306
|
+
print(f" {marker} {name:12s} {desc}")
|
|
307
|
+
for role, model in info["models"].items():
|
|
308
|
+
print(f" {role:8s} -> {model}")
|
|
309
|
+
print()
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
profile_name = args[0]
|
|
313
|
+
try:
|
|
314
|
+
vault.set_profile(profile_name)
|
|
315
|
+
print(f"Active profile set to '{profile_name}'.")
|
|
316
|
+
desc = _PROFILE_DESCRIPTIONS.get(profile_name, "")
|
|
317
|
+
if desc:
|
|
318
|
+
print(f" -> {desc}")
|
|
319
|
+
except ValueError as e:
|
|
320
|
+
print(str(e), file=sys.stderr)
|
|
321
|
+
sys.exit(1)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def cmd_budget(args: list[str]) -> None:
|
|
325
|
+
guard = CostGuard(caller="cli")
|
|
326
|
+
|
|
327
|
+
if not args:
|
|
328
|
+
# Show current budget settings
|
|
329
|
+
s = guard.get_remaining_budget()
|
|
330
|
+
print(f"Global: ${s['monthly_limit_usd']:.2f}")
|
|
331
|
+
if s["provider_limits"]:
|
|
332
|
+
print("\nProvider limits:")
|
|
333
|
+
for p, limit in sorted(s["provider_limits"].items()):
|
|
334
|
+
print(f" {p:20s} ${limit:.2f}")
|
|
335
|
+
if s["model_limits"]:
|
|
336
|
+
print("\nModel limits:")
|
|
337
|
+
for m, limit in sorted(s["model_limits"].items()):
|
|
338
|
+
print(f" {m:35s} ${limit:.2f}")
|
|
339
|
+
if not s["provider_limits"] and not s["model_limits"]:
|
|
340
|
+
print("\nNo provider or model limits set.")
|
|
341
|
+
print("Use 'banto budget --provider openai 30' or 'banto budget --model dall-e-3 10'")
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
# Parse: banto budget <amount>
|
|
345
|
+
# banto budget --provider <name> <amount>
|
|
346
|
+
# banto budget --model <name> <amount>
|
|
347
|
+
# banto budget --provider <name> --remove
|
|
348
|
+
# banto budget --model <name> --remove
|
|
349
|
+
i = 0
|
|
350
|
+
global_limit: float | None = None
|
|
351
|
+
provider: str | None = None
|
|
352
|
+
provider_limit: float | None = None
|
|
353
|
+
model: str | None = None
|
|
354
|
+
model_limit: float | None = None
|
|
355
|
+
remove = False
|
|
356
|
+
|
|
357
|
+
while i < len(args):
|
|
358
|
+
if args[i] == "--provider" and i + 1 < len(args):
|
|
359
|
+
provider = args[i + 1]
|
|
360
|
+
i += 2
|
|
361
|
+
elif args[i] == "--model" and i + 1 < len(args):
|
|
362
|
+
model = args[i + 1]
|
|
363
|
+
i += 2
|
|
364
|
+
elif args[i] == "--remove":
|
|
365
|
+
remove = True
|
|
366
|
+
i += 1
|
|
367
|
+
else:
|
|
368
|
+
try:
|
|
369
|
+
amount = float(args[i])
|
|
370
|
+
_validate_cli_bounds(budget=amount)
|
|
371
|
+
if provider is not None:
|
|
372
|
+
provider_limit = amount
|
|
373
|
+
elif model is not None:
|
|
374
|
+
model_limit = amount
|
|
375
|
+
else:
|
|
376
|
+
global_limit = amount
|
|
377
|
+
i += 1
|
|
378
|
+
except ValueError:
|
|
379
|
+
print(f"Invalid argument: {args[i]}", file=sys.stderr)
|
|
380
|
+
sys.exit(1)
|
|
381
|
+
|
|
382
|
+
if remove:
|
|
383
|
+
if provider:
|
|
384
|
+
provider_limit = 0.0 # 0 = remove
|
|
385
|
+
if model:
|
|
386
|
+
model_limit = 0.0
|
|
387
|
+
|
|
388
|
+
if global_limit is None and provider is None and model is None:
|
|
389
|
+
print("Usage: banto budget <amount>", file=sys.stderr)
|
|
390
|
+
print(" banto budget --provider <name> <amount>", file=sys.stderr)
|
|
391
|
+
print(" banto budget --model <name> <amount>", file=sys.stderr)
|
|
392
|
+
print(" banto budget --provider <name> --remove", file=sys.stderr)
|
|
393
|
+
sys.exit(1)
|
|
394
|
+
|
|
395
|
+
guard.set_budget(
|
|
396
|
+
global_limit=global_limit,
|
|
397
|
+
provider=provider,
|
|
398
|
+
provider_limit=provider_limit,
|
|
399
|
+
model=model,
|
|
400
|
+
model_limit=model_limit,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
if global_limit is not None:
|
|
404
|
+
print(f"Global limit set to ${global_limit:.2f}")
|
|
405
|
+
if provider and remove:
|
|
406
|
+
print(f"Provider limit for '{provider}' removed.")
|
|
407
|
+
elif provider and provider_limit:
|
|
408
|
+
print(f"Provider limit for '{provider}' set to ${provider_limit:.2f}")
|
|
409
|
+
if model and remove:
|
|
410
|
+
print(f"Model limit for '{model}' removed.")
|
|
411
|
+
elif model and model_limit:
|
|
412
|
+
print(f"Model limit for '{model}' set to ${model_limit:.2f}")
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def cmd_init(args: list[str]) -> None:
|
|
416
|
+
config_dest = CONFIG_DIR / "config.json"
|
|
417
|
+
pricing_dest = CONFIG_DIR / "pricing.json"
|
|
418
|
+
|
|
419
|
+
if config_dest.exists():
|
|
420
|
+
print(f"Config already exists: {config_dest}")
|
|
421
|
+
overwrite = input("Overwrite with default? (y/N): ")
|
|
422
|
+
if overwrite.strip().lower() != "y":
|
|
423
|
+
print("Cancelled.")
|
|
424
|
+
return
|
|
425
|
+
|
|
426
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
427
|
+
|
|
428
|
+
bundled = Path(__file__).parent
|
|
429
|
+
shutil.copy2(bundled / "config.json", config_dest)
|
|
430
|
+
config_dest.chmod(0o640)
|
|
431
|
+
|
|
432
|
+
shutil.copy2(bundled / "pricing.json", pricing_dest)
|
|
433
|
+
pricing_dest.chmod(0o640)
|
|
434
|
+
|
|
435
|
+
print(f"Config written to: {config_dest}")
|
|
436
|
+
print(f"Pricing written to: {pricing_dest}")
|
|
437
|
+
print()
|
|
438
|
+
print("Next steps:")
|
|
439
|
+
print(" 1. Set your budget: banto budget <amount>")
|
|
440
|
+
print(" 2. Review pricing: edit pricing.json to match current provider rates")
|
|
441
|
+
print(" 3. Store API keys: banto store <provider>")
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def cmd_register(args: list[str]) -> None:
|
|
445
|
+
from .register_popup import serve_register_popup
|
|
446
|
+
|
|
447
|
+
provider_hint = args[0] if args else None
|
|
448
|
+
print("Opening browser for API key registration...")
|
|
449
|
+
url = serve_register_popup(provider_hint=provider_hint, blocking=True)
|
|
450
|
+
print(f"Server was at: {url}")
|
|
451
|
+
print("Done.")
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def cmd_register_asc(args: list[str]) -> None:
|
|
455
|
+
from .register_popup import serve_asc_register_popup
|
|
456
|
+
|
|
457
|
+
print("Opening browser for App Store Connect credential registration...")
|
|
458
|
+
url = serve_asc_register_popup(blocking=True)
|
|
459
|
+
print(f"Server was at: {url}")
|
|
460
|
+
print("Done.")
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def cmd_sync(args: list[str]) -> None:
|
|
464
|
+
from .sync.cli import cmd_sync_dispatch
|
|
465
|
+
cmd_sync_dispatch(args)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def cmd_lease(args: list[str]) -> None:
|
|
469
|
+
from .lease_cli import cmd_lease_dispatch
|
|
470
|
+
cmd_lease_dispatch(args)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def cmd_sealed(args: list[str]) -> None:
|
|
474
|
+
from .sealed_cli import cmd_sealed_dispatch
|
|
475
|
+
cmd_sealed_dispatch(args)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def cmd_chatgpt(args: list[str]) -> None:
|
|
479
|
+
if not args or args[0] in ("-h", "--help"):
|
|
480
|
+
print("banto chatgpt: Connect banto to ChatGPT\n")
|
|
481
|
+
print("Usage: banto chatgpt connect [--ngrok|--cloudflared] [--port N]")
|
|
482
|
+
return
|
|
483
|
+
if args[0] == "connect":
|
|
484
|
+
from .chatgpt import connect
|
|
485
|
+
connect(args[1:])
|
|
486
|
+
else:
|
|
487
|
+
print(f"Unknown chatgpt command: {args[0]}")
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
COMMANDS = {
|
|
491
|
+
"status": cmd_status,
|
|
492
|
+
"budget": cmd_budget,
|
|
493
|
+
"profile": cmd_profile,
|
|
494
|
+
"store": cmd_store,
|
|
495
|
+
"register": cmd_register,
|
|
496
|
+
"register-asc": cmd_register_asc,
|
|
497
|
+
"asc-register": cmd_register_asc,
|
|
498
|
+
"delete": cmd_delete,
|
|
499
|
+
"list": cmd_list,
|
|
500
|
+
"check": cmd_check,
|
|
501
|
+
"init": cmd_init,
|
|
502
|
+
"sync": cmd_sync,
|
|
503
|
+
"lease": cmd_lease,
|
|
504
|
+
"sealed": cmd_sealed,
|
|
505
|
+
"chatgpt": cmd_chatgpt,
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def main() -> None:
|
|
510
|
+
if len(sys.argv) > 1 and sys.argv[1] == "broker":
|
|
511
|
+
from .broker import main as broker_main
|
|
512
|
+
sys.argv.pop(1)
|
|
513
|
+
broker_main()
|
|
514
|
+
return
|
|
515
|
+
if len(sys.argv) > 1:
|
|
516
|
+
from .broker_cli import route_if_enabled
|
|
517
|
+
if route_if_enabled(sys.argv[1:]):
|
|
518
|
+
return
|
|
519
|
+
if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
|
|
520
|
+
print("banto: Budget-gated API key vault for LLM applications\n")
|
|
521
|
+
print("Usage: banto <command> [args]\n")
|
|
522
|
+
print("Commands:")
|
|
523
|
+
print(" broker <cmd> Common Keychain process: install, health, enable, call")
|
|
524
|
+
print(" status Show budget status (with per-provider/model breakdown)")
|
|
525
|
+
print(" budget [args] View or set budget limits")
|
|
526
|
+
print(" profile [name] Show or set the active model profile")
|
|
527
|
+
print(" store <provider> Store an API key in Keychain (terminal)")
|
|
528
|
+
print(" register [provider] Open browser popup to store an API key")
|
|
529
|
+
print(" delete <provider> Delete an API key from Keychain")
|
|
530
|
+
print(" list List stored keys and budget")
|
|
531
|
+
print(" check <model> ... Dry-run budget check")
|
|
532
|
+
print(" init Copy default config to ~/.config/banto/")
|
|
533
|
+
print(" sync <cmd> Multi-platform secret sync (33 platforms)")
|
|
534
|
+
print(" lease <cmd> Dynamic secrets with TTL and auto-revoke")
|
|
535
|
+
print(" sealed <cmd> Secure Enclave sealed signing (non-extractable keys)")
|
|
536
|
+
print(" chatgpt connect Connect banto to ChatGPT (starts tunnel)")
|
|
537
|
+
print()
|
|
538
|
+
print("Budget examples:")
|
|
539
|
+
print(" banto budget Show all limits")
|
|
540
|
+
print(" banto budget 100 Set global limit to $100")
|
|
541
|
+
print(" banto budget --provider openai 30 Set OpenAI limit to $30")
|
|
542
|
+
print(" banto budget --model dall-e-3 10 Set DALL-E 3 limit to $10")
|
|
543
|
+
print(" banto budget --provider openai --remove Remove provider limit")
|
|
544
|
+
print()
|
|
545
|
+
print("Profile examples:")
|
|
546
|
+
print(" banto profile Show all profiles")
|
|
547
|
+
print(" banto profile balanced Set active profile")
|
|
548
|
+
sys.exit(0)
|
|
549
|
+
|
|
550
|
+
cmd = sys.argv[1]
|
|
551
|
+
if cmd not in COMMANDS:
|
|
552
|
+
print(f"Unknown command: {cmd}", file=sys.stderr)
|
|
553
|
+
print(f"Available: {', '.join(COMMANDS)}", file=sys.stderr)
|
|
554
|
+
sys.exit(1)
|
|
555
|
+
|
|
556
|
+
COMMANDS[cmd](sys.argv[2:])
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
if __name__ == "__main__":
|
|
560
|
+
main()
|
banto/backend.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Copyright 2025-2026 AllNew LLC
|
|
2
|
+
# Licensed under LicenseRef-Dual (see LICENSE)
|
|
3
|
+
"""
|
|
4
|
+
backend.py - Pluggable secret storage protocol.
|
|
5
|
+
|
|
6
|
+
Any object that implements these five methods can be used as a
|
|
7
|
+
secret backend for SecureVault. No inheritance required (structural typing).
|
|
8
|
+
|
|
9
|
+
Built-in backend:
|
|
10
|
+
KeychainStore (macOS Keychain, default)
|
|
11
|
+
|
|
12
|
+
See examples/06_custom_backend.py for 1Password, env-var, and
|
|
13
|
+
in-memory implementations.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from typing import Protocol, runtime_checkable
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@runtime_checkable
|
|
20
|
+
class SecretBackend(Protocol):
|
|
21
|
+
"""Protocol for secret storage backends.
|
|
22
|
+
|
|
23
|
+
Implement these methods to plug in any secret store
|
|
24
|
+
(1Password, HashiCorp Vault, AWS Secrets Manager, etc.).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def get(self, provider: str) -> str | None:
|
|
28
|
+
"""Retrieve an API key. Returns None if not found."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
def store(self, provider: str, api_key: str) -> bool:
|
|
32
|
+
"""Store an API key. Returns True on success."""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
def delete(self, provider: str) -> bool:
|
|
36
|
+
"""Delete an API key. Returns True on success."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
def exists(self, provider: str) -> bool:
|
|
40
|
+
"""Check if an API key exists for the given provider."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
def list_providers(self, known_providers: list[str]) -> list[str]:
|
|
44
|
+
"""Return which of the known providers have stored keys."""
|
|
45
|
+
...
|