proxyagent 0.3.0__tar.gz → 0.4.0__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.4.0}/PKG-INFO +12 -6
- {proxyagent-0.3.0 → proxyagent-0.4.0}/README.md +11 -5
- {proxyagent-0.3.0 → proxyagent-0.4.0}/proxyagent/__init__.py +1 -1
- {proxyagent-0.3.0 → proxyagent-0.4.0}/proxyagent/cli.py +99 -35
- {proxyagent-0.3.0 → proxyagent-0.4.0}/proxyagent/config.py +33 -4
- {proxyagent-0.3.0 → proxyagent-0.4.0}/proxyagent/server.py +21 -1
- proxyagent-0.4.0/proxyagent/ui/index.html +197 -0
- {proxyagent-0.3.0 → proxyagent-0.4.0}/pyproject.toml +1 -1
- proxyagent-0.3.0/proxyagent/ui/index.html +0 -125
- {proxyagent-0.3.0 → proxyagent-0.4.0}/.gitignore +0 -0
- {proxyagent-0.3.0 → proxyagent-0.4.0}/proxyagent/aliases.py +0 -0
- {proxyagent-0.3.0 → proxyagent-0.4.0}/proxyagent/crypto.py +0 -0
- {proxyagent-0.3.0 → proxyagent-0.4.0}/proxyagent/db.py +0 -0
- {proxyagent-0.3.0 → proxyagent-0.4.0}/proxyagent/harness.py +0 -0
- {proxyagent-0.3.0 → proxyagent-0.4.0}/proxyagent/pricing.py +0 -0
- {proxyagent-0.3.0 → proxyagent-0.4.0}/proxyagent/providers.py +0 -0
- {proxyagent-0.3.0 → proxyagent-0.4.0}/proxyagent/security.py +0 -0
- {proxyagent-0.3.0 → proxyagent-0.4.0}/proxyagent/store.py +0 -0
- {proxyagent-0.3.0 → proxyagent-0.4.0}/proxyagent/tools.py +0 -0
- {proxyagent-0.3.0 → proxyagent-0.4.0}/tests/test_proxy.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proxyagent
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
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:**
|
|
@@ -99,9 +99,15 @@ claude -p "ship it"
|
|
|
99
99
|
```
|
|
100
100
|
|
|
101
101
|
## The dashboard
|
|
102
|
-
`proxyagent serve` ships a dashboard at `/`
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
`proxyagent serve` ships a real dashboard at `/` (reveal the admin token with
|
|
103
|
+
`proxyagent admin-token`):
|
|
104
|
+
|
|
105
|
+
- **Providers** — a branded catalog of every supported provider; **connect/disconnect**
|
|
106
|
+
with a key right from the UI, see which auth types each supports (api_key / oauth) and
|
|
107
|
+
whether it's on via env or stored credentials.
|
|
108
|
+
- **Machine tokens** — mint (scoped/TTL), list, revoke.
|
|
109
|
+
- **Model routing** — add/remove model remaps (e.g. `* → mock` for offline).
|
|
110
|
+
- **Activity** — live request log with usage + cost, and headline stats.
|
|
105
111
|
|
|
106
112
|
## Proxied tools — the same trick, for tools
|
|
107
113
|
The proxy can also hold your **tool** keys and hand agents governed tools — so an agent gets
|
|
@@ -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:**
|
|
@@ -67,9 +67,15 @@ claude -p "ship it"
|
|
|
67
67
|
```
|
|
68
68
|
|
|
69
69
|
## The dashboard
|
|
70
|
-
`proxyagent serve` ships a dashboard at `/`
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
`proxyagent serve` ships a real dashboard at `/` (reveal the admin token with
|
|
71
|
+
`proxyagent admin-token`):
|
|
72
|
+
|
|
73
|
+
- **Providers** — a branded catalog of every supported provider; **connect/disconnect**
|
|
74
|
+
with a key right from the UI, see which auth types each supports (api_key / oauth) and
|
|
75
|
+
whether it's on via env or stored credentials.
|
|
76
|
+
- **Machine tokens** — mint (scoped/TTL), list, revoke.
|
|
77
|
+
- **Model routing** — add/remove model remaps (e.g. `* → mock` for offline).
|
|
78
|
+
- **Activity** — live request log with usage + cost, and headline stats.
|
|
73
79
|
|
|
74
80
|
## Proxied tools — the same trick, for tools
|
|
75
81
|
The proxy can also hold your **tool** keys and hand agents governed tools — so an agent gets
|
|
@@ -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
|
|
|
@@ -59,6 +59,30 @@ PROVIDERS: dict[str, Provider] = {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
|
|
62
|
+
# Display metadata for the dashboard: label, the auth kinds each provider supports,
|
|
63
|
+
# a brand accent colour, and example models.
|
|
64
|
+
CATALOG: dict[str, dict] = {
|
|
65
|
+
"anthropic": {"label": "Anthropic", "kinds": ["api_key", "oauth"], "color": "#D97757",
|
|
66
|
+
"models": ["claude-opus-4", "claude-sonnet-4-5", "claude-haiku-4"]},
|
|
67
|
+
"openai": {"label": "OpenAI", "kinds": ["api_key", "oauth"], "color": "#10A37F",
|
|
68
|
+
"models": ["gpt-5", "gpt-4.1", "gpt-4o", "o3"]},
|
|
69
|
+
"gemini": {"label": "Google Gemini","kinds": ["api_key"], "color": "#4285F4",
|
|
70
|
+
"models": ["gemini-2.5-pro", "gemini-2.5-flash"]},
|
|
71
|
+
"groq": {"label": "Groq", "kinds": ["api_key"], "color": "#F55036",
|
|
72
|
+
"models": ["llama-3.3-70b", "deepseek-r1-distill"]},
|
|
73
|
+
"openrouter": {"label": "OpenRouter", "kinds": ["api_key"], "color": "#7C7CFF",
|
|
74
|
+
"models": ["anthropic/claude-sonnet-4.5", "openai/gpt-5"]},
|
|
75
|
+
"mistral": {"label": "Mistral", "kinds": ["api_key"], "color": "#FF7000",
|
|
76
|
+
"models": ["mistral-large", "codestral"]},
|
|
77
|
+
"deepseek": {"label": "DeepSeek", "kinds": ["api_key"], "color": "#4D6BFE",
|
|
78
|
+
"models": ["deepseek-chat", "deepseek-reasoner"]},
|
|
79
|
+
"xai": {"label": "xAI", "kinds": ["api_key"], "color": "#111111",
|
|
80
|
+
"models": ["grok-4", "grok-3-mini"]},
|
|
81
|
+
"together": {"label": "Together", "kinds": ["api_key"], "color": "#0F6FFF",
|
|
82
|
+
"models": ["llama-3.3-70b", "qwen-2.5-72b"]},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
62
86
|
@dataclass
|
|
63
87
|
class Config:
|
|
64
88
|
home: Path = HOME
|
|
@@ -74,16 +98,21 @@ class Config:
|
|
|
74
98
|
# Admin token: from env, or a persisted one, or freshly generated (shown once).
|
|
75
99
|
env_admin = os.environ.get("PROXYAGENT_ADMIN_TOKEN")
|
|
76
100
|
admin_file = HOME / "admin_token"
|
|
101
|
+
existing = admin_file.read_text().strip() if admin_file.exists() else ""
|
|
77
102
|
if env_admin:
|
|
103
|
+
# Production: trust the env token, persist nothing.
|
|
78
104
|
cfg.admin_token_hash = hash_token(env_admin)
|
|
79
|
-
elif
|
|
80
|
-
|
|
105
|
+
elif existing.startswith(ADMIN_PREFIX):
|
|
106
|
+
# Local: the plaintext is stored (0600) so the dashboard stays reachable.
|
|
107
|
+
cfg.admin_token_plain = existing
|
|
108
|
+
cfg.admin_token_hash = hash_token(existing)
|
|
81
109
|
else:
|
|
110
|
+
# Fresh (or migrating an old hash-only file we can't recover): regenerate.
|
|
82
111
|
plain = new_token(ADMIN_PREFIX)
|
|
83
|
-
|
|
84
|
-
admin_file.write_text(cfg.admin_token_hash)
|
|
112
|
+
admin_file.write_text(plain)
|
|
85
113
|
admin_file.chmod(0o600)
|
|
86
114
|
cfg.admin_token_plain = plain
|
|
115
|
+
cfg.admin_token_hash = hash_token(plain)
|
|
87
116
|
return cfg
|
|
88
117
|
|
|
89
118
|
def configured_providers(self) -> list[str]:
|
|
@@ -15,7 +15,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
|
|
15
15
|
from pydantic import BaseModel
|
|
16
16
|
|
|
17
17
|
from . import aliases, crypto
|
|
18
|
-
from .config import Config, PROVIDERS
|
|
18
|
+
from .config import CATALOG, Config, PROVIDERS
|
|
19
19
|
from .providers import forward, scope_allows
|
|
20
20
|
from .security import token_matches
|
|
21
21
|
from .store import Store, now_ms
|
|
@@ -218,6 +218,26 @@ def create_app(config: Config | None = None) -> FastAPI:
|
|
|
218
218
|
raise HTTPException(404, "no such credential")
|
|
219
219
|
return {"ok": True}
|
|
220
220
|
|
|
221
|
+
@app.get("/admin/catalog")
|
|
222
|
+
async def catalog(authorization: str | None = Header(None),
|
|
223
|
+
x_admin_token: str | None = Header(None)):
|
|
224
|
+
require_admin(authorization, x_admin_token)
|
|
225
|
+
stored = {c["provider"]: c for c in store.list_credentials() if c["active"]}
|
|
226
|
+
out = []
|
|
227
|
+
for name, prov in PROVIDERS.items():
|
|
228
|
+
meta = CATALOG.get(name, {})
|
|
229
|
+
cred = stored.get(name)
|
|
230
|
+
out.append({
|
|
231
|
+
"name": name, "label": meta.get("label", name.title()),
|
|
232
|
+
"kinds": meta.get("kinds", ["api_key"]), "color": meta.get("color", "#888"),
|
|
233
|
+
"models": meta.get("models", []), "shape": prov.shape,
|
|
234
|
+
"via_env": bool(prov.key), "via_store": bool(cred),
|
|
235
|
+
"cred_id": cred["id"] if cred else None,
|
|
236
|
+
"cred_kind": cred["kind"] if cred else None,
|
|
237
|
+
"endpoint": prov.endpoint,
|
|
238
|
+
})
|
|
239
|
+
return {"providers": out, "encryption": crypto.encryption_available()}
|
|
240
|
+
|
|
221
241
|
# -- model aliases / remap -------------------------------------------- #
|
|
222
242
|
@app.get("/admin/aliases")
|
|
223
243
|
async def get_aliases(authorization: str | None = Header(None),
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>proxyagent</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root{--bg:#0a0b0d;--panel:#141619;--panel2:#1a1d21;--line:#23262c;--txt:#eceef1;--dim:#878d96;--grn:#34d39e;--red:#f87171;--yel:#fbbf24;--blu:#6ea8fe}
|
|
9
|
+
*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--txt);font:14px/1.55 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto}
|
|
10
|
+
code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
11
|
+
a{color:var(--grn);text-decoration:none}
|
|
12
|
+
header{position:sticky;top:0;z-index:10;display:flex;align-items:center;justify-content:space-between;padding:14px 26px;border-bottom:1px solid var(--line);background:rgba(10,11,13,.8);backdrop-filter:blur(12px)}
|
|
13
|
+
.brand{display:flex;align-items:center;gap:10px;font-weight:650;font-size:16px}
|
|
14
|
+
.logo{width:26px;height:26px;border-radius:7px;background:linear-gradient(135deg,#34d39e,#2563eb);display:grid;place-items:center;color:#04130d;font-weight:800}
|
|
15
|
+
main{max-width:1120px;margin:0 auto;padding:26px}
|
|
16
|
+
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin-bottom:24px}
|
|
17
|
+
.card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:18px}
|
|
18
|
+
.stat .n{font-size:30px;font-weight:680;letter-spacing:-.02em}.stat .l{color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.09em;margin-top:5px}
|
|
19
|
+
.tabs{display:flex;gap:4px;margin-bottom:18px;border-bottom:1px solid var(--line)}
|
|
20
|
+
.tab{padding:9px 15px;color:var(--dim);cursor:pointer;border-bottom:2px solid transparent;font-weight:550}
|
|
21
|
+
.tab.on{color:var(--txt);border-bottom-color:var(--grn)}
|
|
22
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(248px,1fr));gap:14px}
|
|
23
|
+
.prov{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:16px;transition:border-color .15s}
|
|
24
|
+
.prov:hover{border-color:#33383f}
|
|
25
|
+
.prov .top{display:flex;align-items:center;gap:12px}
|
|
26
|
+
.tile{width:40px;height:40px;border-radius:11px;display:grid;place-items:center;flex:none;font-weight:800;font-size:17px}
|
|
27
|
+
.prov h3{margin:0;font-size:15px;font-weight:620}.prov .ep{color:var(--dim);font-size:11px;margin-top:1px}
|
|
28
|
+
.badges{display:flex;gap:6px;flex-wrap:wrap;margin:12px 0}
|
|
29
|
+
.badge{font-size:10.5px;padding:2px 8px;border-radius:99px;background:#1f2329;color:var(--dim);text-transform:uppercase;letter-spacing:.04em}
|
|
30
|
+
.badge.on{background:rgba(52,211,158,.13);color:var(--grn)}
|
|
31
|
+
.models{color:var(--dim);font-size:11.5px;margin:6px 0 12px;min-height:16px}
|
|
32
|
+
input,button,select{font:inherit;border-radius:9px;border:1px solid var(--line);background:#0e1013;color:var(--txt);padding:8px 11px;outline:none}
|
|
33
|
+
input:focus,select:focus{border-color:#3a4048}
|
|
34
|
+
button{background:var(--grn);color:#04130d;border:none;font-weight:640;cursor:pointer}button:hover{filter:brightness(1.07)}
|
|
35
|
+
button.ghost{background:transparent;color:var(--dim);border:1px solid var(--line)}button.ghost:hover{color:var(--txt)}
|
|
36
|
+
button.danger{background:transparent;color:var(--red);border:1px solid #3a2626}
|
|
37
|
+
button.sm{padding:6px 11px;font-size:12.5px;border-radius:8px}
|
|
38
|
+
.row{display:flex;gap:9px;flex-wrap:wrap;align-items:center}
|
|
39
|
+
.connect{margin-top:4px;display:none;gap:8px;flex-direction:column}
|
|
40
|
+
.connect.open{display:flex}
|
|
41
|
+
table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:10px;border-bottom:1px solid var(--line);font-size:13px}
|
|
42
|
+
th{color:var(--dim);font-weight:520;font-size:11px;text-transform:uppercase;letter-spacing:.06em}
|
|
43
|
+
.pill{padding:2px 9px;border-radius:99px;font-size:11px}.pill.ok{background:rgba(52,211,158,.12);color:var(--grn)}.pill.no{background:rgba(248,113,113,.12);color:var(--red)}
|
|
44
|
+
.gate{max-width:430px;margin:96px auto;text-align:center}.gate input{width:100%;margin:14px 0}
|
|
45
|
+
.tok{background:#0e1013;border:1px dashed var(--grn);padding:12px;border-radius:10px;word-break:break-all;margin-top:12px;font-size:13px}
|
|
46
|
+
.hide{display:none}.muted{color:var(--dim)}.h{display:flex;justify-content:space-between;align-items:center;margin:0 0 12px}
|
|
47
|
+
h2{font-size:13px;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);margin:0}
|
|
48
|
+
</style>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<div id="gate" class="gate">
|
|
52
|
+
<div class="brand" style="justify-content:center"><span class="logo">P</span> proxyagent</div>
|
|
53
|
+
<p class="muted">Paste your admin token — reveal it with <code>proxyagent admin-token</code>.</p>
|
|
54
|
+
<input id="admintok" type="password" placeholder="pa_admin_…" onkeydown="if(event.key==='Enter')saveAdmin()"/>
|
|
55
|
+
<button onclick="saveAdmin()">Open dashboard</button>
|
|
56
|
+
<p id="gateerr" style="color:var(--red)"></p>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div id="app" class="hide">
|
|
60
|
+
<header>
|
|
61
|
+
<div class="brand"><span class="logo">P</span> proxyagent <span id="badge_backend" class="badge" style="margin-left:6px"></span></div>
|
|
62
|
+
<div class="row"><span id="enc" class="muted" style="font-size:12px"></span><button class="ghost sm" onclick="logout()">Sign out</button></div>
|
|
63
|
+
</header>
|
|
64
|
+
<main>
|
|
65
|
+
<div class="stats">
|
|
66
|
+
<div class="card stat"><div class="n" id="s_req">0</div><div class="l">Requests</div></div>
|
|
67
|
+
<div class="card stat"><div class="n" id="s_tok">0</div><div class="l">Tokens (in/out)</div></div>
|
|
68
|
+
<div class="card stat"><div class="n" id="s_cost" style="color:var(--grn)">$0</div><div class="l">Cost</div></div>
|
|
69
|
+
<div class="card stat"><div class="n" id="s_prov">0</div><div class="l">Providers connected</div></div>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div class="tabs">
|
|
73
|
+
<div class="tab on" data-t="providers" onclick="tab('providers')">Providers</div>
|
|
74
|
+
<div class="tab" data-t="tokens" onclick="tab('tokens')">Machine tokens</div>
|
|
75
|
+
<div class="tab" data-t="models" onclick="tab('models')">Model routing</div>
|
|
76
|
+
<div class="tab" data-t="activity" onclick="tab('activity')">Activity</div>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<section id="t_providers">
|
|
80
|
+
<div class="grid" id="provgrid"></div>
|
|
81
|
+
</section>
|
|
82
|
+
|
|
83
|
+
<section id="t_tokens" class="hide">
|
|
84
|
+
<div class="card" style="margin-bottom:16px">
|
|
85
|
+
<div class="h"><h2>Mint a machine token</h2></div>
|
|
86
|
+
<div class="row">
|
|
87
|
+
<input id="tk_label" placeholder="label (e.g. macbook-01)"/>
|
|
88
|
+
<input id="tk_scope" placeholder="scope: * or anthropic:claude-*" style="flex:1"/>
|
|
89
|
+
<input id="tk_ttl" type="number" placeholder="ttl (s)" style="width:110px"/>
|
|
90
|
+
<button onclick="mintToken()">Mint</button>
|
|
91
|
+
</div>
|
|
92
|
+
<div id="tk_out" class="tok hide"></div>
|
|
93
|
+
</div>
|
|
94
|
+
<div class="card"><table><thead><tr><th>ID</th><th>Label</th><th>Token</th><th>Scope</th><th>Status</th><th></th></tr></thead><tbody id="toks"></tbody></table></div>
|
|
95
|
+
</section>
|
|
96
|
+
|
|
97
|
+
<section id="t_models" class="hide">
|
|
98
|
+
<div class="card" style="margin-bottom:16px">
|
|
99
|
+
<div class="h"><h2>Remap a model</h2></div>
|
|
100
|
+
<div class="row">
|
|
101
|
+
<input id="al_match" placeholder="match: * or gpt-4o"/>
|
|
102
|
+
<span class="muted">→</span>
|
|
103
|
+
<input id="al_target" placeholder="target: mock or anthropic:claude-sonnet-4-5" style="flex:1"/>
|
|
104
|
+
<button onclick="setAlias()">Map</button>
|
|
105
|
+
</div>
|
|
106
|
+
<p class="muted" style="margin:10px 0 0">Tip: map <code>* → mock</code> to run any agent fully offline (no keys).</p>
|
|
107
|
+
</div>
|
|
108
|
+
<div class="card"><table><thead><tr><th>Match</th><th>→ Target</th><th></th></tr></thead><tbody id="aliases"></tbody></table></div>
|
|
109
|
+
</section>
|
|
110
|
+
|
|
111
|
+
<section id="t_activity" class="hide">
|
|
112
|
+
<div class="card"><table><thead><tr><th>Time</th><th>Token</th><th>Provider</th><th>Model</th><th>Status</th><th>In</th><th>Out</th><th>Cost</th><th>ms</th></tr></thead><tbody id="logs"></tbody></table></div>
|
|
113
|
+
</section>
|
|
114
|
+
</main>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<script>
|
|
118
|
+
const A=()=>localStorage.getItem("pa_admin");
|
|
119
|
+
const H=()=>({"x-admin-token":A(),"content-type":"application/json"});
|
|
120
|
+
async function api(p,o={}){const r=await fetch(p,{...o,headers:{...H(),...(o.headers||{})}});if(r.status===401){logout();throw new Error("401")}return r}
|
|
121
|
+
function val(id){return document.getElementById(id).value.trim()}
|
|
122
|
+
function saveAdmin(){const v=document.getElementById("admintok").value.trim();if(v){localStorage.setItem("pa_admin",v);boot()}}
|
|
123
|
+
function logout(){localStorage.removeItem("pa_admin");document.getElementById("app").classList.add("hide");document.getElementById("gate").classList.remove("hide")}
|
|
124
|
+
function tab(t){document.querySelectorAll(".tab").forEach(e=>e.classList.toggle("on",e.dataset.t===t));["providers","tokens","models","activity"].forEach(s=>document.getElementById("t_"+s).classList.toggle("hide",s!==t))}
|
|
125
|
+
|
|
126
|
+
// ---- provider logos (inline, brand-tinted) ----
|
|
127
|
+
const MARK={
|
|
128
|
+
anthropic:'<path d="M9.6 3 3 21h4.1l1.2-3.5h5.9L15.4 21H20L13.4 3H9.6Zm-.4 10.5 1.9-5.4 2 5.4H9.2Z"/>',
|
|
129
|
+
openai:'<path d="M12 2.6c1.9 0 3.5 1.3 4 3a4.2 4.2 0 0 1 2.3 6.8 4.2 4.2 0 0 1-4 5.8 4.2 4.2 0 0 1-8.6-1A4.2 4.2 0 0 1 5.7 12 4.2 4.2 0 0 1 8 5.5a4.2 4.2 0 0 1 4-2.9Zm0 4.6a4.8 4.8 0 1 0 0 9.6 4.8 4.8 0 0 0 0-9.6Z"/>',
|
|
130
|
+
gemini:'<path d="M12 2c.4 4.6 3.4 7.6 8 8-4.6.4-7.6 3.4-8 8-.4-4.6-3.4-7.6-8-8 4.6-.4 7.6-3.4 8-8Z"/>',
|
|
131
|
+
groq:'<path d="M12 3a6 6 0 1 0 4.2 10.3l-2-2A3.2 3.2 0 1 1 12 6.2c.9 0 1.6.3 2.2.8L16.3 5A6 6 0 0 0 12 3Zm2 7v3.3h2.6V10H14Z"/>',
|
|
132
|
+
openrouter:'<path d="M3 8h6l3 4 3-4h6M3 16h6l3-4M21 8l-3 8h-3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
|
133
|
+
mistral:'<g><rect x="3" y="4" width="3.4" height="3.4"/><rect x="17.6" y="4" width="3.4" height="3.4"/><rect x="7.3" y="8.3" width="3.4" height="3.4"/><rect x="13.3" y="8.3" width="3.4" height="3.4"/><rect x="3" y="12.6" width="3.4" height="3.4"/><rect x="17.6" y="12.6" width="3.4" height="3.4"/><rect x="3" y="16.9" width="18" height="3.1"/></g>',
|
|
134
|
+
deepseek:'<path d="M4 9c3 0 4 2 7 2s4-3 8-2c-1 4-5 7-9 7-3 0-6-2-6-5 0-1 0-2 0-2Zm12 1.5a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"/>',
|
|
135
|
+
xai:'<path d="M4 4h3.2l4 5.6L15.8 4H19l-6 8 6.2 8h-3.2l-4.4-6L7 20H4l6.4-8.4L4 4Z"/>',
|
|
136
|
+
together:'<path d="M8 5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7Zm8 7a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7ZM10.5 12.5l3-1.5" fill="none" stroke="currentColor" stroke-width="2"/>',
|
|
137
|
+
};
|
|
138
|
+
function logo(name,color){const m=MARK[name]||`<text x="12" y="16" text-anchor="middle" font-size="13" font-weight="800" fill="currentColor">${(name[0]||"?").toUpperCase()}</text>`;
|
|
139
|
+
return `<div class="tile" style="background:${hexa(color,.16)};color:${color}"><svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">${m}</svg></div>`}
|
|
140
|
+
function hexa(h,a){const n=h.replace("#","");const x=parseInt(n.length===3?n.split("").map(c=>c+c).join(""):n,16);return `rgba(${(x>>16)&255},${(x>>8)&255},${x&255},${a})`}
|
|
141
|
+
|
|
142
|
+
async function boot(){
|
|
143
|
+
try{
|
|
144
|
+
const u=await(await api("/admin/usage")).json();
|
|
145
|
+
document.getElementById("gate").classList.add("hide");document.getElementById("app").classList.remove("hide");
|
|
146
|
+
document.getElementById("s_req").textContent=u.usage.requests;
|
|
147
|
+
document.getElementById("s_tok").textContent=`${u.usage.prompt_tokens}/${u.usage.completion_tokens}`;
|
|
148
|
+
document.getElementById("s_cost").textContent="$"+(u.usage.cost_usd||0).toFixed(4);
|
|
149
|
+
document.getElementById("badge_backend").textContent=u.backend;
|
|
150
|
+
document.getElementById("enc").textContent=u.encryption?"🔒 encrypted at rest":"⚠ encryption off";
|
|
151
|
+
refreshProviders();refreshTokens();refreshAliases();refreshLogs();
|
|
152
|
+
}catch(e){document.getElementById("gateerr").textContent="Invalid admin token."}
|
|
153
|
+
}
|
|
154
|
+
async function refreshProviders(){
|
|
155
|
+
const d=await(await api("/admin/catalog")).json();const g=document.getElementById("provgrid");
|
|
156
|
+
let connected=0;
|
|
157
|
+
g.innerHTML=d.providers.map(p=>{const on=p.via_env||p.via_store;if(on)connected++;
|
|
158
|
+
const how=p.via_store?`stored ${p.cred_kind}`:(p.via_env?"env":"");
|
|
159
|
+
return `<div class="prov"><div class="top">${logo(p.name,p.color)}<div style="flex:1"><h3>${p.label}</h3><div class="ep">${p.shape} · ${p.name}</div></div>
|
|
160
|
+
${on?'<span class="pill ok">on</span>':'<span class="pill no">off</span>'}</div>
|
|
161
|
+
<div class="badges">${p.kinds.map(k=>`<span class="badge">${k}</span>`).join("")}${on?`<span class="badge on">${how}</span>`:""}</div>
|
|
162
|
+
<div class="models">${(p.models||[]).slice(0,2).join(" · ")}</div>
|
|
163
|
+
<div class="row">${p.via_store?`<button class="danger sm" onclick="disconnect('${p.cred_id}')">Disconnect</button>`:`<button class="sm" onclick="openConnect('${p.name}')">${p.via_env?"Override key":"Connect"}</button>`}</div>
|
|
164
|
+
<div class="connect" id="c_${p.name}">
|
|
165
|
+
<input id="k_${p.name}" type="password" placeholder="${p.name} API key${p.kinds.includes('oauth')?' / OAuth token':''}"/>
|
|
166
|
+
<div class="row">${p.kinds.length>1?`<select id="kind_${p.name}">${p.kinds.map(k=>`<option value="${k}">${k}</option>`).join("")}</select>`:`<input id="kind_${p.name}" type="hidden" value="api_key"/>`}
|
|
167
|
+
<button class="sm" onclick="connect('${p.name}')">Save</button><button class="ghost sm" onclick="openConnect('${p.name}')">Cancel</button></div>
|
|
168
|
+
</div></div>`}).join("");
|
|
169
|
+
document.getElementById("s_prov").textContent=connected;
|
|
170
|
+
document.getElementById("enc2")&&0;
|
|
171
|
+
}
|
|
172
|
+
function openConnect(n){document.getElementById("c_"+n).classList.toggle("open")}
|
|
173
|
+
async function connect(n){const key=document.getElementById("k_"+n).value.trim();if(!key)return;
|
|
174
|
+
const kindEl=document.getElementById("kind_"+n);const kind=kindEl?kindEl.value:"api_key";
|
|
175
|
+
await api("/admin/providers",{method:"POST",body:JSON.stringify({provider:n,secret:key,kind})});refreshProviders()}
|
|
176
|
+
async function disconnect(id){await api("/admin/providers/"+id,{method:"DELETE"});refreshProviders()}
|
|
177
|
+
|
|
178
|
+
async function refreshTokens(){const d=await(await api("/admin/tokens")).json();
|
|
179
|
+
document.getElementById("toks").innerHTML=d.tokens.map(t=>`<tr><td class="mono">${t.id}</td><td>${t.label||""}</td><td class="mono">${t.masked||""}</td><td class="mono">${(t.scope||[]).join(", ")}</td><td>${t.revoked?'<span class="pill no">revoked</span>':'<span class="pill ok">active</span>'}</td><td>${t.revoked?"":`<button class="danger sm" onclick="revokeTok('${t.id}')">revoke</button>`}</td></tr>`).join("")}
|
|
180
|
+
async function mintToken(){const body={label:val("tk_label")||"machine",scope:(val("tk_scope")||"*").split(",").map(s=>s.trim()),ttl_seconds:parseInt(val("tk_ttl"))||null};
|
|
181
|
+
const d=await(await api("/admin/tokens",{method:"POST",body:JSON.stringify(body)})).json();
|
|
182
|
+
const el=document.getElementById("tk_out");el.classList.remove("hide");el.innerHTML=`<b>Token (shown once):</b><br/><span class="mono">${d.token}</span>`;refreshTokens()}
|
|
183
|
+
async function revokeTok(id){await api("/admin/tokens/"+id,{method:"DELETE"});refreshTokens()}
|
|
184
|
+
|
|
185
|
+
async function refreshAliases(){const m=(await(await api("/admin/aliases")).json()).map;
|
|
186
|
+
document.getElementById("aliases").innerHTML=Object.entries(m).map(([k,v])=>`<tr><td class="mono">${k}</td><td class="mono">${v}</td><td><button class="danger sm" onclick="rmAlias('${k}')">remove</button></td></tr>`).join("")||'<tr><td class="muted" colspan="3">No model routes.</td></tr>'}
|
|
187
|
+
async function setAlias(){const m=(await(await api("/admin/aliases")).json()).map;m[val("al_match")||"*"]=val("al_target")||"mock";await api("/admin/aliases",{method:"PUT",body:JSON.stringify({map:m})});document.getElementById("al_match").value="";document.getElementById("al_target").value="";refreshAliases()}
|
|
188
|
+
async function rmAlias(k){const m=(await(await api("/admin/aliases")).json()).map;delete m[k];await api("/admin/aliases",{method:"PUT",body:JSON.stringify({map:m})});refreshAliases()}
|
|
189
|
+
|
|
190
|
+
async function refreshLogs(){const d=await(await api("/admin/logs?limit=80")).json();
|
|
191
|
+
document.getElementById("logs").innerHTML=d.logs.map(g=>`<tr><td class="mono">${new Date(g.ts_ms).toLocaleTimeString()}</td><td>${g.token_label||""}</td><td>${g.provider||""}</td><td class="mono">${(g.model||"").slice(0,24)}</td><td>${g.status||""}</td><td>${g.prompt_tokens??"-"}</td><td>${g.completion_tokens??"-"}</td><td>${g.cost_usd?"$"+g.cost_usd.toFixed(4):"-"}</td><td>${g.latency_ms||""}</td></tr>`).join("")}
|
|
192
|
+
|
|
193
|
+
if(A())boot();
|
|
194
|
+
setInterval(()=>{if(A()&&!document.getElementById("app").classList.contains("hide")){refreshLogs()}},4000);
|
|
195
|
+
</script>
|
|
196
|
+
</body>
|
|
197
|
+
</html>
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "proxyagent"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
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"
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="utf-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
-
<title>proxyagent</title>
|
|
7
|
-
<style>
|
|
8
|
-
:root { --bg:#0b0c0e; --panel:#15171a; --line:#23262b; --txt:#e7e9ec; --dim:#8a9099; --grn:#34d39e; --red:#f87171; --yel:#fbbf24; }
|
|
9
|
-
* { box-sizing:border-box; } body { margin:0; background:var(--bg); color:var(--txt); font:14px/1.5 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto; }
|
|
10
|
-
a { color:var(--grn); } code,.mono { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
|
|
11
|
-
header { display:flex; align-items:center; justify-content:space-between; padding:18px 28px; border-bottom:1px solid var(--line); }
|
|
12
|
-
.brand { display:flex; align-items:center; gap:10px; font-weight:600; font-size:16px; }
|
|
13
|
-
.dot { width:9px; height:9px; border-radius:50%; background:var(--grn); box-shadow:0 0 10px 1px rgba(52,211,158,.5); }
|
|
14
|
-
main { max-width:1100px; margin:0 auto; padding:28px; }
|
|
15
|
-
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:14px; margin-bottom:26px; }
|
|
16
|
-
.card { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:18px; }
|
|
17
|
-
.stat .n { font-size:30px; font-weight:600; } .stat .l { color:var(--dim); font-size:11px; text-transform:uppercase; letter-spacing:.08em; margin-top:4px; }
|
|
18
|
-
h2 { font-size:13px; text-transform:uppercase; letter-spacing:.08em; color:var(--dim); margin:26px 0 12px; }
|
|
19
|
-
table { width:100%; border-collapse:collapse; } th,td { text-align:left; padding:9px 10px; border-bottom:1px solid var(--line); font-size:13px; }
|
|
20
|
-
th { color:var(--dim); font-weight:500; font-size:11px; text-transform:uppercase; letter-spacing:.06em; }
|
|
21
|
-
input,button,select { font:inherit; border-radius:9px; border:1px solid var(--line); background:#0e1013; color:var(--txt); padding:8px 11px; }
|
|
22
|
-
button { background:var(--grn); color:#04130d; border:none; font-weight:600; cursor:pointer; } button.ghost { background:transparent; color:var(--dim); border:1px solid var(--line); }
|
|
23
|
-
button:hover { filter:brightness(1.08); } .row { display:flex; gap:9px; flex-wrap:wrap; align-items:center; }
|
|
24
|
-
.pill { padding:2px 9px; border-radius:99px; font-size:11px; } .pill.ok { background:rgba(52,211,158,.12); color:var(--grn); } .pill.no { background:rgba(248,113,113,.12); color:var(--red); }
|
|
25
|
-
.gate { max-width:420px; margin:90px auto; text-align:center; } .gate input { width:100%; margin:14px 0; }
|
|
26
|
-
.tok { background:#0e1013; border:1px dashed var(--grn); padding:12px; border-radius:10px; word-break:break-all; margin-top:12px; }
|
|
27
|
-
.hide { display:none; }
|
|
28
|
-
</style>
|
|
29
|
-
</head>
|
|
30
|
-
<body>
|
|
31
|
-
<div id="gate" class="gate">
|
|
32
|
-
<div class="brand" style="justify-content:center"><span class="dot"></span> proxyagent</div>
|
|
33
|
-
<p style="color:var(--dim)">Paste your admin token (printed by <code>proxyagent serve</code>).</p>
|
|
34
|
-
<input id="admintok" type="password" placeholder="pa_admin_…" />
|
|
35
|
-
<button onclick="saveAdmin()">Open dashboard</button>
|
|
36
|
-
<p id="gateerr" style="color:var(--red)"></p>
|
|
37
|
-
</div>
|
|
38
|
-
|
|
39
|
-
<div id="app" class="hide">
|
|
40
|
-
<header>
|
|
41
|
-
<div class="brand"><span class="dot"></span> proxyagent</div>
|
|
42
|
-
<div class="row"><span id="provs" style="color:var(--dim)"></span><button class="ghost" onclick="logout()">Sign out</button></div>
|
|
43
|
-
</header>
|
|
44
|
-
<main>
|
|
45
|
-
<div class="grid">
|
|
46
|
-
<div class="card stat"><div class="n" id="s_req">0</div><div class="l">Requests</div></div>
|
|
47
|
-
<div class="card stat"><div class="n" id="s_in">0</div><div class="l">Input tokens</div></div>
|
|
48
|
-
<div class="card stat"><div class="n" id="s_out">0</div><div class="l">Output tokens</div></div>
|
|
49
|
-
<div class="card stat"><div class="n" id="s_cost" style="color:var(--grn)">$0</div><div class="l">Cost</div></div>
|
|
50
|
-
<div class="card stat"><div class="n" id="s_tools">0</div><div class="l">Proxied tools</div></div>
|
|
51
|
-
</div>
|
|
52
|
-
|
|
53
|
-
<h2>Mint a machine token</h2>
|
|
54
|
-
<div class="card">
|
|
55
|
-
<div class="row">
|
|
56
|
-
<input id="t_label" placeholder="label (e.g. macbook-01)" />
|
|
57
|
-
<input id="t_scope" placeholder="scope: * or anthropic:claude-*" style="flex:1" />
|
|
58
|
-
<input id="t_ttl" type="number" placeholder="ttl (s)" style="width:110px" />
|
|
59
|
-
<button onclick="mint()">Mint token</button>
|
|
60
|
-
</div>
|
|
61
|
-
<div id="minted" class="tok hide"></div>
|
|
62
|
-
<p style="color:var(--dim);margin:10px 0 0">The machine holds only this token — never a real key. Revoke anytime.</p>
|
|
63
|
-
</div>
|
|
64
|
-
|
|
65
|
-
<h2>Machine tokens</h2>
|
|
66
|
-
<div class="card"><table><thead><tr><th>ID</th><th>Label</th><th>Token</th><th>Scope</th><th>Status</th><th></th></tr></thead><tbody id="toks"></tbody></table></div>
|
|
67
|
-
|
|
68
|
-
<h2>Recent requests <span style="color:var(--dim);font-weight:400;text-transform:none;letter-spacing:0">· live</span></h2>
|
|
69
|
-
<div class="card"><table><thead><tr><th>Time</th><th>Token</th><th>Provider</th><th>Model</th><th>Status</th><th>In</th><th>Out</th><th>ms</th></tr></thead><tbody id="logs"></tbody></table></div>
|
|
70
|
-
</main>
|
|
71
|
-
</div>
|
|
72
|
-
|
|
73
|
-
<script>
|
|
74
|
-
const A = () => localStorage.getItem("pa_admin");
|
|
75
|
-
const H = () => ({ "x-admin-token": A(), "content-type": "application/json" });
|
|
76
|
-
async function api(path, opts={}) { const r = await fetch(path, { ...opts, headers: { ...H(), ...(opts.headers||{}) } }); if (r.status===401) { logout(); throw new Error("unauthorized"); } return r; }
|
|
77
|
-
|
|
78
|
-
function saveAdmin() { const v = document.getElementById("admintok").value.trim(); if (!v) return; localStorage.setItem("pa_admin", v); boot(); }
|
|
79
|
-
function logout() { localStorage.removeItem("pa_admin"); document.getElementById("app").classList.add("hide"); document.getElementById("gate").classList.remove("hide"); }
|
|
80
|
-
|
|
81
|
-
async function boot() {
|
|
82
|
-
try {
|
|
83
|
-
const u = await (await api("/admin/usage")).json();
|
|
84
|
-
document.getElementById("gate").classList.add("hide");
|
|
85
|
-
document.getElementById("app").classList.remove("hide");
|
|
86
|
-
document.getElementById("s_req").textContent = u.usage.requests;
|
|
87
|
-
document.getElementById("s_in").textContent = u.usage.prompt_tokens;
|
|
88
|
-
document.getElementById("s_out").textContent = u.usage.completion_tokens;
|
|
89
|
-
document.getElementById("s_cost").textContent = "$" + (u.usage.cost_usd || 0).toFixed(4);
|
|
90
|
-
document.getElementById("s_tools").textContent = (u.tools||[]).length;
|
|
91
|
-
document.getElementById("provs").textContent = `${u.backend||"sqlite"} · providers: ` + ((u.providers||[]).join(", ") || "none");
|
|
92
|
-
refreshTokens(); refreshLogs();
|
|
93
|
-
} catch (e) { document.getElementById("gateerr").textContent = "Invalid admin token."; }
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async function refreshTokens() {
|
|
97
|
-
const d = await (await api("/admin/tokens")).json();
|
|
98
|
-
document.getElementById("toks").innerHTML = d.tokens.map(t => `
|
|
99
|
-
<tr><td class="mono">${t.id}</td><td>${t.label||""}</td><td class="mono">${t.masked||""}</td>
|
|
100
|
-
<td class="mono">${(t.scope||[]).join(", ")}</td>
|
|
101
|
-
<td>${t.revoked?'<span class="pill no">revoked</span>':'<span class="pill ok">active</span>'}</td>
|
|
102
|
-
<td>${t.revoked?"":`<button class="ghost" onclick="revoke('${t.id}')">revoke</button>`}</td></tr>`).join("");
|
|
103
|
-
}
|
|
104
|
-
async function refreshLogs() {
|
|
105
|
-
const d = await (await api("/admin/logs?limit=60")).json();
|
|
106
|
-
document.getElementById("logs").innerHTML = d.logs.map(g => `
|
|
107
|
-
<tr><td class="mono">${new Date(g.ts_ms).toLocaleTimeString()}</td><td>${g.token_label||""}</td>
|
|
108
|
-
<td>${g.provider||""}</td><td class="mono">${(g.model||"").slice(0,26)}</td>
|
|
109
|
-
<td>${g.status||""}</td><td>${g.prompt_tokens??"-"}</td><td>${g.completion_tokens??"-"}</td><td>${g.latency_ms||""}</td></tr>`).join("");
|
|
110
|
-
}
|
|
111
|
-
async function mint() {
|
|
112
|
-
const body = { label: val("t_label")||"machine", scope: (val("t_scope")||"*").split(",").map(s=>s.trim()), ttl_seconds: parseInt(val("t_ttl"))||null };
|
|
113
|
-
const d = await (await api("/admin/tokens", { method:"POST", body: JSON.stringify(body) })).json();
|
|
114
|
-
const el = document.getElementById("minted"); el.classList.remove("hide");
|
|
115
|
-
el.innerHTML = `<b>Token (shown once):</b><br/><span class="mono">${d.token}</span>`;
|
|
116
|
-
refreshTokens(); boot();
|
|
117
|
-
}
|
|
118
|
-
async function revoke(id) { await api("/admin/tokens/"+id, { method:"DELETE" }); refreshTokens(); }
|
|
119
|
-
function val(id){ return document.getElementById(id).value.trim(); }
|
|
120
|
-
|
|
121
|
-
if (A()) boot();
|
|
122
|
-
setInterval(() => { if (A() && !document.getElementById("app").classList.contains("hide")) { refreshLogs(); } }, 4000);
|
|
123
|
-
</script>
|
|
124
|
-
</body>
|
|
125
|
-
</html>
|
|
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
|