proxyagent 0.4.0__tar.gz → 0.5.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {proxyagent-0.4.0 → proxyagent-0.5.1}/PKG-INFO +22 -1
- {proxyagent-0.4.0 → proxyagent-0.5.1}/README.md +21 -0
- {proxyagent-0.4.0 → proxyagent-0.5.1}/proxyagent/__init__.py +1 -1
- {proxyagent-0.4.0 → proxyagent-0.5.1}/proxyagent/cli.py +5 -2
- {proxyagent-0.4.0 → proxyagent-0.5.1}/proxyagent/config.py +21 -0
- {proxyagent-0.4.0 → proxyagent-0.5.1}/proxyagent/server.py +30 -5
- {proxyagent-0.4.0 → proxyagent-0.5.1}/proxyagent/store.py +20 -8
- {proxyagent-0.4.0 → proxyagent-0.5.1}/proxyagent/ui/index.html +82 -44
- {proxyagent-0.4.0 → proxyagent-0.5.1}/pyproject.toml +1 -1
- {proxyagent-0.4.0 → proxyagent-0.5.1}/tests/test_proxy.py +23 -0
- {proxyagent-0.4.0 → proxyagent-0.5.1}/.gitignore +0 -0
- {proxyagent-0.4.0 → proxyagent-0.5.1}/proxyagent/aliases.py +0 -0
- {proxyagent-0.4.0 → proxyagent-0.5.1}/proxyagent/crypto.py +0 -0
- {proxyagent-0.4.0 → proxyagent-0.5.1}/proxyagent/db.py +0 -0
- {proxyagent-0.4.0 → proxyagent-0.5.1}/proxyagent/harness.py +0 -0
- {proxyagent-0.4.0 → proxyagent-0.5.1}/proxyagent/pricing.py +0 -0
- {proxyagent-0.4.0 → proxyagent-0.5.1}/proxyagent/providers.py +0 -0
- {proxyagent-0.4.0 → proxyagent-0.5.1}/proxyagent/security.py +0 -0
- {proxyagent-0.4.0 → proxyagent-0.5.1}/proxyagent/tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proxyagent
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: Run any agent (Claude, Codex, custom) on any machine — with no API key on the machine. A secure, self-hosted proxy for models and tools.
|
|
5
5
|
Project-URL: Homepage, https://github.com/teddyoweh/proxyagent
|
|
6
6
|
Author-email: Spawn Labs <teddy@spawnlabs.ai>
|
|
@@ -168,6 +168,27 @@ proxyagent.run("claude-code", goal="build the app",
|
|
|
168
168
|
proxy="https://proxy.you.com", token=token)
|
|
169
169
|
```
|
|
170
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
|
+
|
|
171
192
|
## Supported providers
|
|
172
193
|
`anthropic` · `openai` · `gemini` · `groq` · `openrouter` · `mistral` · `deepseek` ·
|
|
173
194
|
`xai` · `together` — Anthropic uses its Messages API; the rest are OpenAI-compatible.
|
|
@@ -136,6 +136,27 @@ proxyagent.run("claude-code", goal="build the app",
|
|
|
136
136
|
proxy="https://proxy.you.com", token=token)
|
|
137
137
|
```
|
|
138
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
|
+
|
|
139
160
|
## Supported providers
|
|
140
161
|
`anthropic` · `openai` · `gemini` · `groq` · `openrouter` · `mistral` · `deepseek` ·
|
|
141
162
|
`xai` · `together` — Anthropic uses its Messages API; the rest are OpenAI-compatible.
|
|
@@ -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,
|
|
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"]
|
|
@@ -83,6 +83,27 @@ CATALOG: dict[str, dict] = {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
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
|
+
|
|
86
107
|
@dataclass
|
|
87
108
|
class Config:
|
|
88
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 CATALOG, 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
|
-
|
|
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,26 @@ 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
|
+
|
|
221
246
|
@app.get("/admin/catalog")
|
|
222
247
|
async def catalog(authorization: str | None = Header(None),
|
|
223
248
|
x_admin_token: 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
|
-
#
|
|
94
|
-
|
|
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)
|
|
@@ -5,44 +5,47 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>proxyagent</title>
|
|
7
7
|
<style>
|
|
8
|
-
:root{--bg:#
|
|
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}
|
|
8
|
+
:root{--bg:#08090b;--panel:#16181d;--panel2:#1e2127;--soft:#22262e;--txt:#eef0f3;--dim:#888e98;--grn:#34d39e;--red:#f6788a}
|
|
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;-webkit-font-smoothing:antialiased}
|
|
10
10
|
code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
11
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:
|
|
12
|
+
header{position:sticky;top:0;z-index:10;display:flex;align-items:center;justify-content:space-between;padding:16px 28px;background:rgba(8,9,11,.72);backdrop-filter:blur(14px)}
|
|
13
13
|
.brand{display:flex;align-items:center;gap:10px;font-weight:650;font-size:16px}
|
|
14
|
-
.logo{width:
|
|
15
|
-
main{max-width:
|
|
16
|
-
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin-bottom:
|
|
17
|
-
.card{background:var(--panel);border
|
|
18
|
-
.stat .n{font-size:
|
|
19
|
-
.tabs{display:flex;gap:
|
|
20
|
-
.tab{padding:
|
|
21
|
-
.tab
|
|
22
|
-
.
|
|
23
|
-
.
|
|
24
|
-
.prov:
|
|
25
|
-
.prov
|
|
26
|
-
.
|
|
27
|
-
.
|
|
28
|
-
.
|
|
29
|
-
.
|
|
30
|
-
.
|
|
31
|
-
.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
button
|
|
14
|
+
.logo{width:27px;height:27px;border-radius:8px;background:linear-gradient(135deg,#34d39e,#3b82f6);display:grid;place-items:center;color:#04130d;font-weight:800}
|
|
15
|
+
main{max-width:1140px;margin:0 auto;padding:8px 28px 60px}
|
|
16
|
+
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin-bottom:26px}
|
|
17
|
+
.card{background:var(--panel);border-radius:18px;padding:20px}
|
|
18
|
+
.stat .n{font-size:31px;font-weight:700;letter-spacing:-.02em}.stat .l{color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.09em;margin-top:6px}
|
|
19
|
+
.tabs{display:flex;gap:6px;margin-bottom:20px}
|
|
20
|
+
.tab{padding:8px 15px;color:var(--dim);cursor:pointer;font-weight:560;border-radius:10px}
|
|
21
|
+
.tab:hover{color:var(--txt);background:var(--panel)}
|
|
22
|
+
.tab.on{color:var(--txt);background:var(--panel)}
|
|
23
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(258px,1fr));gap:16px}
|
|
24
|
+
.prov{background:var(--panel);border-radius:18px;padding:18px;transition:background .15s,transform .15s}
|
|
25
|
+
.prov:hover{background:#1a1d23}
|
|
26
|
+
.prov .top{display:flex;align-items:center;gap:13px}
|
|
27
|
+
.tile{width:42px;height:42px;border-radius:12px;display:grid;place-items:center;flex:none;font-weight:800;font-size:18px;overflow:hidden}
|
|
28
|
+
.tile img{width:23px;height:23px;display:block}.tile .fbm{display:none}.tile.fb img{display:none}.tile.fb .fbm{display:flex;align-items:center;justify-content:center}
|
|
29
|
+
.prov h3{margin:0;font-size:15px;font-weight:640}.prov .ep{color:var(--dim);font-size:11.5px;margin-top:1px}
|
|
30
|
+
.badges{display:flex;gap:6px;flex-wrap:wrap;margin:13px 0}
|
|
31
|
+
.badge{font-size:10.5px;padding:3px 9px;border-radius:8px;background:var(--soft);color:var(--dim);text-transform:uppercase;letter-spacing:.04em}
|
|
32
|
+
.badge.on{background:rgba(52,211,158,.14);color:var(--grn)}
|
|
33
|
+
.models{color:var(--dim);font-size:11.5px;margin:8px 0 13px;min-height:16px}
|
|
34
|
+
input,select{font:inherit;border-radius:11px;border:none;background:var(--panel2);color:var(--txt);padding:9px 12px;outline:none;box-shadow:inset 0 0 0 1px transparent}
|
|
35
|
+
input:focus,select:focus{box-shadow:inset 0 0 0 1px rgba(52,211,158,.45)}
|
|
36
|
+
input::placeholder{color:#5a606b}
|
|
37
|
+
button{font:inherit;background:var(--grn);color:#04130d;border:none;border-radius:11px;padding:9px 14px;font-weight:660;cursor:pointer}button:hover{filter:brightness(1.07)}
|
|
38
|
+
button.ghost{background:var(--soft);color:var(--dim)}button.ghost:hover{color:var(--txt)}
|
|
39
|
+
button.danger{background:rgba(246,120,138,.12);color:var(--red)}button.danger:hover{background:rgba(246,120,138,.2)}
|
|
40
|
+
button.sm{padding:7px 12px;font-size:12.5px;border-radius:9px}
|
|
38
41
|
.row{display:flex;gap:9px;flex-wrap:wrap;align-items:center}
|
|
39
|
-
.connect{margin-top:4px;display:none;gap:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
.pill{padding:
|
|
44
|
-
.gate{max-width:430px;margin:
|
|
45
|
-
.tok{background
|
|
42
|
+
.connect{margin-top:4px;display:none;gap:9px;flex-direction:column}.connect.open{display:flex}
|
|
43
|
+
table{width:100%;border-collapse:collapse}td,th{text-align:left;padding:11px 12px;font-size:13px}
|
|
44
|
+
thead th{color:var(--dim);font-weight:520;font-size:11px;text-transform:uppercase;letter-spacing:.06em}
|
|
45
|
+
tbody tr{transition:background .12s}tbody tr:hover{background:#1a1d23}
|
|
46
|
+
.pill{padding:3px 10px;border-radius:8px;font-size:11px}.pill.ok{background:rgba(52,211,158,.13);color:var(--grn)}.pill.no{background:rgba(246,120,138,.13);color:var(--red)}
|
|
47
|
+
.gate{max-width:430px;margin:100px auto;text-align:center}.gate input{width:100%;margin:14px 0}
|
|
48
|
+
.tok{background:var(--panel2);padding:13px;border-radius:12px;word-break:break-all;margin-top:13px;font-size:13px}
|
|
46
49
|
.hide{display:none}.muted{color:var(--dim)}.h{display:flex;justify-content:space-between;align-items:center;margin:0 0 12px}
|
|
47
50
|
h2{font-size:13px;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);margin:0}
|
|
48
51
|
</style>
|
|
@@ -70,13 +73,20 @@
|
|
|
70
73
|
</div>
|
|
71
74
|
|
|
72
75
|
<div class="tabs">
|
|
73
|
-
<div class="tab on" data-t="
|
|
76
|
+
<div class="tab on" data-t="harnesses" onclick="tab('harnesses')">Harnesses</div>
|
|
77
|
+
<div class="tab" data-t="providers" onclick="tab('providers')">Model endpoints</div>
|
|
74
78
|
<div class="tab" data-t="tokens" onclick="tab('tokens')">Machine tokens</div>
|
|
75
79
|
<div class="tab" data-t="models" onclick="tab('models')">Model routing</div>
|
|
76
80
|
<div class="tab" data-t="activity" onclick="tab('activity')">Activity</div>
|
|
77
81
|
</div>
|
|
78
82
|
|
|
79
|
-
<section id="
|
|
83
|
+
<section id="t_harnesses">
|
|
84
|
+
<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>
|
|
85
|
+
<div class="grid" id="harngrid"></div>
|
|
86
|
+
</section>
|
|
87
|
+
|
|
88
|
+
<section id="t_providers" class="hide">
|
|
89
|
+
<p class="muted" style="margin:0 0 14px">Raw model backends for model-agnostic harnesses (aider, Cline, Codex pointed elsewhere…).</p>
|
|
80
90
|
<div class="grid" id="provgrid"></div>
|
|
81
91
|
</section>
|
|
82
92
|
|
|
@@ -86,12 +96,13 @@
|
|
|
86
96
|
<div class="row">
|
|
87
97
|
<input id="tk_label" placeholder="label (e.g. macbook-01)"/>
|
|
88
98
|
<input id="tk_scope" placeholder="scope: * or anthropic:claude-*" style="flex:1"/>
|
|
89
|
-
<input id="tk_ttl" type="number" placeholder="ttl (s)" style="width:
|
|
99
|
+
<input id="tk_ttl" type="number" placeholder="ttl (s)" style="width:100px"/>
|
|
100
|
+
<input id="tk_budget" type="number" step="0.01" placeholder="budget $" style="width:100px"/>
|
|
90
101
|
<button onclick="mintToken()">Mint</button>
|
|
91
102
|
</div>
|
|
92
103
|
<div id="tk_out" class="tok hide"></div>
|
|
93
104
|
</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>
|
|
105
|
+
<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>
|
|
95
106
|
</section>
|
|
96
107
|
|
|
97
108
|
<section id="t_models" class="hide">
|
|
@@ -121,7 +132,7 @@ async function api(p,o={}){const r=await fetch(p,{...o,headers:{...H(),...(o.hea
|
|
|
121
132
|
function val(id){return document.getElementById(id).value.trim()}
|
|
122
133
|
function saveAdmin(){const v=document.getElementById("admintok").value.trim();if(v){localStorage.setItem("pa_admin",v);boot()}}
|
|
123
134
|
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))}
|
|
135
|
+
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))}
|
|
125
136
|
|
|
126
137
|
// ---- provider logos (inline, brand-tinted) ----
|
|
127
138
|
const MARK={
|
|
@@ -135,8 +146,16 @@ const MARK={
|
|
|
135
146
|
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
147
|
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
148
|
};
|
|
138
|
-
|
|
139
|
-
|
|
149
|
+
// real brand logos via the Simple Icons CDN, tinted to the brand colour; falls back to
|
|
150
|
+
// the drawn mark / letter if a slug is missing or the CDN is unreachable (offline).
|
|
151
|
+
const SLUG={anthropic:"anthropic",openai:"openai",gemini:"googlegemini",groq:"groq",mistral:"mistralai",deepseek:"deepseek",xai:"x"};
|
|
152
|
+
function logo(name,color){
|
|
153
|
+
const mark=MARK[name]?`<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">${MARK[name]}</svg>`:`<b>${(name[0]||"?").toUpperCase()}</b>`;
|
|
154
|
+
const inner=SLUG[name]
|
|
155
|
+
?`<img src="https://cdn.simpleicons.org/${SLUG[name]}/${color.replace("#","")}" alt="${name}" onerror="this.closest('.tile').classList.add('fb')"/><span class="fbm">${mark}</span>`
|
|
156
|
+
:`<span class="fbm" style="display:flex">${mark}</span>`;
|
|
157
|
+
return `<div class="tile" style="background:${hexa(color,.14)};color:${color}">${inner}</div>`;
|
|
158
|
+
}
|
|
140
159
|
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
160
|
|
|
142
161
|
async function boot(){
|
|
@@ -148,9 +167,28 @@ async function boot(){
|
|
|
148
167
|
document.getElementById("s_cost").textContent="$"+(u.usage.cost_usd||0).toFixed(4);
|
|
149
168
|
document.getElementById("badge_backend").textContent=u.backend;
|
|
150
169
|
document.getElementById("enc").textContent=u.encryption?"🔒 encrypted at rest":"⚠ encryption off";
|
|
151
|
-
refreshProviders();refreshTokens();refreshAliases();refreshLogs();
|
|
170
|
+
renderHarnesses();refreshProviders();refreshTokens();refreshAliases();refreshLogs();
|
|
152
171
|
}catch(e){document.getElementById("gateerr").textContent="Invalid admin token."}
|
|
153
172
|
}
|
|
173
|
+
async function renderHarnesses(){
|
|
174
|
+
const d=await(await api("/admin/harnesses")).json();
|
|
175
|
+
document.getElementById("harngrid").innerHTML=d.harnesses.map(h=>{
|
|
176
|
+
const chips=h.auth.map(a=>{
|
|
177
|
+
const cls=a.connected?"badge on":(a.ready?"badge":"badge");
|
|
178
|
+
const tag=a.connected?"✓ connected":(a.ready?"connect":"soon");
|
|
179
|
+
return `<span class="${cls}" title="${a.ready?'available now':'coming soon'}">${a.label} · ${tag}</span>`}).join("");
|
|
180
|
+
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>
|
|
181
|
+
${h.configured?'<span class="pill ok">ready</span>':'<span class="pill no">connect a key</span>'}</div>
|
|
182
|
+
<div class="badges">${chips}</div>
|
|
183
|
+
<div class="models mono" style="font-size:11px">${h.install}</div>
|
|
184
|
+
<div class="row"><button class="sm" onclick="openConnect('h_${h.name}')">Connect API key</button></div>
|
|
185
|
+
<div class="connect" id="c_h_${h.name}">
|
|
186
|
+
<input id="k_h_${h.name}" type="password" placeholder="${h.provider} API key"/>
|
|
187
|
+
<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>
|
|
188
|
+
</div></div>`}).join("");
|
|
189
|
+
}
|
|
190
|
+
async function connectHarness(name,provider){const key=document.getElementById("k_h_"+name).value.trim();if(!key)return;
|
|
191
|
+
await api("/admin/providers",{method:"POST",body:JSON.stringify({provider,secret:key,kind:"api_key"})});renderHarnesses();refreshProviders()}
|
|
154
192
|
async function refreshProviders(){
|
|
155
193
|
const d=await(await api("/admin/catalog")).json();const g=document.getElementById("provgrid");
|
|
156
194
|
let connected=0;
|
|
@@ -176,8 +214,8 @@ async function connect(n){const key=document.getElementById("k_"+n).value.trim()
|
|
|
176
214
|
async function disconnect(id){await api("/admin/providers/"+id,{method:"DELETE"});refreshProviders()}
|
|
177
215
|
|
|
178
216
|
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};
|
|
217
|
+
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("")}
|
|
218
|
+
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};
|
|
181
219
|
const d=await(await api("/admin/tokens",{method:"POST",body:JSON.stringify(body)})).json();
|
|
182
220
|
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
221
|
async function revokeTok(id){await api("/admin/tokens/"+id,{method:"DELETE"});refreshTokens()}
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "proxyagent"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.1"
|
|
8
8
|
description = "Run any agent (Claude, Codex, custom) on any machine — with no API key on the machine. A secure, self-hosted proxy for models and tools."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -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"])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|