proxyagent 0.4.0__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.
- {proxyagent-0.4.0 → proxyagent-0.5.0}/PKG-INFO +22 -1
- {proxyagent-0.4.0 → proxyagent-0.5.0}/README.md +21 -0
- {proxyagent-0.4.0 → proxyagent-0.5.0}/proxyagent/__init__.py +1 -1
- {proxyagent-0.4.0 → proxyagent-0.5.0}/proxyagent/cli.py +5 -2
- {proxyagent-0.4.0 → proxyagent-0.5.0}/proxyagent/config.py +21 -0
- {proxyagent-0.4.0 → proxyagent-0.5.0}/proxyagent/server.py +30 -5
- {proxyagent-0.4.0 → proxyagent-0.5.0}/proxyagent/store.py +20 -8
- {proxyagent-0.4.0 → proxyagent-0.5.0}/proxyagent/ui/index.html +35 -8
- {proxyagent-0.4.0 → proxyagent-0.5.0}/pyproject.toml +1 -1
- {proxyagent-0.4.0 → proxyagent-0.5.0}/tests/test_proxy.py +23 -0
- {proxyagent-0.4.0 → proxyagent-0.5.0}/.gitignore +0 -0
- {proxyagent-0.4.0 → proxyagent-0.5.0}/proxyagent/aliases.py +0 -0
- {proxyagent-0.4.0 → proxyagent-0.5.0}/proxyagent/crypto.py +0 -0
- {proxyagent-0.4.0 → proxyagent-0.5.0}/proxyagent/db.py +0 -0
- {proxyagent-0.4.0 → proxyagent-0.5.0}/proxyagent/harness.py +0 -0
- {proxyagent-0.4.0 → proxyagent-0.5.0}/proxyagent/pricing.py +0 -0
- {proxyagent-0.4.0 → proxyagent-0.5.0}/proxyagent/providers.py +0 -0
- {proxyagent-0.4.0 → proxyagent-0.5.0}/proxyagent/security.py +0 -0
- {proxyagent-0.4.0 → proxyagent-0.5.0}/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.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>
|
|
@@ -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)
|
|
@@ -70,13 +70,20 @@
|
|
|
70
70
|
</div>
|
|
71
71
|
|
|
72
72
|
<div class="tabs">
|
|
73
|
-
<div class="tab on" data-t="
|
|
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>
|
|
74
75
|
<div class="tab" data-t="tokens" onclick="tab('tokens')">Machine tokens</div>
|
|
75
76
|
<div class="tab" data-t="models" onclick="tab('models')">Model routing</div>
|
|
76
77
|
<div class="tab" data-t="activity" onclick="tab('activity')">Activity</div>
|
|
77
78
|
</div>
|
|
78
79
|
|
|
79
|
-
<section id="
|
|
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>
|
|
80
87
|
<div class="grid" id="provgrid"></div>
|
|
81
88
|
</section>
|
|
82
89
|
|
|
@@ -86,12 +93,13 @@
|
|
|
86
93
|
<div class="row">
|
|
87
94
|
<input id="tk_label" placeholder="label (e.g. macbook-01)"/>
|
|
88
95
|
<input id="tk_scope" placeholder="scope: * or anthropic:claude-*" style="flex:1"/>
|
|
89
|
-
<input id="tk_ttl" type="number" placeholder="ttl (s)" style="width:
|
|
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"/>
|
|
90
98
|
<button onclick="mintToken()">Mint</button>
|
|
91
99
|
</div>
|
|
92
100
|
<div id="tk_out" class="tok hide"></div>
|
|
93
101
|
</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>
|
|
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>
|
|
95
103
|
</section>
|
|
96
104
|
|
|
97
105
|
<section id="t_models" class="hide">
|
|
@@ -121,7 +129,7 @@ async function api(p,o={}){const r=await fetch(p,{...o,headers:{...H(),...(o.hea
|
|
|
121
129
|
function val(id){return document.getElementById(id).value.trim()}
|
|
122
130
|
function saveAdmin(){const v=document.getElementById("admintok").value.trim();if(v){localStorage.setItem("pa_admin",v);boot()}}
|
|
123
131
|
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))}
|
|
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))}
|
|
125
133
|
|
|
126
134
|
// ---- provider logos (inline, brand-tinted) ----
|
|
127
135
|
const MARK={
|
|
@@ -148,9 +156,28 @@ async function boot(){
|
|
|
148
156
|
document.getElementById("s_cost").textContent="$"+(u.usage.cost_usd||0).toFixed(4);
|
|
149
157
|
document.getElementById("badge_backend").textContent=u.backend;
|
|
150
158
|
document.getElementById("enc").textContent=u.encryption?"🔒 encrypted at rest":"⚠ encryption off";
|
|
151
|
-
refreshProviders();refreshTokens();refreshAliases();refreshLogs();
|
|
159
|
+
renderHarnesses();refreshProviders();refreshTokens();refreshAliases();refreshLogs();
|
|
152
160
|
}catch(e){document.getElementById("gateerr").textContent="Invalid admin token."}
|
|
153
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()}
|
|
154
181
|
async function refreshProviders(){
|
|
155
182
|
const d=await(await api("/admin/catalog")).json();const g=document.getElementById("provgrid");
|
|
156
183
|
let connected=0;
|
|
@@ -176,8 +203,8 @@ async function connect(n){const key=document.getElementById("k_"+n).value.trim()
|
|
|
176
203
|
async function disconnect(id){await api("/admin/providers/"+id,{method:"DELETE"});refreshProviders()}
|
|
177
204
|
|
|
178
205
|
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};
|
|
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};
|
|
181
208
|
const d=await(await api("/admin/tokens",{method:"POST",body:JSON.stringify(body)})).json();
|
|
182
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()}
|
|
183
210
|
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.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"])
|
|
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
|