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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxyagent
3
- Version: 0.3.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 --admin pa_admin_… # mint a token
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-*" --admin pa_admin_…
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 `/` mint/revoke tokens, watch live usage and a
103
- full request audit log, see configured providers + proxied tools. Paste the admin token to
104
- open it.
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 --admin pa_admin_… # mint a token
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-*" --admin pa_admin_…
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 `/` mint/revoke tokens, watch live usage and a
71
- full request audit log, see configured providers + proxied tools. Paste the admin token to
72
- open it.
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,7 +16,7 @@ from typing import Optional
16
16
 
17
17
  from .harness import run # noqa: F401 (the headline SDK call)
18
18
 
19
- __version__ = "0.3.0"
19
+ __version__ = "0.4.0"
20
20
  __all__ = ["run", "serve", "create_app", "Config", "Admin", "__version__"]
21
21
 
22
22
 
@@ -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] (shown once)\n"
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]Save it — you need it for the dashboard + `proxyagent token`.[/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 — set ANTHROPIC_API_KEY / OPENAI_API_KEY'}")
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
- with _admin_client(proxy, admin) as c:
126
- r = c.post("/admin/providers", json={"provider": provider, "secret": key,
127
- "kind": kind, "label": label})
128
- if r.status_code >= 400:
129
- err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
130
- d = r.json()
131
- note = "[green]encrypted[/green]" if d["stored"] == "encrypted" else "[yellow]plaintext — set PROXYAGENT_SECRET_KEY[/yellow]"
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
- with _admin_client(proxy, admin) as c:
140
- r = c.get("/admin/providers")
141
- if r.status_code >= 400:
142
- err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
143
- d = r.json()
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
- with _admin_client(proxy, admin) as c:
159
- r = c.delete(f"/admin/providers/{cred_id}")
160
- if r.status_code >= 400:
161
- err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
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
- with _admin_client(proxy, admin) as c:
176
- r = c.post("/admin/tokens", json={"label": label, "scope": list(scope),
177
- "ttl_seconds": ttl, "rate_limit": rate})
178
- if r.status_code >= 400:
179
- err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
180
- d = r.json()
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]{d['token']}[/yellow]\n\n"
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
- with _admin_client(proxy, admin) as c:
191
- r = c.get("/admin/tokens")
192
- if r.status_code >= 400:
193
- err.print(f"[red][/red] {r.text}"); raise typer.Exit(1)
194
- rows = r.json()["tokens"]
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
- with _admin_client(proxy, admin) as c:
211
- r = c.delete(f"/admin/tokens/{token_id}")
212
- if r.status_code >= 400:
213
- err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
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 admin_file.exists():
80
- cfg.admin_token_hash = admin_file.read_text().strip()
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
- cfg.admin_token_hash = hash_token(plain)
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.3.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