proxyagent 0.3.0__tar.gz → 0.3.1__tar.gz
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.
- {proxyagent-0.3.0 → proxyagent-0.3.1}/PKG-INFO +3 -3
- {proxyagent-0.3.0 → proxyagent-0.3.1}/README.md +2 -2
- {proxyagent-0.3.0 → proxyagent-0.3.1}/proxyagent/__init__.py +1 -1
- {proxyagent-0.3.0 → proxyagent-0.3.1}/proxyagent/cli.py +99 -35
- {proxyagent-0.3.0 → proxyagent-0.3.1}/proxyagent/config.py +9 -4
- {proxyagent-0.3.0 → proxyagent-0.3.1}/pyproject.toml +1 -1
- {proxyagent-0.3.0 → proxyagent-0.3.1}/.gitignore +0 -0
- {proxyagent-0.3.0 → proxyagent-0.3.1}/proxyagent/aliases.py +0 -0
- {proxyagent-0.3.0 → proxyagent-0.3.1}/proxyagent/crypto.py +0 -0
- {proxyagent-0.3.0 → proxyagent-0.3.1}/proxyagent/db.py +0 -0
- {proxyagent-0.3.0 → proxyagent-0.3.1}/proxyagent/harness.py +0 -0
- {proxyagent-0.3.0 → proxyagent-0.3.1}/proxyagent/pricing.py +0 -0
- {proxyagent-0.3.0 → proxyagent-0.3.1}/proxyagent/providers.py +0 -0
- {proxyagent-0.3.0 → proxyagent-0.3.1}/proxyagent/security.py +0 -0
- {proxyagent-0.3.0 → proxyagent-0.3.1}/proxyagent/server.py +0 -0
- {proxyagent-0.3.0 → proxyagent-0.3.1}/proxyagent/store.py +0 -0
- {proxyagent-0.3.0 → proxyagent-0.3.1}/proxyagent/tools.py +0 -0
- {proxyagent-0.3.0 → proxyagent-0.3.1}/proxyagent/ui/index.html +0 -0
- {proxyagent-0.3.0 → proxyagent-0.3.1}/tests/test_proxy.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proxyagent
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Run any agent (Claude, Codex, custom) on any machine — with no API key on the machine. A secure, self-hosted proxy for models and tools.
|
|
5
5
|
Project-URL: Homepage, https://github.com/teddyoweh/proxyagent
|
|
6
6
|
Author-email: Spawn Labs <teddy@spawnlabs.ai>
|
|
@@ -64,7 +64,7 @@ machine never sees a real credential.
|
|
|
64
64
|
## Try it with zero keys (local)
|
|
65
65
|
```bash
|
|
66
66
|
pip install proxyagent && proxyagent serve # prints an admin token
|
|
67
|
-
proxyagent token new local
|
|
67
|
+
proxyagent token new local # works locally, no admin token needed # mint a token
|
|
68
68
|
# call the built-in `mock` model — full pipeline (auth, scope, usage, cost, log), no real key:
|
|
69
69
|
curl -s localhost:8080/anthropic/v1/messages -H "x-api-key: pa_…" \
|
|
70
70
|
-d '{"model":"mock","max_tokens":50,"messages":[{"role":"user","content":"hi"}]}'
|
|
@@ -81,7 +81,7 @@ proxyagent serve # prints an admin token + a dashboard at
|
|
|
81
81
|
|
|
82
82
|
**2. Mint a machine token** (scoped + revocable):
|
|
83
83
|
```bash
|
|
84
|
-
proxyagent token new macbook-01 --scope "anthropic:claude-*"
|
|
84
|
+
proxyagent token new macbook-01 --scope "anthropic:claude-*" # local: no admin token needed
|
|
85
85
|
```
|
|
86
86
|
|
|
87
87
|
**3. Run any agent on any machine — no real key there:**
|
|
@@ -32,7 +32,7 @@ machine never sees a real credential.
|
|
|
32
32
|
## Try it with zero keys (local)
|
|
33
33
|
```bash
|
|
34
34
|
pip install proxyagent && proxyagent serve # prints an admin token
|
|
35
|
-
proxyagent token new local
|
|
35
|
+
proxyagent token new local # works locally, no admin token needed # mint a token
|
|
36
36
|
# call the built-in `mock` model — full pipeline (auth, scope, usage, cost, log), no real key:
|
|
37
37
|
curl -s localhost:8080/anthropic/v1/messages -H "x-api-key: pa_…" \
|
|
38
38
|
-d '{"model":"mock","max_tokens":50,"messages":[{"role":"user","content":"hi"}]}'
|
|
@@ -49,7 +49,7 @@ proxyagent serve # prints an admin token + a dashboard at
|
|
|
49
49
|
|
|
50
50
|
**2. Mint a machine token** (scoped + revocable):
|
|
51
51
|
```bash
|
|
52
|
-
proxyagent token new macbook-01 --scope "anthropic:claude-*"
|
|
52
|
+
proxyagent token new macbook-01 --scope "anthropic:claude-*" # local: no admin token needed
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
**3. Run any agent on any machine — no real key there:**
|
|
@@ -16,6 +16,9 @@ console = Console()
|
|
|
16
16
|
err = Console(stderr=True)
|
|
17
17
|
|
|
18
18
|
|
|
19
|
+
DEFAULT_PROXY = "http://127.0.0.1:8080"
|
|
20
|
+
|
|
21
|
+
|
|
19
22
|
def _admin_client(proxy: str, admin: Optional[str]) -> httpx.Client:
|
|
20
23
|
admin = admin or os.environ.get("PROXYAGENT_ADMIN_TOKEN")
|
|
21
24
|
if not admin:
|
|
@@ -25,6 +28,22 @@ def _admin_client(proxy: str, admin: Optional[str]) -> httpx.Client:
|
|
|
25
28
|
return httpx.Client(base_url=proxy.rstrip("/"), headers={"x-admin-token": admin}, timeout=30)
|
|
26
29
|
|
|
27
30
|
|
|
31
|
+
def _is_remote(proxy: str, admin: Optional[str]) -> bool:
|
|
32
|
+
"""Manage a REMOTE proxy (via admin API) only if an admin token is given or the
|
|
33
|
+
proxy isn't localhost. Otherwise operate on the LOCAL store directly — no admin
|
|
34
|
+
token needed, you already have filesystem access."""
|
|
35
|
+
from urllib.parse import urlparse
|
|
36
|
+
if admin or os.environ.get("PROXYAGENT_ADMIN_TOKEN"):
|
|
37
|
+
return True
|
|
38
|
+
return urlparse(proxy).hostname not in (None, "127.0.0.1", "localhost", "0.0.0.0")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _local_store():
|
|
42
|
+
from .config import Config
|
|
43
|
+
from .store import Store
|
|
44
|
+
return Store(Config.load().db_path)
|
|
45
|
+
|
|
46
|
+
|
|
28
47
|
@app.command()
|
|
29
48
|
def serve(host: str = "127.0.0.1", port: int = 8080):
|
|
30
49
|
"""Run the proxy server + dashboard."""
|
|
@@ -36,15 +55,27 @@ def serve(host: str = "127.0.0.1", port: int = 8080):
|
|
|
36
55
|
config = Config.load()
|
|
37
56
|
if config.admin_token_plain:
|
|
38
57
|
console.print(Panel.fit(
|
|
39
|
-
f"[green]✓ proxyagent[/green]\n\n[bold]Admin token[/bold] (
|
|
58
|
+
f"[green]✓ proxyagent[/green]\n\n[bold]Admin token[/bold] (for the dashboard)\n"
|
|
40
59
|
f" [yellow]{config.admin_token_plain}[/yellow]\n\n"
|
|
41
|
-
f"[dim]
|
|
60
|
+
f"[dim]Reveal anytime: [bold]proxyagent admin-token[/bold][/dim]",
|
|
42
61
|
border_style="green"))
|
|
43
62
|
console.print(f"[dim]Dashboard:[/dim] http://{host}:{port} "
|
|
44
|
-
f"[dim]providers:[/dim] {', '.join(config.configured_providers()) or 'none —
|
|
63
|
+
f"[dim]providers:[/dim] {', '.join(config.configured_providers()) or 'none — `proxyagent provider add anthropic --key …`'}")
|
|
64
|
+
console.print("[dim]Mint a machine token in another terminal:[/dim] [bold]proxyagent token new[/bold] [dim](works locally, no admin token needed)[/dim]")
|
|
45
65
|
uvicorn.run(create_app(config), host=host, port=port, log_level="warning")
|
|
46
66
|
|
|
47
67
|
|
|
68
|
+
@app.command("admin-token")
|
|
69
|
+
def admin_token():
|
|
70
|
+
"""Print this machine's admin token (for the dashboard)."""
|
|
71
|
+
from .config import Config
|
|
72
|
+
cfg = Config.load()
|
|
73
|
+
if cfg.admin_token_plain:
|
|
74
|
+
console.print(cfg.admin_token_plain)
|
|
75
|
+
else:
|
|
76
|
+
err.print("[yellow]Admin token is set via PROXYAGENT_ADMIN_TOKEN (not stored here).[/yellow]")
|
|
77
|
+
|
|
78
|
+
|
|
48
79
|
@app.command("run")
|
|
49
80
|
def run_harness(
|
|
50
81
|
harness: str = typer.Argument(..., help="claude-code | codex | <custom>"),
|
|
@@ -122,13 +153,21 @@ def provider_add(
|
|
|
122
153
|
admin: str = typer.Option(None, "--admin"),
|
|
123
154
|
):
|
|
124
155
|
"""Store a provider credential (encrypted if PROXYAGENT_SECRET_KEY is set)."""
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
156
|
+
from .config import PROVIDERS
|
|
157
|
+
if provider not in PROVIDERS:
|
|
158
|
+
err.print(f"[red]✗[/red] unknown provider; known: {', '.join(PROVIDERS)}"); raise typer.Exit(1)
|
|
159
|
+
if not _is_remote(proxy, admin):
|
|
160
|
+
from . import crypto
|
|
161
|
+
_local_store().add_credential(provider, key, kind=kind, label=label)
|
|
162
|
+
stored = "encrypted" if crypto.encryption_available() else "plaintext"
|
|
163
|
+
else:
|
|
164
|
+
with _admin_client(proxy, admin) as c:
|
|
165
|
+
r = c.post("/admin/providers", json={"provider": provider, "secret": key,
|
|
166
|
+
"kind": kind, "label": label})
|
|
167
|
+
if r.status_code >= 400:
|
|
168
|
+
err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
|
|
169
|
+
stored = r.json()["stored"]
|
|
170
|
+
note = "[green]encrypted[/green]" if stored == "encrypted" else "[yellow]plaintext — set PROXYAGENT_SECRET_KEY[/yellow]"
|
|
132
171
|
console.print(f"[green]✓[/green] stored [cyan]{provider}[/cyan] ({kind}) · {note}")
|
|
133
172
|
|
|
134
173
|
|
|
@@ -136,11 +175,19 @@ def provider_add(
|
|
|
136
175
|
def provider_ls(proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
|
|
137
176
|
admin: str = typer.Option(None, "--admin")):
|
|
138
177
|
"""List stored provider credentials (secrets never shown)."""
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
178
|
+
if not _is_remote(proxy, admin):
|
|
179
|
+
from . import crypto
|
|
180
|
+
from .config import PROVIDERS
|
|
181
|
+
creds = _local_store().list_credentials()
|
|
182
|
+
configured = sorted({n for n, p in PROVIDERS.items() if p.key}
|
|
183
|
+
| {c["provider"] for c in creds if c["active"]})
|
|
184
|
+
d = {"credentials": creds, "configured": configured, "encryption": crypto.encryption_available()}
|
|
185
|
+
else:
|
|
186
|
+
with _admin_client(proxy, admin) as c:
|
|
187
|
+
r = c.get("/admin/providers")
|
|
188
|
+
if r.status_code >= 400:
|
|
189
|
+
err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
|
|
190
|
+
d = r.json()
|
|
144
191
|
t = Table(title=f"Provider credentials · encryption {'on' if d['encryption'] else 'OFF'}")
|
|
145
192
|
for col in ("ID", "Provider", "Kind", "Label", "Active"):
|
|
146
193
|
t.add_column(col)
|
|
@@ -155,10 +202,14 @@ def provider_ls(proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
|
|
|
155
202
|
def provider_rm(cred_id: str, proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
|
|
156
203
|
admin: str = typer.Option(None, "--admin")):
|
|
157
204
|
"""Remove a stored credential."""
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
205
|
+
if not _is_remote(proxy, admin):
|
|
206
|
+
if not _local_store().remove_credential(cred_id):
|
|
207
|
+
err.print(f"[red]✗[/red] no such credential"); raise typer.Exit(1)
|
|
208
|
+
else:
|
|
209
|
+
with _admin_client(proxy, admin) as c:
|
|
210
|
+
r = c.delete(f"/admin/providers/{cred_id}")
|
|
211
|
+
if r.status_code >= 400:
|
|
212
|
+
err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
|
|
162
213
|
console.print(f"[green]✓[/green] removed {cred_id}")
|
|
163
214
|
|
|
164
215
|
|
|
@@ -172,14 +223,17 @@ def token_new(
|
|
|
172
223
|
admin: Optional[str] = typer.Option(None, "--admin"),
|
|
173
224
|
):
|
|
174
225
|
"""Mint a machine token — give it to a remote machine; it holds no real key."""
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
226
|
+
if not _is_remote(proxy, admin):
|
|
227
|
+
plain, _ = _local_store().create_token(label, list(scope), ttl_seconds=ttl, rate_limit=rate)
|
|
228
|
+
else:
|
|
229
|
+
with _admin_client(proxy, admin) as c:
|
|
230
|
+
r = c.post("/admin/tokens", json={"label": label, "scope": list(scope),
|
|
231
|
+
"ttl_seconds": ttl, "rate_limit": rate})
|
|
232
|
+
if r.status_code >= 400:
|
|
233
|
+
err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
|
|
234
|
+
plain = r.json()["token"]
|
|
181
235
|
console.print(Panel.fit(
|
|
182
|
-
f"[green]✓ machine token[/green] [dim]({label})[/dim]\n\n [yellow]{
|
|
236
|
+
f"[green]✓ machine token[/green] [dim]({label})[/dim]\n\n [yellow]{plain}[/yellow]\n\n"
|
|
183
237
|
f"[dim]scope: {', '.join(scope)} · shown once[/dim]", border_style="green"))
|
|
184
238
|
|
|
185
239
|
|
|
@@ -187,11 +241,17 @@ def token_new(
|
|
|
187
241
|
def token_ls(proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
|
|
188
242
|
admin: Optional[str] = typer.Option(None, "--admin")):
|
|
189
243
|
"""List machine tokens."""
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
244
|
+
if not _is_remote(proxy, admin):
|
|
245
|
+
import json as _json
|
|
246
|
+
rows = [{"id": t["id"], "label": t["label"], "masked": t["masked"],
|
|
247
|
+
"scope": _json.loads(t["scope_json"]), "revoked": t["revoked"]}
|
|
248
|
+
for t in _local_store().list_tokens()]
|
|
249
|
+
else:
|
|
250
|
+
with _admin_client(proxy, admin) as c:
|
|
251
|
+
r = c.get("/admin/tokens")
|
|
252
|
+
if r.status_code >= 400:
|
|
253
|
+
err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
|
|
254
|
+
rows = r.json()["tokens"]
|
|
195
255
|
if not rows:
|
|
196
256
|
console.print("[dim]No tokens.[/dim]"); return
|
|
197
257
|
t = Table(title="Machine tokens")
|
|
@@ -207,10 +267,14 @@ def token_ls(proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
|
|
|
207
267
|
def token_revoke(token_id: str, proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
|
|
208
268
|
admin: Optional[str] = typer.Option(None, "--admin")):
|
|
209
269
|
"""Revoke a token by id."""
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
270
|
+
if not _is_remote(proxy, admin):
|
|
271
|
+
if not _local_store().revoke_token(token_id):
|
|
272
|
+
err.print(f"[red]✗[/red] no such token"); raise typer.Exit(1)
|
|
273
|
+
else:
|
|
274
|
+
with _admin_client(proxy, admin) as c:
|
|
275
|
+
r = c.delete(f"/admin/tokens/{token_id}")
|
|
276
|
+
if r.status_code >= 400:
|
|
277
|
+
err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
|
|
214
278
|
console.print(f"[green]✓[/green] revoked {token_id}")
|
|
215
279
|
|
|
216
280
|
|
|
@@ -74,16 +74,21 @@ class Config:
|
|
|
74
74
|
# Admin token: from env, or a persisted one, or freshly generated (shown once).
|
|
75
75
|
env_admin = os.environ.get("PROXYAGENT_ADMIN_TOKEN")
|
|
76
76
|
admin_file = HOME / "admin_token"
|
|
77
|
+
existing = admin_file.read_text().strip() if admin_file.exists() else ""
|
|
77
78
|
if env_admin:
|
|
79
|
+
# Production: trust the env token, persist nothing.
|
|
78
80
|
cfg.admin_token_hash = hash_token(env_admin)
|
|
79
|
-
elif
|
|
80
|
-
|
|
81
|
+
elif existing.startswith(ADMIN_PREFIX):
|
|
82
|
+
# Local: the plaintext is stored (0600) so the dashboard stays reachable.
|
|
83
|
+
cfg.admin_token_plain = existing
|
|
84
|
+
cfg.admin_token_hash = hash_token(existing)
|
|
81
85
|
else:
|
|
86
|
+
# Fresh (or migrating an old hash-only file we can't recover): regenerate.
|
|
82
87
|
plain = new_token(ADMIN_PREFIX)
|
|
83
|
-
|
|
84
|
-
admin_file.write_text(cfg.admin_token_hash)
|
|
88
|
+
admin_file.write_text(plain)
|
|
85
89
|
admin_file.chmod(0o600)
|
|
86
90
|
cfg.admin_token_plain = plain
|
|
91
|
+
cfg.admin_token_hash = hash_token(plain)
|
|
87
92
|
return cfg
|
|
88
93
|
|
|
89
94
|
def configured_providers(self) -> list[str]:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "proxyagent"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Run any agent (Claude, Codex, custom) on any machine — with no API key on the machine. A secure, self-hosted proxy for models and tools."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|