proxyagent 0.3.1__tar.gz → 0.5.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.1
3
+ Version: 0.5.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>
@@ -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
@@ -162,6 +168,27 @@ proxyagent.run("claude-code", goal="build the app",
162
168
  proxy="https://proxy.you.com", token=token)
163
169
  ```
164
170
 
171
+ ## Harnesses & auth modes
172
+ You run an **agent harness**, and each one can authenticate several ways. The proxy's job
173
+ is to centralise *all* of them so the machine running the harness holds only a `pa_` token:
174
+
175
+ | Harness | Provider | Auth modes |
176
+ |---|---|---|
177
+ | **Claude Code** | Anthropic | API key · OAuth (subscription) · AWS Bedrock · Google Vertex |
178
+ | **Codex** | OpenAI | API key · OAuth (ChatGPT) · Azure |
179
+ | **Gemini CLI** | Google | API key · OAuth · Vertex |
180
+
181
+ Connect each mode once in the dashboard's **Harnesses** tab. API-key mode is wired today;
182
+ Bedrock / Vertex / OAuth-refresh (the cloud-credential paths enterprises actually use) are
183
+ being built out — the proxy holds the AWS/GCP creds and signs upstream, so the machine needs
184
+ none. The model providers below are the *backends* for model-agnostic harnesses (aider, Cline…).
185
+
186
+ ## Per-token budgets
187
+ Cap what any token can spend; once its summed cost crosses the cap, the proxy returns **402**.
188
+ ```bash
189
+ proxyagent token new ci --budget 5.00 # this token may spend at most $5
190
+ ```
191
+
165
192
  ## Supported providers
166
193
  `anthropic` · `openai` · `gemini` · `groq` · `openrouter` · `mistral` · `deepseek` ·
167
194
  `xai` · `together` — Anthropic uses its Messages API; the rest are OpenAI-compatible.
@@ -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
@@ -130,6 +136,27 @@ proxyagent.run("claude-code", goal="build the app",
130
136
  proxy="https://proxy.you.com", token=token)
131
137
  ```
132
138
 
139
+ ## Harnesses & auth modes
140
+ You run an **agent harness**, and each one can authenticate several ways. The proxy's job
141
+ is to centralise *all* of them so the machine running the harness holds only a `pa_` token:
142
+
143
+ | Harness | Provider | Auth modes |
144
+ |---|---|---|
145
+ | **Claude Code** | Anthropic | API key · OAuth (subscription) · AWS Bedrock · Google Vertex |
146
+ | **Codex** | OpenAI | API key · OAuth (ChatGPT) · Azure |
147
+ | **Gemini CLI** | Google | API key · OAuth · Vertex |
148
+
149
+ Connect each mode once in the dashboard's **Harnesses** tab. API-key mode is wired today;
150
+ Bedrock / Vertex / OAuth-refresh (the cloud-credential paths enterprises actually use) are
151
+ being built out — the proxy holds the AWS/GCP creds and signs upstream, so the machine needs
152
+ none. The model providers below are the *backends* for model-agnostic harnesses (aider, Cline…).
153
+
154
+ ## Per-token budgets
155
+ Cap what any token can spend; once its summed cost crosses the cap, the proxy returns **402**.
156
+ ```bash
157
+ proxyagent token new ci --budget 5.00 # this token may spend at most $5
158
+ ```
159
+
133
160
  ## Supported providers
134
161
  `anthropic` · `openai` · `gemini` · `groq` · `openrouter` · `mistral` · `deepseek` ·
135
162
  `xai` · `together` — Anthropic uses its Messages API; the rest are OpenAI-compatible.
@@ -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.1"
19
+ __version__ = "0.5.0"
20
20
  __all__ = ["run", "serve", "create_app", "Config", "Admin", "__version__"]
21
21
 
22
22
 
@@ -219,16 +219,19 @@ def token_new(
219
219
  scope: list[str] = typer.Option(["*"], "--scope", help="Allowed provider:model globs, e.g. anthropic:claude-*"),
220
220
  ttl: Optional[int] = typer.Option(None, "--ttl", help="Seconds until expiry."),
221
221
  rate: int = typer.Option(0, "--rate", help="Max requests/min (0 = unlimited)."),
222
+ budget: Optional[float] = typer.Option(None, "--budget", help="Max $ this token may spend."),
222
223
  proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
223
224
  admin: Optional[str] = typer.Option(None, "--admin"),
224
225
  ):
225
226
  """Mint a machine token — give it to a remote machine; it holds no real key."""
226
227
  if not _is_remote(proxy, admin):
227
- plain, _ = _local_store().create_token(label, list(scope), ttl_seconds=ttl, rate_limit=rate)
228
+ plain, _ = _local_store().create_token(label, list(scope), ttl_seconds=ttl,
229
+ rate_limit=rate, budget_usd=budget)
228
230
  else:
229
231
  with _admin_client(proxy, admin) as c:
230
232
  r = c.post("/admin/tokens", json={"label": label, "scope": list(scope),
231
- "ttl_seconds": ttl, "rate_limit": rate})
233
+ "ttl_seconds": ttl, "rate_limit": rate,
234
+ "budget_usd": budget})
232
235
  if r.status_code >= 400:
233
236
  err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
234
237
  plain = r.json()["token"]
@@ -59,6 +59,51 @@ 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
+
86
+ # Agent harnesses (what you actually RUN) and the auth modes each supports. The model
87
+ # providers above are the *backends*; these are the agents. Auth mode availability is
88
+ # what makes the proxy valuable — it can centralise all of them so the machine holds none.
89
+ HARNESSES: dict[str, dict] = {
90
+ "claude-code": {"label": "Claude Code", "provider": "anthropic", "color": "#D97757",
91
+ "install": "npm i -g @anthropic-ai/claude-code",
92
+ "auth": ["api_key", "oauth", "bedrock", "vertex"]},
93
+ "codex": {"label": "Codex", "provider": "openai", "color": "#10A37F",
94
+ "install": "npm i -g @openai/codex",
95
+ "auth": ["api_key", "oauth", "azure"]},
96
+ "gemini-cli": {"label": "Gemini CLI", "provider": "gemini", "color": "#4285F4",
97
+ "install": "npm i -g @google/gemini-cli",
98
+ "auth": ["api_key", "oauth", "vertex"]},
99
+ }
100
+ AUTH_LABELS = {"api_key": "API key", "oauth": "OAuth", "bedrock": "AWS Bedrock",
101
+ "vertex": "Google Vertex", "azure": "Azure"}
102
+ # Auth modes that are fully wired today (just a key swap). Others are surfaced in the
103
+ # UI as "available" and built out (Bedrock SigV4 / Vertex token / OAuth refresh).
104
+ AUTH_READY = {"api_key"}
105
+
106
+
62
107
  @dataclass
63
108
  class Config:
64
109
  home: Path = HOME
@@ -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 AUTH_LABELS, AUTH_READY, CATALOG, HARNESSES, 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
@@ -29,6 +29,7 @@ class TokenBody(BaseModel):
29
29
  scope: list[str] = ["*"]
30
30
  ttl_seconds: int | None = None
31
31
  rate_limit: int = 0
32
+ budget_usd: float | None = None
32
33
 
33
34
 
34
35
  class ProviderBody(BaseModel):
@@ -66,6 +67,9 @@ def create_app(config: Config | None = None) -> FastAPI:
66
67
  raise HTTPException(401, "token expired")
67
68
  if row["rate_limit"] and store.recent_request_count(row["id"]) >= row["rate_limit"]:
68
69
  raise HTTPException(429, "rate limit exceeded")
70
+ budget = row.get("budget_usd")
71
+ if budget is not None and store.token_spend(row["id"]) >= budget:
72
+ raise HTTPException(402, f"token budget of ${budget:.4f} exhausted")
69
73
  store.touch_token(row["id"])
70
74
  return row
71
75
 
@@ -147,10 +151,10 @@ def create_app(config: Config | None = None) -> FastAPI:
147
151
  async def create_token(body: TokenBody, authorization: str | None = Header(None),
148
152
  x_admin_token: str | None = Header(None)):
149
153
  require_admin(authorization, x_admin_token)
150
- plain, row = store.create_token(body.label, body.scope,
151
- ttl_seconds=body.ttl_seconds, rate_limit=body.rate_limit)
154
+ plain, row = store.create_token(body.label, body.scope, ttl_seconds=body.ttl_seconds,
155
+ rate_limit=body.rate_limit, budget_usd=body.budget_usd)
152
156
  return {"token": plain, "id": row["id"], "label": row["label"],
153
- "scope": body.scope, "note": "shown once — store it now"}
157
+ "scope": body.scope, "budget_usd": body.budget_usd, "note": "shown once — store it now"}
154
158
 
155
159
  @app.get("/admin/tokens")
156
160
  async def list_tokens_ep(authorization: str | None = Header(None),
@@ -161,7 +165,8 @@ def create_app(config: Config | None = None) -> FastAPI:
161
165
  out.append({"id": t["id"], "label": t["label"], "masked": t["masked"],
162
166
  "scope": _json.loads(t["scope_json"]), "revoked": bool(t["revoked"]),
163
167
  "rate_limit": t["rate_limit"], "expires_ms": t["expires_ms"],
164
- "last_used_ms": t["last_used_ms"]})
168
+ "last_used_ms": t["last_used_ms"], "budget_usd": t.get("budget_usd"),
169
+ "spent_usd": round(store.token_spend(t["id"]), 6)})
165
170
  return {"tokens": out}
166
171
 
167
172
  @app.delete("/admin/tokens/{tid}")
@@ -218,6 +223,46 @@ def create_app(config: Config | None = None) -> FastAPI:
218
223
  raise HTTPException(404, "no such credential")
219
224
  return {"ok": True}
220
225
 
226
+ @app.get("/admin/harnesses")
227
+ async def harnesses(authorization: str | None = Header(None),
228
+ x_admin_token: str | None = Header(None)):
229
+ require_admin(authorization, x_admin_token)
230
+ stored = {c["provider"] for c in store.list_credentials() if c["active"]}
231
+ out = []
232
+ for name, h in HARNESSES.items():
233
+ prov = PROVIDERS.get(h["provider"])
234
+ configured = bool(prov and prov.key) or h["provider"] in stored
235
+ out.append({
236
+ "name": name, "label": h["label"], "provider": h["provider"],
237
+ "color": h["color"], "install": h["install"],
238
+ "auth": [{"mode": m, "label": AUTH_LABELS.get(m, m),
239
+ "ready": m in AUTH_READY,
240
+ "connected": m == "api_key" and configured}
241
+ for m in h["auth"]],
242
+ "configured": configured,
243
+ })
244
+ return {"harnesses": out}
245
+
246
+ @app.get("/admin/catalog")
247
+ async def catalog(authorization: str | None = Header(None),
248
+ x_admin_token: str | None = Header(None)):
249
+ require_admin(authorization, x_admin_token)
250
+ stored = {c["provider"]: c for c in store.list_credentials() if c["active"]}
251
+ out = []
252
+ for name, prov in PROVIDERS.items():
253
+ meta = CATALOG.get(name, {})
254
+ cred = stored.get(name)
255
+ out.append({
256
+ "name": name, "label": meta.get("label", name.title()),
257
+ "kinds": meta.get("kinds", ["api_key"]), "color": meta.get("color", "#888"),
258
+ "models": meta.get("models", []), "shape": prov.shape,
259
+ "via_env": bool(prov.key), "via_store": bool(cred),
260
+ "cred_id": cred["id"] if cred else None,
261
+ "cred_kind": cred["kind"] if cred else None,
262
+ "endpoint": prov.endpoint,
263
+ })
264
+ return {"providers": out, "encryption": crypto.encryption_available()}
265
+
221
266
  # -- model aliases / remap -------------------------------------------- #
222
267
  @app.get("/admin/aliases")
223
268
  async def get_aliases(authorization: str | None = Header(None),
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS proxy_agent_tokens (
22
22
  id TEXT PRIMARY KEY, hash TEXT NOT NULL UNIQUE, label TEXT,
23
23
  scope_json TEXT NOT NULL DEFAULT '["*"]', rate_limit INTEGER NOT NULL DEFAULT 0,
24
24
  created_ms BIGINT, expires_ms BIGINT, revoked INTEGER NOT NULL DEFAULT 0,
25
- last_used_ms BIGINT, masked TEXT
25
+ last_used_ms BIGINT, masked TEXT, budget_usd DOUBLE PRECISION
26
26
  );
27
27
  CREATE TABLE IF NOT EXISTS proxy_agent_keys (
28
28
  id TEXT PRIMARY KEY, provider TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'api_key',
@@ -47,22 +47,32 @@ class Store:
47
47
  self.db = DB(str(path), url=url)
48
48
  self.db.executescript(_SCHEMA)
49
49
  self.backend = "postgres" if self.db.pg else "sqlite"
50
+ # migrate older DBs created before budget_usd existed
51
+ try:
52
+ self.db.execute("ALTER TABLE proxy_agent_tokens ADD COLUMN budget_usd DOUBLE PRECISION")
53
+ except Exception:
54
+ pass
50
55
 
51
56
  # -- machine tokens ---------------------------------------------------- #
52
57
 
53
- def create_token(self, label, scope, *, ttl_seconds=None, rate_limit=0):
58
+ def create_token(self, label, scope, *, ttl_seconds=None, rate_limit=0, budget_usd=None):
54
59
  plain = new_token()
55
60
  tid = "tok_" + uuid.uuid4().hex[:12]
56
61
  expires = now_ms() + ttl_seconds * 1000 if ttl_seconds else None
57
62
  self.db.execute(
58
63
  """INSERT INTO proxy_agent_tokens
59
- (id, hash, label, scope_json, rate_limit, created_ms, expires_ms, masked)
60
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
64
+ (id, hash, label, scope_json, rate_limit, created_ms, expires_ms, masked, budget_usd)
65
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
61
66
  (tid, hash_token(plain), label, json.dumps(scope), rate_limit, now_ms(),
62
- expires, mask(plain)),
67
+ expires, mask(plain), budget_usd),
63
68
  )
64
69
  return plain, self.get_token(tid)
65
70
 
71
+ def token_spend(self, token_id: str) -> float:
72
+ r = self.db.fetchone(
73
+ "SELECT COALESCE(SUM(cost_usd),0) s FROM proxy_agent_calls WHERE token_id=?", (token_id,))
74
+ return float((r or {}).get("s", 0) or 0)
75
+
66
76
  def get_token(self, tid):
67
77
  return self.db.fetchone("SELECT * FROM proxy_agent_tokens WHERE id=?", (tid,))
68
78
 
@@ -88,10 +98,12 @@ class Store:
88
98
  # -- provider credentials (proxy_agent_keys) --------------------------- #
89
99
 
90
100
  def add_credential(self, provider, secret, *, kind="api_key", refresh=None,
91
- expires_ms=None, label=None, meta=None):
101
+ expires_ms=None, label=None, meta=None, replace=True):
92
102
  cid = "key_" + uuid.uuid4().hex[:12]
93
- # one active credential per provider: deactivate older ones
94
- self.db.execute("UPDATE proxy_agent_keys SET active=0 WHERE provider=?", (provider,))
103
+ # replace=True (default, the UI "connect"): swap the key. replace=False adds an
104
+ # ADDITIONAL active key to the provider's pool for failover/rotation.
105
+ if replace:
106
+ self.db.execute("UPDATE proxy_agent_keys SET active=0 WHERE provider=?", (provider,))
95
107
  self.db.execute(
96
108
  """INSERT INTO proxy_agent_keys
97
109
  (id, provider, kind, secret, refresh, expires_ms, label, created_ms, meta_json, active)
@@ -0,0 +1,224 @@
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="harnesses" onclick="tab('harnesses')">Harnesses</div>
74
+ <div class="tab" data-t="providers" onclick="tab('providers')">Model endpoints</div>
75
+ <div class="tab" data-t="tokens" onclick="tab('tokens')">Machine tokens</div>
76
+ <div class="tab" data-t="models" onclick="tab('models')">Model routing</div>
77
+ <div class="tab" data-t="activity" onclick="tab('activity')">Activity</div>
78
+ </div>
79
+
80
+ <section id="t_harnesses">
81
+ <p class="muted" style="margin:0 0 14px">The agents you run. Connect each auth method once — the machine running the harness then holds only a <code>pa_</code> token.</p>
82
+ <div class="grid" id="harngrid"></div>
83
+ </section>
84
+
85
+ <section id="t_providers" class="hide">
86
+ <p class="muted" style="margin:0 0 14px">Raw model backends for model-agnostic harnesses (aider, Cline, Codex pointed elsewhere…).</p>
87
+ <div class="grid" id="provgrid"></div>
88
+ </section>
89
+
90
+ <section id="t_tokens" class="hide">
91
+ <div class="card" style="margin-bottom:16px">
92
+ <div class="h"><h2>Mint a machine token</h2></div>
93
+ <div class="row">
94
+ <input id="tk_label" placeholder="label (e.g. macbook-01)"/>
95
+ <input id="tk_scope" placeholder="scope: * or anthropic:claude-*" style="flex:1"/>
96
+ <input id="tk_ttl" type="number" placeholder="ttl (s)" style="width:100px"/>
97
+ <input id="tk_budget" type="number" step="0.01" placeholder="budget $" style="width:100px"/>
98
+ <button onclick="mintToken()">Mint</button>
99
+ </div>
100
+ <div id="tk_out" class="tok hide"></div>
101
+ </div>
102
+ <div class="card"><table><thead><tr><th>ID</th><th>Label</th><th>Token</th><th>Scope</th><th>Budget</th><th>Status</th><th></th></tr></thead><tbody id="toks"></tbody></table></div>
103
+ </section>
104
+
105
+ <section id="t_models" class="hide">
106
+ <div class="card" style="margin-bottom:16px">
107
+ <div class="h"><h2>Remap a model</h2></div>
108
+ <div class="row">
109
+ <input id="al_match" placeholder="match: * or gpt-4o"/>
110
+ <span class="muted">→</span>
111
+ <input id="al_target" placeholder="target: mock or anthropic:claude-sonnet-4-5" style="flex:1"/>
112
+ <button onclick="setAlias()">Map</button>
113
+ </div>
114
+ <p class="muted" style="margin:10px 0 0">Tip: map <code>* → mock</code> to run any agent fully offline (no keys).</p>
115
+ </div>
116
+ <div class="card"><table><thead><tr><th>Match</th><th>→ Target</th><th></th></tr></thead><tbody id="aliases"></tbody></table></div>
117
+ </section>
118
+
119
+ <section id="t_activity" class="hide">
120
+ <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>
121
+ </section>
122
+ </main>
123
+ </div>
124
+
125
+ <script>
126
+ const A=()=>localStorage.getItem("pa_admin");
127
+ const H=()=>({"x-admin-token":A(),"content-type":"application/json"});
128
+ 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}
129
+ function val(id){return document.getElementById(id).value.trim()}
130
+ function saveAdmin(){const v=document.getElementById("admintok").value.trim();if(v){localStorage.setItem("pa_admin",v);boot()}}
131
+ function logout(){localStorage.removeItem("pa_admin");document.getElementById("app").classList.add("hide");document.getElementById("gate").classList.remove("hide")}
132
+ function tab(t){document.querySelectorAll(".tab").forEach(e=>e.classList.toggle("on",e.dataset.t===t));["harnesses","providers","tokens","models","activity"].forEach(s=>document.getElementById("t_"+s).classList.toggle("hide",s!==t))}
133
+
134
+ // ---- provider logos (inline, brand-tinted) ----
135
+ const MARK={
136
+ 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"/>',
137
+ 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"/>',
138
+ 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"/>',
139
+ 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"/>',
140
+ 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"/>',
141
+ 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>',
142
+ 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"/>',
143
+ 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"/>',
144
+ 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"/>',
145
+ };
146
+ 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>`;
147
+ 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>`}
148
+ 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})`}
149
+
150
+ async function boot(){
151
+ try{
152
+ const u=await(await api("/admin/usage")).json();
153
+ document.getElementById("gate").classList.add("hide");document.getElementById("app").classList.remove("hide");
154
+ document.getElementById("s_req").textContent=u.usage.requests;
155
+ document.getElementById("s_tok").textContent=`${u.usage.prompt_tokens}/${u.usage.completion_tokens}`;
156
+ document.getElementById("s_cost").textContent="$"+(u.usage.cost_usd||0).toFixed(4);
157
+ document.getElementById("badge_backend").textContent=u.backend;
158
+ document.getElementById("enc").textContent=u.encryption?"🔒 encrypted at rest":"⚠ encryption off";
159
+ renderHarnesses();refreshProviders();refreshTokens();refreshAliases();refreshLogs();
160
+ }catch(e){document.getElementById("gateerr").textContent="Invalid admin token."}
161
+ }
162
+ async function renderHarnesses(){
163
+ const d=await(await api("/admin/harnesses")).json();
164
+ document.getElementById("harngrid").innerHTML=d.harnesses.map(h=>{
165
+ const chips=h.auth.map(a=>{
166
+ const cls=a.connected?"badge on":(a.ready?"badge":"badge");
167
+ const tag=a.connected?"✓ connected":(a.ready?"connect":"soon");
168
+ return `<span class="${cls}" title="${a.ready?'available now':'coming soon'}">${a.label} · ${tag}</span>`}).join("");
169
+ return `<div class="prov"><div class="top">${logo(h.provider,h.color)}<div style="flex:1"><h3>${h.label}</h3><div class="ep">runs on ${h.provider}</div></div>
170
+ ${h.configured?'<span class="pill ok">ready</span>':'<span class="pill no">connect a key</span>'}</div>
171
+ <div class="badges">${chips}</div>
172
+ <div class="models mono" style="font-size:11px">${h.install}</div>
173
+ <div class="row"><button class="sm" onclick="openConnect('h_${h.name}')">Connect API key</button></div>
174
+ <div class="connect" id="c_h_${h.name}">
175
+ <input id="k_h_${h.name}" type="password" placeholder="${h.provider} API key"/>
176
+ <div class="row"><button class="sm" onclick="connectHarness('${h.name}','${h.provider}')">Save</button><button class="ghost sm" onclick="openConnect('h_${h.name}')">Cancel</button></div>
177
+ </div></div>`}).join("");
178
+ }
179
+ async function connectHarness(name,provider){const key=document.getElementById("k_h_"+name).value.trim();if(!key)return;
180
+ await api("/admin/providers",{method:"POST",body:JSON.stringify({provider,secret:key,kind:"api_key"})});renderHarnesses();refreshProviders()}
181
+ async function refreshProviders(){
182
+ const d=await(await api("/admin/catalog")).json();const g=document.getElementById("provgrid");
183
+ let connected=0;
184
+ g.innerHTML=d.providers.map(p=>{const on=p.via_env||p.via_store;if(on)connected++;
185
+ const how=p.via_store?`stored ${p.cred_kind}`:(p.via_env?"env":"");
186
+ 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>
187
+ ${on?'<span class="pill ok">on</span>':'<span class="pill no">off</span>'}</div>
188
+ <div class="badges">${p.kinds.map(k=>`<span class="badge">${k}</span>`).join("")}${on?`<span class="badge on">${how}</span>`:""}</div>
189
+ <div class="models">${(p.models||[]).slice(0,2).join(" · ")}</div>
190
+ <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>
191
+ <div class="connect" id="c_${p.name}">
192
+ <input id="k_${p.name}" type="password" placeholder="${p.name} API key${p.kinds.includes('oauth')?' / OAuth token':''}"/>
193
+ <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"/>`}
194
+ <button class="sm" onclick="connect('${p.name}')">Save</button><button class="ghost sm" onclick="openConnect('${p.name}')">Cancel</button></div>
195
+ </div></div>`}).join("");
196
+ document.getElementById("s_prov").textContent=connected;
197
+ document.getElementById("enc2")&&0;
198
+ }
199
+ function openConnect(n){document.getElementById("c_"+n).classList.toggle("open")}
200
+ async function connect(n){const key=document.getElementById("k_"+n).value.trim();if(!key)return;
201
+ const kindEl=document.getElementById("kind_"+n);const kind=kindEl?kindEl.value:"api_key";
202
+ await api("/admin/providers",{method:"POST",body:JSON.stringify({provider:n,secret:key,kind})});refreshProviders()}
203
+ async function disconnect(id){await api("/admin/providers/"+id,{method:"DELETE"});refreshProviders()}
204
+
205
+ async function refreshTokens(){const d=await(await api("/admin/tokens")).json();
206
+ 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 class="mono">${t.budget_usd?('$'+(t.spent_usd||0).toFixed(4)+' / $'+(+t.budget_usd).toFixed(2)):'—'}</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("")}
207
+ 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,budget_usd:parseFloat(val("tk_budget"))||null};
208
+ const d=await(await api("/admin/tokens",{method:"POST",body:JSON.stringify(body)})).json();
209
+ 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()}
210
+ async function revokeTok(id){await api("/admin/tokens/"+id,{method:"DELETE"});refreshTokens()}
211
+
212
+ async function refreshAliases(){const m=(await(await api("/admin/aliases")).json()).map;
213
+ 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>'}
214
+ 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()}
215
+ 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()}
216
+
217
+ async function refreshLogs(){const d=await(await api("/admin/logs?limit=80")).json();
218
+ 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("")}
219
+
220
+ if(A())boot();
221
+ setInterval(()=>{if(A()&&!document.getElementById("app").classList.contains("hide")){refreshLogs()}},4000);
222
+ </script>
223
+ </body>
224
+ </html>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "proxyagent"
7
- version = "0.3.1"
7
+ version = "0.5.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"
@@ -165,3 +165,26 @@ def test_model_remap_reroutes_provider():
165
165
  _aliases.set_map({"gpt-4o": "anthropic:mock"})
166
166
  assert remap("openai", "gpt-4o") == ("anthropic", "mock")
167
167
  assert remap("openai", "gpt-4o-mini") == ("openai", "gpt-4o-mini") # no match
168
+
169
+
170
+ def test_budget_exhaustion_returns_402():
171
+ c = _client()
172
+ r = c.post("/admin/tokens", headers=ADMIN, json={"scope": ["*"], "budget_usd": 0.001})
173
+ tok, tid = r.json()["token"], r.json()["id"]
174
+ # under budget (mock costs $0) → ok
175
+ assert c.post("/anthropic/v1/messages", headers={"x-api-key": tok},
176
+ json={"model": "mock", "messages": [{"role": "user", "content": "hi"}]}).status_code == 200
177
+ # record spend over the cap, then the next call is blocked
178
+ c.app.state.store.log_request(token_id=tid, provider="anthropic", model="x", status=200, cost_usd=0.05)
179
+ assert c.post("/anthropic/v1/messages", headers={"x-api-key": tok},
180
+ json={"model": "mock", "messages": []}).status_code == 402
181
+
182
+
183
+ def test_harness_catalog():
184
+ c = _client()
185
+ h = c.get("/admin/harnesses", headers=ADMIN).json()["harnesses"]
186
+ names = {x["name"] for x in h}
187
+ assert {"claude-code", "codex", "gemini-cli"} <= names
188
+ cc = next(x for x in h if x["name"] == "claude-code")
189
+ assert {a["mode"] for a in cc["auth"]} == {"api_key", "oauth", "bedrock", "vertex"}
190
+ assert any(a["mode"] == "api_key" and a["ready"] for a in cc["auth"])
@@ -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