proxyagent 0.3.1__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.1
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>
@@ -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
@@ -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.1"
19
+ __version__ = "0.4.0"
20
20
  __all__ = ["run", "serve", "create_app", "Config", "Admin", "__version__"]
21
21
 
22
22
 
@@ -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
@@ -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.1"
7
+ version = "0.4.0"
8
8
  description = "Run any agent (Claude, Codex, custom) on any machine — with no API key on the machine. A secure, self-hosted proxy for models and tools."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,125 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>proxyagent</title>
7
- <style>
8
- :root { --bg:#0b0c0e; --panel:#15171a; --line:#23262b; --txt:#e7e9ec; --dim:#8a9099; --grn:#34d39e; --red:#f87171; --yel:#fbbf24; }
9
- * { box-sizing:border-box; } body { margin:0; background:var(--bg); color:var(--txt); font:14px/1.5 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto; }
10
- a { color:var(--grn); } code,.mono { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
11
- header { display:flex; align-items:center; justify-content:space-between; padding:18px 28px; border-bottom:1px solid var(--line); }
12
- .brand { display:flex; align-items:center; gap:10px; font-weight:600; font-size:16px; }
13
- .dot { width:9px; height:9px; border-radius:50%; background:var(--grn); box-shadow:0 0 10px 1px rgba(52,211,158,.5); }
14
- main { max-width:1100px; margin:0 auto; padding:28px; }
15
- .grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:14px; margin-bottom:26px; }
16
- .card { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:18px; }
17
- .stat .n { font-size:30px; font-weight:600; } .stat .l { color:var(--dim); font-size:11px; text-transform:uppercase; letter-spacing:.08em; margin-top:4px; }
18
- h2 { font-size:13px; text-transform:uppercase; letter-spacing:.08em; color:var(--dim); margin:26px 0 12px; }
19
- table { width:100%; border-collapse:collapse; } th,td { text-align:left; padding:9px 10px; border-bottom:1px solid var(--line); font-size:13px; }
20
- th { color:var(--dim); font-weight:500; font-size:11px; text-transform:uppercase; letter-spacing:.06em; }
21
- input,button,select { font:inherit; border-radius:9px; border:1px solid var(--line); background:#0e1013; color:var(--txt); padding:8px 11px; }
22
- button { background:var(--grn); color:#04130d; border:none; font-weight:600; cursor:pointer; } button.ghost { background:transparent; color:var(--dim); border:1px solid var(--line); }
23
- button:hover { filter:brightness(1.08); } .row { display:flex; gap:9px; flex-wrap:wrap; align-items:center; }
24
- .pill { padding:2px 9px; border-radius:99px; font-size:11px; } .pill.ok { background:rgba(52,211,158,.12); color:var(--grn); } .pill.no { background:rgba(248,113,113,.12); color:var(--red); }
25
- .gate { max-width:420px; margin:90px auto; text-align:center; } .gate input { width:100%; margin:14px 0; }
26
- .tok { background:#0e1013; border:1px dashed var(--grn); padding:12px; border-radius:10px; word-break:break-all; margin-top:12px; }
27
- .hide { display:none; }
28
- </style>
29
- </head>
30
- <body>
31
- <div id="gate" class="gate">
32
- <div class="brand" style="justify-content:center"><span class="dot"></span> proxyagent</div>
33
- <p style="color:var(--dim)">Paste your admin token (printed by <code>proxyagent serve</code>).</p>
34
- <input id="admintok" type="password" placeholder="pa_admin_…" />
35
- <button onclick="saveAdmin()">Open dashboard</button>
36
- <p id="gateerr" style="color:var(--red)"></p>
37
- </div>
38
-
39
- <div id="app" class="hide">
40
- <header>
41
- <div class="brand"><span class="dot"></span> proxyagent</div>
42
- <div class="row"><span id="provs" style="color:var(--dim)"></span><button class="ghost" onclick="logout()">Sign out</button></div>
43
- </header>
44
- <main>
45
- <div class="grid">
46
- <div class="card stat"><div class="n" id="s_req">0</div><div class="l">Requests</div></div>
47
- <div class="card stat"><div class="n" id="s_in">0</div><div class="l">Input tokens</div></div>
48
- <div class="card stat"><div class="n" id="s_out">0</div><div class="l">Output tokens</div></div>
49
- <div class="card stat"><div class="n" id="s_cost" style="color:var(--grn)">$0</div><div class="l">Cost</div></div>
50
- <div class="card stat"><div class="n" id="s_tools">0</div><div class="l">Proxied tools</div></div>
51
- </div>
52
-
53
- <h2>Mint a machine token</h2>
54
- <div class="card">
55
- <div class="row">
56
- <input id="t_label" placeholder="label (e.g. macbook-01)" />
57
- <input id="t_scope" placeholder="scope: * or anthropic:claude-*" style="flex:1" />
58
- <input id="t_ttl" type="number" placeholder="ttl (s)" style="width:110px" />
59
- <button onclick="mint()">Mint token</button>
60
- </div>
61
- <div id="minted" class="tok hide"></div>
62
- <p style="color:var(--dim);margin:10px 0 0">The machine holds only this token — never a real key. Revoke anytime.</p>
63
- </div>
64
-
65
- <h2>Machine tokens</h2>
66
- <div class="card"><table><thead><tr><th>ID</th><th>Label</th><th>Token</th><th>Scope</th><th>Status</th><th></th></tr></thead><tbody id="toks"></tbody></table></div>
67
-
68
- <h2>Recent requests <span style="color:var(--dim);font-weight:400;text-transform:none;letter-spacing:0">· live</span></h2>
69
- <div class="card"><table><thead><tr><th>Time</th><th>Token</th><th>Provider</th><th>Model</th><th>Status</th><th>In</th><th>Out</th><th>ms</th></tr></thead><tbody id="logs"></tbody></table></div>
70
- </main>
71
- </div>
72
-
73
- <script>
74
- const A = () => localStorage.getItem("pa_admin");
75
- const H = () => ({ "x-admin-token": A(), "content-type": "application/json" });
76
- async function api(path, opts={}) { const r = await fetch(path, { ...opts, headers: { ...H(), ...(opts.headers||{}) } }); if (r.status===401) { logout(); throw new Error("unauthorized"); } return r; }
77
-
78
- function saveAdmin() { const v = document.getElementById("admintok").value.trim(); if (!v) return; localStorage.setItem("pa_admin", v); boot(); }
79
- function logout() { localStorage.removeItem("pa_admin"); document.getElementById("app").classList.add("hide"); document.getElementById("gate").classList.remove("hide"); }
80
-
81
- async function boot() {
82
- try {
83
- const u = await (await api("/admin/usage")).json();
84
- document.getElementById("gate").classList.add("hide");
85
- document.getElementById("app").classList.remove("hide");
86
- document.getElementById("s_req").textContent = u.usage.requests;
87
- document.getElementById("s_in").textContent = u.usage.prompt_tokens;
88
- document.getElementById("s_out").textContent = u.usage.completion_tokens;
89
- document.getElementById("s_cost").textContent = "$" + (u.usage.cost_usd || 0).toFixed(4);
90
- document.getElementById("s_tools").textContent = (u.tools||[]).length;
91
- document.getElementById("provs").textContent = `${u.backend||"sqlite"} · providers: ` + ((u.providers||[]).join(", ") || "none");
92
- refreshTokens(); refreshLogs();
93
- } catch (e) { document.getElementById("gateerr").textContent = "Invalid admin token."; }
94
- }
95
-
96
- async function refreshTokens() {
97
- const d = await (await api("/admin/tokens")).json();
98
- document.getElementById("toks").innerHTML = d.tokens.map(t => `
99
- <tr><td class="mono">${t.id}</td><td>${t.label||""}</td><td class="mono">${t.masked||""}</td>
100
- <td class="mono">${(t.scope||[]).join(", ")}</td>
101
- <td>${t.revoked?'<span class="pill no">revoked</span>':'<span class="pill ok">active</span>'}</td>
102
- <td>${t.revoked?"":`<button class="ghost" onclick="revoke('${t.id}')">revoke</button>`}</td></tr>`).join("");
103
- }
104
- async function refreshLogs() {
105
- const d = await (await api("/admin/logs?limit=60")).json();
106
- document.getElementById("logs").innerHTML = d.logs.map(g => `
107
- <tr><td class="mono">${new Date(g.ts_ms).toLocaleTimeString()}</td><td>${g.token_label||""}</td>
108
- <td>${g.provider||""}</td><td class="mono">${(g.model||"").slice(0,26)}</td>
109
- <td>${g.status||""}</td><td>${g.prompt_tokens??"-"}</td><td>${g.completion_tokens??"-"}</td><td>${g.latency_ms||""}</td></tr>`).join("");
110
- }
111
- async function mint() {
112
- const body = { label: val("t_label")||"machine", scope: (val("t_scope")||"*").split(",").map(s=>s.trim()), ttl_seconds: parseInt(val("t_ttl"))||null };
113
- const d = await (await api("/admin/tokens", { method:"POST", body: JSON.stringify(body) })).json();
114
- const el = document.getElementById("minted"); el.classList.remove("hide");
115
- el.innerHTML = `<b>Token (shown once):</b><br/><span class="mono">${d.token}</span>`;
116
- refreshTokens(); boot();
117
- }
118
- async function revoke(id) { await api("/admin/tokens/"+id, { method:"DELETE" }); refreshTokens(); }
119
- function val(id){ return document.getElementById(id).value.trim(); }
120
-
121
- if (A()) boot();
122
- setInterval(() => { if (A() && !document.getElementById("app").classList.contains("hide")) { refreshLogs(); } }, 4000);
123
- </script>
124
- </body>
125
- </html>
File without changes
File without changes
File without changes