proxyagent 0.3.1__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {proxyagent-0.3.1 → proxyagent-0.5.0}/PKG-INFO +31 -4
- {proxyagent-0.3.1 → proxyagent-0.5.0}/README.md +30 -3
- {proxyagent-0.3.1 → proxyagent-0.5.0}/proxyagent/__init__.py +1 -1
- {proxyagent-0.3.1 → proxyagent-0.5.0}/proxyagent/cli.py +5 -2
- {proxyagent-0.3.1 → proxyagent-0.5.0}/proxyagent/config.py +45 -0
- {proxyagent-0.3.1 → proxyagent-0.5.0}/proxyagent/server.py +50 -5
- {proxyagent-0.3.1 → proxyagent-0.5.0}/proxyagent/store.py +20 -8
- proxyagent-0.5.0/proxyagent/ui/index.html +224 -0
- {proxyagent-0.3.1 → proxyagent-0.5.0}/pyproject.toml +1 -1
- {proxyagent-0.3.1 → proxyagent-0.5.0}/tests/test_proxy.py +23 -0
- proxyagent-0.3.1/proxyagent/ui/index.html +0 -125
- {proxyagent-0.3.1 → proxyagent-0.5.0}/.gitignore +0 -0
- {proxyagent-0.3.1 → proxyagent-0.5.0}/proxyagent/aliases.py +0 -0
- {proxyagent-0.3.1 → proxyagent-0.5.0}/proxyagent/crypto.py +0 -0
- {proxyagent-0.3.1 → proxyagent-0.5.0}/proxyagent/db.py +0 -0
- {proxyagent-0.3.1 → proxyagent-0.5.0}/proxyagent/harness.py +0 -0
- {proxyagent-0.3.1 → proxyagent-0.5.0}/proxyagent/pricing.py +0 -0
- {proxyagent-0.3.1 → proxyagent-0.5.0}/proxyagent/providers.py +0 -0
- {proxyagent-0.3.1 → proxyagent-0.5.0}/proxyagent/security.py +0 -0
- {proxyagent-0.3.1 → 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>
|
|
@@ -99,9 +99,15 @@ claude -p "ship it"
|
|
|
99
99
|
```
|
|
100
100
|
|
|
101
101
|
## The dashboard
|
|
102
|
-
`proxyagent serve` ships a dashboard at `/`
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
`proxyagent serve` ships a real dashboard at `/` (reveal the admin token with
|
|
103
|
+
`proxyagent admin-token`):
|
|
104
|
+
|
|
105
|
+
- **Providers** — a branded catalog of every supported provider; **connect/disconnect**
|
|
106
|
+
with a key right from the UI, see which auth types each supports (api_key / oauth) and
|
|
107
|
+
whether it's on via env or stored credentials.
|
|
108
|
+
- **Machine tokens** — mint (scoped/TTL), list, revoke.
|
|
109
|
+
- **Model routing** — add/remove model remaps (e.g. `* → mock` for offline).
|
|
110
|
+
- **Activity** — live request log with usage + cost, and headline stats.
|
|
105
111
|
|
|
106
112
|
## Proxied tools — the same trick, for tools
|
|
107
113
|
The proxy can also hold your **tool** keys and hand agents governed tools — so an agent gets
|
|
@@ -162,6 +168,27 @@ proxyagent.run("claude-code", goal="build the app",
|
|
|
162
168
|
proxy="https://proxy.you.com", token=token)
|
|
163
169
|
```
|
|
164
170
|
|
|
171
|
+
## Harnesses & auth modes
|
|
172
|
+
You run an **agent harness**, and each one can authenticate several ways. The proxy's job
|
|
173
|
+
is to centralise *all* of them so the machine running the harness holds only a `pa_` token:
|
|
174
|
+
|
|
175
|
+
| Harness | Provider | Auth modes |
|
|
176
|
+
|---|---|---|
|
|
177
|
+
| **Claude Code** | Anthropic | API key · OAuth (subscription) · AWS Bedrock · Google Vertex |
|
|
178
|
+
| **Codex** | OpenAI | API key · OAuth (ChatGPT) · Azure |
|
|
179
|
+
| **Gemini CLI** | Google | API key · OAuth · Vertex |
|
|
180
|
+
|
|
181
|
+
Connect each mode once in the dashboard's **Harnesses** tab. API-key mode is wired today;
|
|
182
|
+
Bedrock / Vertex / OAuth-refresh (the cloud-credential paths enterprises actually use) are
|
|
183
|
+
being built out — the proxy holds the AWS/GCP creds and signs upstream, so the machine needs
|
|
184
|
+
none. The model providers below are the *backends* for model-agnostic harnesses (aider, Cline…).
|
|
185
|
+
|
|
186
|
+
## Per-token budgets
|
|
187
|
+
Cap what any token can spend; once its summed cost crosses the cap, the proxy returns **402**.
|
|
188
|
+
```bash
|
|
189
|
+
proxyagent token new ci --budget 5.00 # this token may spend at most $5
|
|
190
|
+
```
|
|
191
|
+
|
|
165
192
|
## Supported providers
|
|
166
193
|
`anthropic` · `openai` · `gemini` · `groq` · `openrouter` · `mistral` · `deepseek` ·
|
|
167
194
|
`xai` · `together` — Anthropic uses its Messages API; the rest are OpenAI-compatible.
|
|
@@ -67,9 +67,15 @@ claude -p "ship it"
|
|
|
67
67
|
```
|
|
68
68
|
|
|
69
69
|
## The dashboard
|
|
70
|
-
`proxyagent serve` ships a dashboard at `/`
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
`proxyagent serve` ships a real dashboard at `/` (reveal the admin token with
|
|
71
|
+
`proxyagent admin-token`):
|
|
72
|
+
|
|
73
|
+
- **Providers** — a branded catalog of every supported provider; **connect/disconnect**
|
|
74
|
+
with a key right from the UI, see which auth types each supports (api_key / oauth) and
|
|
75
|
+
whether it's on via env or stored credentials.
|
|
76
|
+
- **Machine tokens** — mint (scoped/TTL), list, revoke.
|
|
77
|
+
- **Model routing** — add/remove model remaps (e.g. `* → mock` for offline).
|
|
78
|
+
- **Activity** — live request log with usage + cost, and headline stats.
|
|
73
79
|
|
|
74
80
|
## Proxied tools — the same trick, for tools
|
|
75
81
|
The proxy can also hold your **tool** keys and hand agents governed tools — so an agent gets
|
|
@@ -130,6 +136,27 @@ proxyagent.run("claude-code", goal="build the app",
|
|
|
130
136
|
proxy="https://proxy.you.com", token=token)
|
|
131
137
|
```
|
|
132
138
|
|
|
139
|
+
## Harnesses & auth modes
|
|
140
|
+
You run an **agent harness**, and each one can authenticate several ways. The proxy's job
|
|
141
|
+
is to centralise *all* of them so the machine running the harness holds only a `pa_` token:
|
|
142
|
+
|
|
143
|
+
| Harness | Provider | Auth modes |
|
|
144
|
+
|---|---|---|
|
|
145
|
+
| **Claude Code** | Anthropic | API key · OAuth (subscription) · AWS Bedrock · Google Vertex |
|
|
146
|
+
| **Codex** | OpenAI | API key · OAuth (ChatGPT) · Azure |
|
|
147
|
+
| **Gemini CLI** | Google | API key · OAuth · Vertex |
|
|
148
|
+
|
|
149
|
+
Connect each mode once in the dashboard's **Harnesses** tab. API-key mode is wired today;
|
|
150
|
+
Bedrock / Vertex / OAuth-refresh (the cloud-credential paths enterprises actually use) are
|
|
151
|
+
being built out — the proxy holds the AWS/GCP creds and signs upstream, so the machine needs
|
|
152
|
+
none. The model providers below are the *backends* for model-agnostic harnesses (aider, Cline…).
|
|
153
|
+
|
|
154
|
+
## Per-token budgets
|
|
155
|
+
Cap what any token can spend; once its summed cost crosses the cap, the proxy returns **402**.
|
|
156
|
+
```bash
|
|
157
|
+
proxyagent token new ci --budget 5.00 # this token may spend at most $5
|
|
158
|
+
```
|
|
159
|
+
|
|
133
160
|
## Supported providers
|
|
134
161
|
`anthropic` · `openai` · `gemini` · `groq` · `openrouter` · `mistral` · `deepseek` ·
|
|
135
162
|
`xai` · `together` — Anthropic uses its Messages API; the rest are OpenAI-compatible.
|
|
@@ -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"]
|
|
@@ -59,6 +59,51 @@ PROVIDERS: dict[str, Provider] = {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
|
|
62
|
+
# Display metadata for the dashboard: label, the auth kinds each provider supports,
|
|
63
|
+
# a brand accent colour, and example models.
|
|
64
|
+
CATALOG: dict[str, dict] = {
|
|
65
|
+
"anthropic": {"label": "Anthropic", "kinds": ["api_key", "oauth"], "color": "#D97757",
|
|
66
|
+
"models": ["claude-opus-4", "claude-sonnet-4-5", "claude-haiku-4"]},
|
|
67
|
+
"openai": {"label": "OpenAI", "kinds": ["api_key", "oauth"], "color": "#10A37F",
|
|
68
|
+
"models": ["gpt-5", "gpt-4.1", "gpt-4o", "o3"]},
|
|
69
|
+
"gemini": {"label": "Google Gemini","kinds": ["api_key"], "color": "#4285F4",
|
|
70
|
+
"models": ["gemini-2.5-pro", "gemini-2.5-flash"]},
|
|
71
|
+
"groq": {"label": "Groq", "kinds": ["api_key"], "color": "#F55036",
|
|
72
|
+
"models": ["llama-3.3-70b", "deepseek-r1-distill"]},
|
|
73
|
+
"openrouter": {"label": "OpenRouter", "kinds": ["api_key"], "color": "#7C7CFF",
|
|
74
|
+
"models": ["anthropic/claude-sonnet-4.5", "openai/gpt-5"]},
|
|
75
|
+
"mistral": {"label": "Mistral", "kinds": ["api_key"], "color": "#FF7000",
|
|
76
|
+
"models": ["mistral-large", "codestral"]},
|
|
77
|
+
"deepseek": {"label": "DeepSeek", "kinds": ["api_key"], "color": "#4D6BFE",
|
|
78
|
+
"models": ["deepseek-chat", "deepseek-reasoner"]},
|
|
79
|
+
"xai": {"label": "xAI", "kinds": ["api_key"], "color": "#111111",
|
|
80
|
+
"models": ["grok-4", "grok-3-mini"]},
|
|
81
|
+
"together": {"label": "Together", "kinds": ["api_key"], "color": "#0F6FFF",
|
|
82
|
+
"models": ["llama-3.3-70b", "qwen-2.5-72b"]},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# Agent harnesses (what you actually RUN) and the auth modes each supports. The model
|
|
87
|
+
# providers above are the *backends*; these are the agents. Auth mode availability is
|
|
88
|
+
# what makes the proxy valuable — it can centralise all of them so the machine holds none.
|
|
89
|
+
HARNESSES: dict[str, dict] = {
|
|
90
|
+
"claude-code": {"label": "Claude Code", "provider": "anthropic", "color": "#D97757",
|
|
91
|
+
"install": "npm i -g @anthropic-ai/claude-code",
|
|
92
|
+
"auth": ["api_key", "oauth", "bedrock", "vertex"]},
|
|
93
|
+
"codex": {"label": "Codex", "provider": "openai", "color": "#10A37F",
|
|
94
|
+
"install": "npm i -g @openai/codex",
|
|
95
|
+
"auth": ["api_key", "oauth", "azure"]},
|
|
96
|
+
"gemini-cli": {"label": "Gemini CLI", "provider": "gemini", "color": "#4285F4",
|
|
97
|
+
"install": "npm i -g @google/gemini-cli",
|
|
98
|
+
"auth": ["api_key", "oauth", "vertex"]},
|
|
99
|
+
}
|
|
100
|
+
AUTH_LABELS = {"api_key": "API key", "oauth": "OAuth", "bedrock": "AWS Bedrock",
|
|
101
|
+
"vertex": "Google Vertex", "azure": "Azure"}
|
|
102
|
+
# Auth modes that are fully wired today (just a key swap). Others are surfaced in the
|
|
103
|
+
# UI as "available" and built out (Bedrock SigV4 / Vertex token / OAuth refresh).
|
|
104
|
+
AUTH_READY = {"api_key"}
|
|
105
|
+
|
|
106
|
+
|
|
62
107
|
@dataclass
|
|
63
108
|
class Config:
|
|
64
109
|
home: Path = HOME
|
|
@@ -15,7 +15,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
|
|
15
15
|
from pydantic import BaseModel
|
|
16
16
|
|
|
17
17
|
from . import aliases, crypto
|
|
18
|
-
from .config import Config, PROVIDERS
|
|
18
|
+
from .config import AUTH_LABELS, AUTH_READY, CATALOG, HARNESSES, Config, PROVIDERS
|
|
19
19
|
from .providers import forward, scope_allows
|
|
20
20
|
from .security import token_matches
|
|
21
21
|
from .store import Store, now_ms
|
|
@@ -29,6 +29,7 @@ class TokenBody(BaseModel):
|
|
|
29
29
|
scope: list[str] = ["*"]
|
|
30
30
|
ttl_seconds: int | None = None
|
|
31
31
|
rate_limit: int = 0
|
|
32
|
+
budget_usd: float | None = None
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
class ProviderBody(BaseModel):
|
|
@@ -66,6 +67,9 @@ def create_app(config: Config | None = None) -> FastAPI:
|
|
|
66
67
|
raise HTTPException(401, "token expired")
|
|
67
68
|
if row["rate_limit"] and store.recent_request_count(row["id"]) >= row["rate_limit"]:
|
|
68
69
|
raise HTTPException(429, "rate limit exceeded")
|
|
70
|
+
budget = row.get("budget_usd")
|
|
71
|
+
if budget is not None and store.token_spend(row["id"]) >= budget:
|
|
72
|
+
raise HTTPException(402, f"token budget of ${budget:.4f} exhausted")
|
|
69
73
|
store.touch_token(row["id"])
|
|
70
74
|
return row
|
|
71
75
|
|
|
@@ -147,10 +151,10 @@ def create_app(config: Config | None = None) -> FastAPI:
|
|
|
147
151
|
async def create_token(body: TokenBody, authorization: str | None = Header(None),
|
|
148
152
|
x_admin_token: str | None = Header(None)):
|
|
149
153
|
require_admin(authorization, x_admin_token)
|
|
150
|
-
plain, row = store.create_token(body.label, body.scope,
|
|
151
|
-
|
|
154
|
+
plain, row = store.create_token(body.label, body.scope, ttl_seconds=body.ttl_seconds,
|
|
155
|
+
rate_limit=body.rate_limit, budget_usd=body.budget_usd)
|
|
152
156
|
return {"token": plain, "id": row["id"], "label": row["label"],
|
|
153
|
-
"scope": body.scope, "note": "shown once — store it now"}
|
|
157
|
+
"scope": body.scope, "budget_usd": body.budget_usd, "note": "shown once — store it now"}
|
|
154
158
|
|
|
155
159
|
@app.get("/admin/tokens")
|
|
156
160
|
async def list_tokens_ep(authorization: str | None = Header(None),
|
|
@@ -161,7 +165,8 @@ def create_app(config: Config | None = None) -> FastAPI:
|
|
|
161
165
|
out.append({"id": t["id"], "label": t["label"], "masked": t["masked"],
|
|
162
166
|
"scope": _json.loads(t["scope_json"]), "revoked": bool(t["revoked"]),
|
|
163
167
|
"rate_limit": t["rate_limit"], "expires_ms": t["expires_ms"],
|
|
164
|
-
"last_used_ms": t["last_used_ms"]
|
|
168
|
+
"last_used_ms": t["last_used_ms"], "budget_usd": t.get("budget_usd"),
|
|
169
|
+
"spent_usd": round(store.token_spend(t["id"]), 6)})
|
|
165
170
|
return {"tokens": out}
|
|
166
171
|
|
|
167
172
|
@app.delete("/admin/tokens/{tid}")
|
|
@@ -218,6 +223,46 @@ def create_app(config: Config | None = None) -> FastAPI:
|
|
|
218
223
|
raise HTTPException(404, "no such credential")
|
|
219
224
|
return {"ok": True}
|
|
220
225
|
|
|
226
|
+
@app.get("/admin/harnesses")
|
|
227
|
+
async def harnesses(authorization: str | None = Header(None),
|
|
228
|
+
x_admin_token: str | None = Header(None)):
|
|
229
|
+
require_admin(authorization, x_admin_token)
|
|
230
|
+
stored = {c["provider"] for c in store.list_credentials() if c["active"]}
|
|
231
|
+
out = []
|
|
232
|
+
for name, h in HARNESSES.items():
|
|
233
|
+
prov = PROVIDERS.get(h["provider"])
|
|
234
|
+
configured = bool(prov and prov.key) or h["provider"] in stored
|
|
235
|
+
out.append({
|
|
236
|
+
"name": name, "label": h["label"], "provider": h["provider"],
|
|
237
|
+
"color": h["color"], "install": h["install"],
|
|
238
|
+
"auth": [{"mode": m, "label": AUTH_LABELS.get(m, m),
|
|
239
|
+
"ready": m in AUTH_READY,
|
|
240
|
+
"connected": m == "api_key" and configured}
|
|
241
|
+
for m in h["auth"]],
|
|
242
|
+
"configured": configured,
|
|
243
|
+
})
|
|
244
|
+
return {"harnesses": out}
|
|
245
|
+
|
|
246
|
+
@app.get("/admin/catalog")
|
|
247
|
+
async def catalog(authorization: str | None = Header(None),
|
|
248
|
+
x_admin_token: str | None = Header(None)):
|
|
249
|
+
require_admin(authorization, x_admin_token)
|
|
250
|
+
stored = {c["provider"]: c for c in store.list_credentials() if c["active"]}
|
|
251
|
+
out = []
|
|
252
|
+
for name, prov in PROVIDERS.items():
|
|
253
|
+
meta = CATALOG.get(name, {})
|
|
254
|
+
cred = stored.get(name)
|
|
255
|
+
out.append({
|
|
256
|
+
"name": name, "label": meta.get("label", name.title()),
|
|
257
|
+
"kinds": meta.get("kinds", ["api_key"]), "color": meta.get("color", "#888"),
|
|
258
|
+
"models": meta.get("models", []), "shape": prov.shape,
|
|
259
|
+
"via_env": bool(prov.key), "via_store": bool(cred),
|
|
260
|
+
"cred_id": cred["id"] if cred else None,
|
|
261
|
+
"cred_kind": cred["kind"] if cred else None,
|
|
262
|
+
"endpoint": prov.endpoint,
|
|
263
|
+
})
|
|
264
|
+
return {"providers": out, "encryption": crypto.encryption_available()}
|
|
265
|
+
|
|
221
266
|
# -- model aliases / remap -------------------------------------------- #
|
|
222
267
|
@app.get("/admin/aliases")
|
|
223
268
|
async def get_aliases(authorization: str | None = Header(None),
|
|
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS proxy_agent_tokens (
|
|
|
22
22
|
id TEXT PRIMARY KEY, hash TEXT NOT NULL UNIQUE, label TEXT,
|
|
23
23
|
scope_json TEXT NOT NULL DEFAULT '["*"]', rate_limit INTEGER NOT NULL DEFAULT 0,
|
|
24
24
|
created_ms BIGINT, expires_ms BIGINT, revoked INTEGER NOT NULL DEFAULT 0,
|
|
25
|
-
last_used_ms BIGINT, masked TEXT
|
|
25
|
+
last_used_ms BIGINT, masked TEXT, budget_usd DOUBLE PRECISION
|
|
26
26
|
);
|
|
27
27
|
CREATE TABLE IF NOT EXISTS proxy_agent_keys (
|
|
28
28
|
id TEXT PRIMARY KEY, provider TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'api_key',
|
|
@@ -47,22 +47,32 @@ class Store:
|
|
|
47
47
|
self.db = DB(str(path), url=url)
|
|
48
48
|
self.db.executescript(_SCHEMA)
|
|
49
49
|
self.backend = "postgres" if self.db.pg else "sqlite"
|
|
50
|
+
# migrate older DBs created before budget_usd existed
|
|
51
|
+
try:
|
|
52
|
+
self.db.execute("ALTER TABLE proxy_agent_tokens ADD COLUMN budget_usd DOUBLE PRECISION")
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
50
55
|
|
|
51
56
|
# -- machine tokens ---------------------------------------------------- #
|
|
52
57
|
|
|
53
|
-
def create_token(self, label, scope, *, ttl_seconds=None, rate_limit=0):
|
|
58
|
+
def create_token(self, label, scope, *, ttl_seconds=None, rate_limit=0, budget_usd=None):
|
|
54
59
|
plain = new_token()
|
|
55
60
|
tid = "tok_" + uuid.uuid4().hex[:12]
|
|
56
61
|
expires = now_ms() + ttl_seconds * 1000 if ttl_seconds else None
|
|
57
62
|
self.db.execute(
|
|
58
63
|
"""INSERT INTO proxy_agent_tokens
|
|
59
|
-
(id, hash, label, scope_json, rate_limit, created_ms, expires_ms, masked)
|
|
60
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
64
|
+
(id, hash, label, scope_json, rate_limit, created_ms, expires_ms, masked, budget_usd)
|
|
65
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
61
66
|
(tid, hash_token(plain), label, json.dumps(scope), rate_limit, now_ms(),
|
|
62
|
-
expires, mask(plain)),
|
|
67
|
+
expires, mask(plain), budget_usd),
|
|
63
68
|
)
|
|
64
69
|
return plain, self.get_token(tid)
|
|
65
70
|
|
|
71
|
+
def token_spend(self, token_id: str) -> float:
|
|
72
|
+
r = self.db.fetchone(
|
|
73
|
+
"SELECT COALESCE(SUM(cost_usd),0) s FROM proxy_agent_calls WHERE token_id=?", (token_id,))
|
|
74
|
+
return float((r or {}).get("s", 0) or 0)
|
|
75
|
+
|
|
66
76
|
def get_token(self, tid):
|
|
67
77
|
return self.db.fetchone("SELECT * FROM proxy_agent_tokens WHERE id=?", (tid,))
|
|
68
78
|
|
|
@@ -88,10 +98,12 @@ class Store:
|
|
|
88
98
|
# -- provider credentials (proxy_agent_keys) --------------------------- #
|
|
89
99
|
|
|
90
100
|
def add_credential(self, provider, secret, *, kind="api_key", refresh=None,
|
|
91
|
-
expires_ms=None, label=None, meta=None):
|
|
101
|
+
expires_ms=None, label=None, meta=None, replace=True):
|
|
92
102
|
cid = "key_" + uuid.uuid4().hex[:12]
|
|
93
|
-
#
|
|
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)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>proxyagent</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root{--bg:#0a0b0d;--panel:#141619;--panel2:#1a1d21;--line:#23262c;--txt:#eceef1;--dim:#878d96;--grn:#34d39e;--red:#f87171;--yel:#fbbf24;--blu:#6ea8fe}
|
|
9
|
+
*{box-sizing:border-box}html,body{margin:0;background:var(--bg);color:var(--txt);font:14px/1.55 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto}
|
|
10
|
+
code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
11
|
+
a{color:var(--grn);text-decoration:none}
|
|
12
|
+
header{position:sticky;top:0;z-index:10;display:flex;align-items:center;justify-content:space-between;padding:14px 26px;border-bottom:1px solid var(--line);background:rgba(10,11,13,.8);backdrop-filter:blur(12px)}
|
|
13
|
+
.brand{display:flex;align-items:center;gap:10px;font-weight:650;font-size:16px}
|
|
14
|
+
.logo{width:26px;height:26px;border-radius:7px;background:linear-gradient(135deg,#34d39e,#2563eb);display:grid;place-items:center;color:#04130d;font-weight:800}
|
|
15
|
+
main{max-width:1120px;margin:0 auto;padding:26px}
|
|
16
|
+
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin-bottom:24px}
|
|
17
|
+
.card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:18px}
|
|
18
|
+
.stat .n{font-size:30px;font-weight:680;letter-spacing:-.02em}.stat .l{color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.09em;margin-top:5px}
|
|
19
|
+
.tabs{display:flex;gap:4px;margin-bottom:18px;border-bottom:1px solid var(--line)}
|
|
20
|
+
.tab{padding:9px 15px;color:var(--dim);cursor:pointer;border-bottom:2px solid transparent;font-weight:550}
|
|
21
|
+
.tab.on{color:var(--txt);border-bottom-color:var(--grn)}
|
|
22
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(248px,1fr));gap:14px}
|
|
23
|
+
.prov{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:16px;transition:border-color .15s}
|
|
24
|
+
.prov:hover{border-color:#33383f}
|
|
25
|
+
.prov .top{display:flex;align-items:center;gap:12px}
|
|
26
|
+
.tile{width:40px;height:40px;border-radius:11px;display:grid;place-items:center;flex:none;font-weight:800;font-size:17px}
|
|
27
|
+
.prov h3{margin:0;font-size:15px;font-weight:620}.prov .ep{color:var(--dim);font-size:11px;margin-top:1px}
|
|
28
|
+
.badges{display:flex;gap:6px;flex-wrap:wrap;margin:12px 0}
|
|
29
|
+
.badge{font-size:10.5px;padding:2px 8px;border-radius:99px;background:#1f2329;color:var(--dim);text-transform:uppercase;letter-spacing:.04em}
|
|
30
|
+
.badge.on{background:rgba(52,211,158,.13);color:var(--grn)}
|
|
31
|
+
.models{color:var(--dim);font-size:11.5px;margin:6px 0 12px;min-height:16px}
|
|
32
|
+
input,button,select{font:inherit;border-radius:9px;border:1px solid var(--line);background:#0e1013;color:var(--txt);padding:8px 11px;outline:none}
|
|
33
|
+
input:focus,select:focus{border-color:#3a4048}
|
|
34
|
+
button{background:var(--grn);color:#04130d;border:none;font-weight:640;cursor:pointer}button:hover{filter:brightness(1.07)}
|
|
35
|
+
button.ghost{background:transparent;color:var(--dim);border:1px solid var(--line)}button.ghost:hover{color:var(--txt)}
|
|
36
|
+
button.danger{background:transparent;color:var(--red);border:1px solid #3a2626}
|
|
37
|
+
button.sm{padding:6px 11px;font-size:12.5px;border-radius:8px}
|
|
38
|
+
.row{display:flex;gap:9px;flex-wrap:wrap;align-items:center}
|
|
39
|
+
.connect{margin-top:4px;display:none;gap:8px;flex-direction:column}
|
|
40
|
+
.connect.open{display:flex}
|
|
41
|
+
table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:10px;border-bottom:1px solid var(--line);font-size:13px}
|
|
42
|
+
th{color:var(--dim);font-weight:520;font-size:11px;text-transform:uppercase;letter-spacing:.06em}
|
|
43
|
+
.pill{padding:2px 9px;border-radius:99px;font-size:11px}.pill.ok{background:rgba(52,211,158,.12);color:var(--grn)}.pill.no{background:rgba(248,113,113,.12);color:var(--red)}
|
|
44
|
+
.gate{max-width:430px;margin:96px auto;text-align:center}.gate input{width:100%;margin:14px 0}
|
|
45
|
+
.tok{background:#0e1013;border:1px dashed var(--grn);padding:12px;border-radius:10px;word-break:break-all;margin-top:12px;font-size:13px}
|
|
46
|
+
.hide{display:none}.muted{color:var(--dim)}.h{display:flex;justify-content:space-between;align-items:center;margin:0 0 12px}
|
|
47
|
+
h2{font-size:13px;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);margin:0}
|
|
48
|
+
</style>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<div id="gate" class="gate">
|
|
52
|
+
<div class="brand" style="justify-content:center"><span class="logo">P</span> proxyagent</div>
|
|
53
|
+
<p class="muted">Paste your admin token — reveal it with <code>proxyagent admin-token</code>.</p>
|
|
54
|
+
<input id="admintok" type="password" placeholder="pa_admin_…" onkeydown="if(event.key==='Enter')saveAdmin()"/>
|
|
55
|
+
<button onclick="saveAdmin()">Open dashboard</button>
|
|
56
|
+
<p id="gateerr" style="color:var(--red)"></p>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div id="app" class="hide">
|
|
60
|
+
<header>
|
|
61
|
+
<div class="brand"><span class="logo">P</span> proxyagent <span id="badge_backend" class="badge" style="margin-left:6px"></span></div>
|
|
62
|
+
<div class="row"><span id="enc" class="muted" style="font-size:12px"></span><button class="ghost sm" onclick="logout()">Sign out</button></div>
|
|
63
|
+
</header>
|
|
64
|
+
<main>
|
|
65
|
+
<div class="stats">
|
|
66
|
+
<div class="card stat"><div class="n" id="s_req">0</div><div class="l">Requests</div></div>
|
|
67
|
+
<div class="card stat"><div class="n" id="s_tok">0</div><div class="l">Tokens (in/out)</div></div>
|
|
68
|
+
<div class="card stat"><div class="n" id="s_cost" style="color:var(--grn)">$0</div><div class="l">Cost</div></div>
|
|
69
|
+
<div class="card stat"><div class="n" id="s_prov">0</div><div class="l">Providers connected</div></div>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div class="tabs">
|
|
73
|
+
<div class="tab on" data-t="harnesses" onclick="tab('harnesses')">Harnesses</div>
|
|
74
|
+
<div class="tab" data-t="providers" onclick="tab('providers')">Model endpoints</div>
|
|
75
|
+
<div class="tab" data-t="tokens" onclick="tab('tokens')">Machine tokens</div>
|
|
76
|
+
<div class="tab" data-t="models" onclick="tab('models')">Model routing</div>
|
|
77
|
+
<div class="tab" data-t="activity" onclick="tab('activity')">Activity</div>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<section id="t_harnesses">
|
|
81
|
+
<p class="muted" style="margin:0 0 14px">The agents you run. Connect each auth method once — the machine running the harness then holds only a <code>pa_</code> token.</p>
|
|
82
|
+
<div class="grid" id="harngrid"></div>
|
|
83
|
+
</section>
|
|
84
|
+
|
|
85
|
+
<section id="t_providers" class="hide">
|
|
86
|
+
<p class="muted" style="margin:0 0 14px">Raw model backends for model-agnostic harnesses (aider, Cline, Codex pointed elsewhere…).</p>
|
|
87
|
+
<div class="grid" id="provgrid"></div>
|
|
88
|
+
</section>
|
|
89
|
+
|
|
90
|
+
<section id="t_tokens" class="hide">
|
|
91
|
+
<div class="card" style="margin-bottom:16px">
|
|
92
|
+
<div class="h"><h2>Mint a machine token</h2></div>
|
|
93
|
+
<div class="row">
|
|
94
|
+
<input id="tk_label" placeholder="label (e.g. macbook-01)"/>
|
|
95
|
+
<input id="tk_scope" placeholder="scope: * or anthropic:claude-*" style="flex:1"/>
|
|
96
|
+
<input id="tk_ttl" type="number" placeholder="ttl (s)" style="width:100px"/>
|
|
97
|
+
<input id="tk_budget" type="number" step="0.01" placeholder="budget $" style="width:100px"/>
|
|
98
|
+
<button onclick="mintToken()">Mint</button>
|
|
99
|
+
</div>
|
|
100
|
+
<div id="tk_out" class="tok hide"></div>
|
|
101
|
+
</div>
|
|
102
|
+
<div class="card"><table><thead><tr><th>ID</th><th>Label</th><th>Token</th><th>Scope</th><th>Budget</th><th>Status</th><th></th></tr></thead><tbody id="toks"></tbody></table></div>
|
|
103
|
+
</section>
|
|
104
|
+
|
|
105
|
+
<section id="t_models" class="hide">
|
|
106
|
+
<div class="card" style="margin-bottom:16px">
|
|
107
|
+
<div class="h"><h2>Remap a model</h2></div>
|
|
108
|
+
<div class="row">
|
|
109
|
+
<input id="al_match" placeholder="match: * or gpt-4o"/>
|
|
110
|
+
<span class="muted">→</span>
|
|
111
|
+
<input id="al_target" placeholder="target: mock or anthropic:claude-sonnet-4-5" style="flex:1"/>
|
|
112
|
+
<button onclick="setAlias()">Map</button>
|
|
113
|
+
</div>
|
|
114
|
+
<p class="muted" style="margin:10px 0 0">Tip: map <code>* → mock</code> to run any agent fully offline (no keys).</p>
|
|
115
|
+
</div>
|
|
116
|
+
<div class="card"><table><thead><tr><th>Match</th><th>→ Target</th><th></th></tr></thead><tbody id="aliases"></tbody></table></div>
|
|
117
|
+
</section>
|
|
118
|
+
|
|
119
|
+
<section id="t_activity" class="hide">
|
|
120
|
+
<div class="card"><table><thead><tr><th>Time</th><th>Token</th><th>Provider</th><th>Model</th><th>Status</th><th>In</th><th>Out</th><th>Cost</th><th>ms</th></tr></thead><tbody id="logs"></tbody></table></div>
|
|
121
|
+
</section>
|
|
122
|
+
</main>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<script>
|
|
126
|
+
const A=()=>localStorage.getItem("pa_admin");
|
|
127
|
+
const H=()=>({"x-admin-token":A(),"content-type":"application/json"});
|
|
128
|
+
async function api(p,o={}){const r=await fetch(p,{...o,headers:{...H(),...(o.headers||{})}});if(r.status===401){logout();throw new Error("401")}return r}
|
|
129
|
+
function val(id){return document.getElementById(id).value.trim()}
|
|
130
|
+
function saveAdmin(){const v=document.getElementById("admintok").value.trim();if(v){localStorage.setItem("pa_admin",v);boot()}}
|
|
131
|
+
function logout(){localStorage.removeItem("pa_admin");document.getElementById("app").classList.add("hide");document.getElementById("gate").classList.remove("hide")}
|
|
132
|
+
function tab(t){document.querySelectorAll(".tab").forEach(e=>e.classList.toggle("on",e.dataset.t===t));["harnesses","providers","tokens","models","activity"].forEach(s=>document.getElementById("t_"+s).classList.toggle("hide",s!==t))}
|
|
133
|
+
|
|
134
|
+
// ---- provider logos (inline, brand-tinted) ----
|
|
135
|
+
const MARK={
|
|
136
|
+
anthropic:'<path d="M9.6 3 3 21h4.1l1.2-3.5h5.9L15.4 21H20L13.4 3H9.6Zm-.4 10.5 1.9-5.4 2 5.4H9.2Z"/>',
|
|
137
|
+
openai:'<path d="M12 2.6c1.9 0 3.5 1.3 4 3a4.2 4.2 0 0 1 2.3 6.8 4.2 4.2 0 0 1-4 5.8 4.2 4.2 0 0 1-8.6-1A4.2 4.2 0 0 1 5.7 12 4.2 4.2 0 0 1 8 5.5a4.2 4.2 0 0 1 4-2.9Zm0 4.6a4.8 4.8 0 1 0 0 9.6 4.8 4.8 0 0 0 0-9.6Z"/>',
|
|
138
|
+
gemini:'<path d="M12 2c.4 4.6 3.4 7.6 8 8-4.6.4-7.6 3.4-8 8-.4-4.6-3.4-7.6-8-8 4.6-.4 7.6-3.4 8-8Z"/>',
|
|
139
|
+
groq:'<path d="M12 3a6 6 0 1 0 4.2 10.3l-2-2A3.2 3.2 0 1 1 12 6.2c.9 0 1.6.3 2.2.8L16.3 5A6 6 0 0 0 12 3Zm2 7v3.3h2.6V10H14Z"/>',
|
|
140
|
+
openrouter:'<path d="M3 8h6l3 4 3-4h6M3 16h6l3-4M21 8l-3 8h-3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
|
141
|
+
mistral:'<g><rect x="3" y="4" width="3.4" height="3.4"/><rect x="17.6" y="4" width="3.4" height="3.4"/><rect x="7.3" y="8.3" width="3.4" height="3.4"/><rect x="13.3" y="8.3" width="3.4" height="3.4"/><rect x="3" y="12.6" width="3.4" height="3.4"/><rect x="17.6" y="12.6" width="3.4" height="3.4"/><rect x="3" y="16.9" width="18" height="3.1"/></g>',
|
|
142
|
+
deepseek:'<path d="M4 9c3 0 4 2 7 2s4-3 8-2c-1 4-5 7-9 7-3 0-6-2-6-5 0-1 0-2 0-2Zm12 1.5a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"/>',
|
|
143
|
+
xai:'<path d="M4 4h3.2l4 5.6L15.8 4H19l-6 8 6.2 8h-3.2l-4.4-6L7 20H4l6.4-8.4L4 4Z"/>',
|
|
144
|
+
together:'<path d="M8 5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7Zm8 7a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7ZM10.5 12.5l3-1.5" fill="none" stroke="currentColor" stroke-width="2"/>',
|
|
145
|
+
};
|
|
146
|
+
function logo(name,color){const m=MARK[name]||`<text x="12" y="16" text-anchor="middle" font-size="13" font-weight="800" fill="currentColor">${(name[0]||"?").toUpperCase()}</text>`;
|
|
147
|
+
return `<div class="tile" style="background:${hexa(color,.16)};color:${color}"><svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">${m}</svg></div>`}
|
|
148
|
+
function hexa(h,a){const n=h.replace("#","");const x=parseInt(n.length===3?n.split("").map(c=>c+c).join(""):n,16);return `rgba(${(x>>16)&255},${(x>>8)&255},${x&255},${a})`}
|
|
149
|
+
|
|
150
|
+
async function boot(){
|
|
151
|
+
try{
|
|
152
|
+
const u=await(await api("/admin/usage")).json();
|
|
153
|
+
document.getElementById("gate").classList.add("hide");document.getElementById("app").classList.remove("hide");
|
|
154
|
+
document.getElementById("s_req").textContent=u.usage.requests;
|
|
155
|
+
document.getElementById("s_tok").textContent=`${u.usage.prompt_tokens}/${u.usage.completion_tokens}`;
|
|
156
|
+
document.getElementById("s_cost").textContent="$"+(u.usage.cost_usd||0).toFixed(4);
|
|
157
|
+
document.getElementById("badge_backend").textContent=u.backend;
|
|
158
|
+
document.getElementById("enc").textContent=u.encryption?"🔒 encrypted at rest":"⚠ encryption off";
|
|
159
|
+
renderHarnesses();refreshProviders();refreshTokens();refreshAliases();refreshLogs();
|
|
160
|
+
}catch(e){document.getElementById("gateerr").textContent="Invalid admin token."}
|
|
161
|
+
}
|
|
162
|
+
async function renderHarnesses(){
|
|
163
|
+
const d=await(await api("/admin/harnesses")).json();
|
|
164
|
+
document.getElementById("harngrid").innerHTML=d.harnesses.map(h=>{
|
|
165
|
+
const chips=h.auth.map(a=>{
|
|
166
|
+
const cls=a.connected?"badge on":(a.ready?"badge":"badge");
|
|
167
|
+
const tag=a.connected?"✓ connected":(a.ready?"connect":"soon");
|
|
168
|
+
return `<span class="${cls}" title="${a.ready?'available now':'coming soon'}">${a.label} · ${tag}</span>`}).join("");
|
|
169
|
+
return `<div class="prov"><div class="top">${logo(h.provider,h.color)}<div style="flex:1"><h3>${h.label}</h3><div class="ep">runs on ${h.provider}</div></div>
|
|
170
|
+
${h.configured?'<span class="pill ok">ready</span>':'<span class="pill no">connect a key</span>'}</div>
|
|
171
|
+
<div class="badges">${chips}</div>
|
|
172
|
+
<div class="models mono" style="font-size:11px">${h.install}</div>
|
|
173
|
+
<div class="row"><button class="sm" onclick="openConnect('h_${h.name}')">Connect API key</button></div>
|
|
174
|
+
<div class="connect" id="c_h_${h.name}">
|
|
175
|
+
<input id="k_h_${h.name}" type="password" placeholder="${h.provider} API key"/>
|
|
176
|
+
<div class="row"><button class="sm" onclick="connectHarness('${h.name}','${h.provider}')">Save</button><button class="ghost sm" onclick="openConnect('h_${h.name}')">Cancel</button></div>
|
|
177
|
+
</div></div>`}).join("");
|
|
178
|
+
}
|
|
179
|
+
async function connectHarness(name,provider){const key=document.getElementById("k_h_"+name).value.trim();if(!key)return;
|
|
180
|
+
await api("/admin/providers",{method:"POST",body:JSON.stringify({provider,secret:key,kind:"api_key"})});renderHarnesses();refreshProviders()}
|
|
181
|
+
async function refreshProviders(){
|
|
182
|
+
const d=await(await api("/admin/catalog")).json();const g=document.getElementById("provgrid");
|
|
183
|
+
let connected=0;
|
|
184
|
+
g.innerHTML=d.providers.map(p=>{const on=p.via_env||p.via_store;if(on)connected++;
|
|
185
|
+
const how=p.via_store?`stored ${p.cred_kind}`:(p.via_env?"env":"");
|
|
186
|
+
return `<div class="prov"><div class="top">${logo(p.name,p.color)}<div style="flex:1"><h3>${p.label}</h3><div class="ep">${p.shape} · ${p.name}</div></div>
|
|
187
|
+
${on?'<span class="pill ok">on</span>':'<span class="pill no">off</span>'}</div>
|
|
188
|
+
<div class="badges">${p.kinds.map(k=>`<span class="badge">${k}</span>`).join("")}${on?`<span class="badge on">${how}</span>`:""}</div>
|
|
189
|
+
<div class="models">${(p.models||[]).slice(0,2).join(" · ")}</div>
|
|
190
|
+
<div class="row">${p.via_store?`<button class="danger sm" onclick="disconnect('${p.cred_id}')">Disconnect</button>`:`<button class="sm" onclick="openConnect('${p.name}')">${p.via_env?"Override key":"Connect"}</button>`}</div>
|
|
191
|
+
<div class="connect" id="c_${p.name}">
|
|
192
|
+
<input id="k_${p.name}" type="password" placeholder="${p.name} API key${p.kinds.includes('oauth')?' / OAuth token':''}"/>
|
|
193
|
+
<div class="row">${p.kinds.length>1?`<select id="kind_${p.name}">${p.kinds.map(k=>`<option value="${k}">${k}</option>`).join("")}</select>`:`<input id="kind_${p.name}" type="hidden" value="api_key"/>`}
|
|
194
|
+
<button class="sm" onclick="connect('${p.name}')">Save</button><button class="ghost sm" onclick="openConnect('${p.name}')">Cancel</button></div>
|
|
195
|
+
</div></div>`}).join("");
|
|
196
|
+
document.getElementById("s_prov").textContent=connected;
|
|
197
|
+
document.getElementById("enc2")&&0;
|
|
198
|
+
}
|
|
199
|
+
function openConnect(n){document.getElementById("c_"+n).classList.toggle("open")}
|
|
200
|
+
async function connect(n){const key=document.getElementById("k_"+n).value.trim();if(!key)return;
|
|
201
|
+
const kindEl=document.getElementById("kind_"+n);const kind=kindEl?kindEl.value:"api_key";
|
|
202
|
+
await api("/admin/providers",{method:"POST",body:JSON.stringify({provider:n,secret:key,kind})});refreshProviders()}
|
|
203
|
+
async function disconnect(id){await api("/admin/providers/"+id,{method:"DELETE"});refreshProviders()}
|
|
204
|
+
|
|
205
|
+
async function refreshTokens(){const d=await(await api("/admin/tokens")).json();
|
|
206
|
+
document.getElementById("toks").innerHTML=d.tokens.map(t=>`<tr><td class="mono">${t.id}</td><td>${t.label||""}</td><td class="mono">${t.masked||""}</td><td class="mono">${(t.scope||[]).join(", ")}</td><td class="mono">${t.budget_usd?('$'+(t.spent_usd||0).toFixed(4)+' / $'+(+t.budget_usd).toFixed(2)):'—'}</td><td>${t.revoked?'<span class="pill no">revoked</span>':'<span class="pill ok">active</span>'}</td><td>${t.revoked?"":`<button class="danger sm" onclick="revokeTok('${t.id}')">revoke</button>`}</td></tr>`).join("")}
|
|
207
|
+
async function mintToken(){const body={label:val("tk_label")||"machine",scope:(val("tk_scope")||"*").split(",").map(s=>s.trim()),ttl_seconds:parseInt(val("tk_ttl"))||null,budget_usd:parseFloat(val("tk_budget"))||null};
|
|
208
|
+
const d=await(await api("/admin/tokens",{method:"POST",body:JSON.stringify(body)})).json();
|
|
209
|
+
const el=document.getElementById("tk_out");el.classList.remove("hide");el.innerHTML=`<b>Token (shown once):</b><br/><span class="mono">${d.token}</span>`;refreshTokens()}
|
|
210
|
+
async function revokeTok(id){await api("/admin/tokens/"+id,{method:"DELETE"});refreshTokens()}
|
|
211
|
+
|
|
212
|
+
async function refreshAliases(){const m=(await(await api("/admin/aliases")).json()).map;
|
|
213
|
+
document.getElementById("aliases").innerHTML=Object.entries(m).map(([k,v])=>`<tr><td class="mono">${k}</td><td class="mono">${v}</td><td><button class="danger sm" onclick="rmAlias('${k}')">remove</button></td></tr>`).join("")||'<tr><td class="muted" colspan="3">No model routes.</td></tr>'}
|
|
214
|
+
async function setAlias(){const m=(await(await api("/admin/aliases")).json()).map;m[val("al_match")||"*"]=val("al_target")||"mock";await api("/admin/aliases",{method:"PUT",body:JSON.stringify({map:m})});document.getElementById("al_match").value="";document.getElementById("al_target").value="";refreshAliases()}
|
|
215
|
+
async function rmAlias(k){const m=(await(await api("/admin/aliases")).json()).map;delete m[k];await api("/admin/aliases",{method:"PUT",body:JSON.stringify({map:m})});refreshAliases()}
|
|
216
|
+
|
|
217
|
+
async function refreshLogs(){const d=await(await api("/admin/logs?limit=80")).json();
|
|
218
|
+
document.getElementById("logs").innerHTML=d.logs.map(g=>`<tr><td class="mono">${new Date(g.ts_ms).toLocaleTimeString()}</td><td>${g.token_label||""}</td><td>${g.provider||""}</td><td class="mono">${(g.model||"").slice(0,24)}</td><td>${g.status||""}</td><td>${g.prompt_tokens??"-"}</td><td>${g.completion_tokens??"-"}</td><td>${g.cost_usd?"$"+g.cost_usd.toFixed(4):"-"}</td><td>${g.latency_ms||""}</td></tr>`).join("")}
|
|
219
|
+
|
|
220
|
+
if(A())boot();
|
|
221
|
+
setInterval(()=>{if(A()&&!document.getElementById("app").classList.contains("hide")){refreshLogs()}},4000);
|
|
222
|
+
</script>
|
|
223
|
+
</body>
|
|
224
|
+
</html>
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "proxyagent"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "Run any agent (Claude, Codex, custom) on any machine — with no API key on the machine. A secure, self-hosted proxy for models and tools."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -165,3 +165,26 @@ def test_model_remap_reroutes_provider():
|
|
|
165
165
|
_aliases.set_map({"gpt-4o": "anthropic:mock"})
|
|
166
166
|
assert remap("openai", "gpt-4o") == ("anthropic", "mock")
|
|
167
167
|
assert remap("openai", "gpt-4o-mini") == ("openai", "gpt-4o-mini") # no match
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_budget_exhaustion_returns_402():
|
|
171
|
+
c = _client()
|
|
172
|
+
r = c.post("/admin/tokens", headers=ADMIN, json={"scope": ["*"], "budget_usd": 0.001})
|
|
173
|
+
tok, tid = r.json()["token"], r.json()["id"]
|
|
174
|
+
# under budget (mock costs $0) → ok
|
|
175
|
+
assert c.post("/anthropic/v1/messages", headers={"x-api-key": tok},
|
|
176
|
+
json={"model": "mock", "messages": [{"role": "user", "content": "hi"}]}).status_code == 200
|
|
177
|
+
# record spend over the cap, then the next call is blocked
|
|
178
|
+
c.app.state.store.log_request(token_id=tid, provider="anthropic", model="x", status=200, cost_usd=0.05)
|
|
179
|
+
assert c.post("/anthropic/v1/messages", headers={"x-api-key": tok},
|
|
180
|
+
json={"model": "mock", "messages": []}).status_code == 402
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_harness_catalog():
|
|
184
|
+
c = _client()
|
|
185
|
+
h = c.get("/admin/harnesses", headers=ADMIN).json()["harnesses"]
|
|
186
|
+
names = {x["name"] for x in h}
|
|
187
|
+
assert {"claude-code", "codex", "gemini-cli"} <= names
|
|
188
|
+
cc = next(x for x in h if x["name"] == "claude-code")
|
|
189
|
+
assert {a["mode"] for a in cc["auth"]} == {"api_key", "oauth", "bedrock", "vertex"}
|
|
190
|
+
assert any(a["mode"] == "api_key" and a["ready"] for a in cc["auth"])
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="utf-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
-
<title>proxyagent</title>
|
|
7
|
-
<style>
|
|
8
|
-
:root { --bg:#0b0c0e; --panel:#15171a; --line:#23262b; --txt:#e7e9ec; --dim:#8a9099; --grn:#34d39e; --red:#f87171; --yel:#fbbf24; }
|
|
9
|
-
* { box-sizing:border-box; } body { margin:0; background:var(--bg); color:var(--txt); font:14px/1.5 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto; }
|
|
10
|
-
a { color:var(--grn); } code,.mono { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
|
|
11
|
-
header { display:flex; align-items:center; justify-content:space-between; padding:18px 28px; border-bottom:1px solid var(--line); }
|
|
12
|
-
.brand { display:flex; align-items:center; gap:10px; font-weight:600; font-size:16px; }
|
|
13
|
-
.dot { width:9px; height:9px; border-radius:50%; background:var(--grn); box-shadow:0 0 10px 1px rgba(52,211,158,.5); }
|
|
14
|
-
main { max-width:1100px; margin:0 auto; padding:28px; }
|
|
15
|
-
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:14px; margin-bottom:26px; }
|
|
16
|
-
.card { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:18px; }
|
|
17
|
-
.stat .n { font-size:30px; font-weight:600; } .stat .l { color:var(--dim); font-size:11px; text-transform:uppercase; letter-spacing:.08em; margin-top:4px; }
|
|
18
|
-
h2 { font-size:13px; text-transform:uppercase; letter-spacing:.08em; color:var(--dim); margin:26px 0 12px; }
|
|
19
|
-
table { width:100%; border-collapse:collapse; } th,td { text-align:left; padding:9px 10px; border-bottom:1px solid var(--line); font-size:13px; }
|
|
20
|
-
th { color:var(--dim); font-weight:500; font-size:11px; text-transform:uppercase; letter-spacing:.06em; }
|
|
21
|
-
input,button,select { font:inherit; border-radius:9px; border:1px solid var(--line); background:#0e1013; color:var(--txt); padding:8px 11px; }
|
|
22
|
-
button { background:var(--grn); color:#04130d; border:none; font-weight:600; cursor:pointer; } button.ghost { background:transparent; color:var(--dim); border:1px solid var(--line); }
|
|
23
|
-
button:hover { filter:brightness(1.08); } .row { display:flex; gap:9px; flex-wrap:wrap; align-items:center; }
|
|
24
|
-
.pill { padding:2px 9px; border-radius:99px; font-size:11px; } .pill.ok { background:rgba(52,211,158,.12); color:var(--grn); } .pill.no { background:rgba(248,113,113,.12); color:var(--red); }
|
|
25
|
-
.gate { max-width:420px; margin:90px auto; text-align:center; } .gate input { width:100%; margin:14px 0; }
|
|
26
|
-
.tok { background:#0e1013; border:1px dashed var(--grn); padding:12px; border-radius:10px; word-break:break-all; margin-top:12px; }
|
|
27
|
-
.hide { display:none; }
|
|
28
|
-
</style>
|
|
29
|
-
</head>
|
|
30
|
-
<body>
|
|
31
|
-
<div id="gate" class="gate">
|
|
32
|
-
<div class="brand" style="justify-content:center"><span class="dot"></span> proxyagent</div>
|
|
33
|
-
<p style="color:var(--dim)">Paste your admin token (printed by <code>proxyagent serve</code>).</p>
|
|
34
|
-
<input id="admintok" type="password" placeholder="pa_admin_…" />
|
|
35
|
-
<button onclick="saveAdmin()">Open dashboard</button>
|
|
36
|
-
<p id="gateerr" style="color:var(--red)"></p>
|
|
37
|
-
</div>
|
|
38
|
-
|
|
39
|
-
<div id="app" class="hide">
|
|
40
|
-
<header>
|
|
41
|
-
<div class="brand"><span class="dot"></span> proxyagent</div>
|
|
42
|
-
<div class="row"><span id="provs" style="color:var(--dim)"></span><button class="ghost" onclick="logout()">Sign out</button></div>
|
|
43
|
-
</header>
|
|
44
|
-
<main>
|
|
45
|
-
<div class="grid">
|
|
46
|
-
<div class="card stat"><div class="n" id="s_req">0</div><div class="l">Requests</div></div>
|
|
47
|
-
<div class="card stat"><div class="n" id="s_in">0</div><div class="l">Input tokens</div></div>
|
|
48
|
-
<div class="card stat"><div class="n" id="s_out">0</div><div class="l">Output tokens</div></div>
|
|
49
|
-
<div class="card stat"><div class="n" id="s_cost" style="color:var(--grn)">$0</div><div class="l">Cost</div></div>
|
|
50
|
-
<div class="card stat"><div class="n" id="s_tools">0</div><div class="l">Proxied tools</div></div>
|
|
51
|
-
</div>
|
|
52
|
-
|
|
53
|
-
<h2>Mint a machine token</h2>
|
|
54
|
-
<div class="card">
|
|
55
|
-
<div class="row">
|
|
56
|
-
<input id="t_label" placeholder="label (e.g. macbook-01)" />
|
|
57
|
-
<input id="t_scope" placeholder="scope: * or anthropic:claude-*" style="flex:1" />
|
|
58
|
-
<input id="t_ttl" type="number" placeholder="ttl (s)" style="width:110px" />
|
|
59
|
-
<button onclick="mint()">Mint token</button>
|
|
60
|
-
</div>
|
|
61
|
-
<div id="minted" class="tok hide"></div>
|
|
62
|
-
<p style="color:var(--dim);margin:10px 0 0">The machine holds only this token — never a real key. Revoke anytime.</p>
|
|
63
|
-
</div>
|
|
64
|
-
|
|
65
|
-
<h2>Machine tokens</h2>
|
|
66
|
-
<div class="card"><table><thead><tr><th>ID</th><th>Label</th><th>Token</th><th>Scope</th><th>Status</th><th></th></tr></thead><tbody id="toks"></tbody></table></div>
|
|
67
|
-
|
|
68
|
-
<h2>Recent requests <span style="color:var(--dim);font-weight:400;text-transform:none;letter-spacing:0">· live</span></h2>
|
|
69
|
-
<div class="card"><table><thead><tr><th>Time</th><th>Token</th><th>Provider</th><th>Model</th><th>Status</th><th>In</th><th>Out</th><th>ms</th></tr></thead><tbody id="logs"></tbody></table></div>
|
|
70
|
-
</main>
|
|
71
|
-
</div>
|
|
72
|
-
|
|
73
|
-
<script>
|
|
74
|
-
const A = () => localStorage.getItem("pa_admin");
|
|
75
|
-
const H = () => ({ "x-admin-token": A(), "content-type": "application/json" });
|
|
76
|
-
async function api(path, opts={}) { const r = await fetch(path, { ...opts, headers: { ...H(), ...(opts.headers||{}) } }); if (r.status===401) { logout(); throw new Error("unauthorized"); } return r; }
|
|
77
|
-
|
|
78
|
-
function saveAdmin() { const v = document.getElementById("admintok").value.trim(); if (!v) return; localStorage.setItem("pa_admin", v); boot(); }
|
|
79
|
-
function logout() { localStorage.removeItem("pa_admin"); document.getElementById("app").classList.add("hide"); document.getElementById("gate").classList.remove("hide"); }
|
|
80
|
-
|
|
81
|
-
async function boot() {
|
|
82
|
-
try {
|
|
83
|
-
const u = await (await api("/admin/usage")).json();
|
|
84
|
-
document.getElementById("gate").classList.add("hide");
|
|
85
|
-
document.getElementById("app").classList.remove("hide");
|
|
86
|
-
document.getElementById("s_req").textContent = u.usage.requests;
|
|
87
|
-
document.getElementById("s_in").textContent = u.usage.prompt_tokens;
|
|
88
|
-
document.getElementById("s_out").textContent = u.usage.completion_tokens;
|
|
89
|
-
document.getElementById("s_cost").textContent = "$" + (u.usage.cost_usd || 0).toFixed(4);
|
|
90
|
-
document.getElementById("s_tools").textContent = (u.tools||[]).length;
|
|
91
|
-
document.getElementById("provs").textContent = `${u.backend||"sqlite"} · providers: ` + ((u.providers||[]).join(", ") || "none");
|
|
92
|
-
refreshTokens(); refreshLogs();
|
|
93
|
-
} catch (e) { document.getElementById("gateerr").textContent = "Invalid admin token."; }
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async function refreshTokens() {
|
|
97
|
-
const d = await (await api("/admin/tokens")).json();
|
|
98
|
-
document.getElementById("toks").innerHTML = d.tokens.map(t => `
|
|
99
|
-
<tr><td class="mono">${t.id}</td><td>${t.label||""}</td><td class="mono">${t.masked||""}</td>
|
|
100
|
-
<td class="mono">${(t.scope||[]).join(", ")}</td>
|
|
101
|
-
<td>${t.revoked?'<span class="pill no">revoked</span>':'<span class="pill ok">active</span>'}</td>
|
|
102
|
-
<td>${t.revoked?"":`<button class="ghost" onclick="revoke('${t.id}')">revoke</button>`}</td></tr>`).join("");
|
|
103
|
-
}
|
|
104
|
-
async function refreshLogs() {
|
|
105
|
-
const d = await (await api("/admin/logs?limit=60")).json();
|
|
106
|
-
document.getElementById("logs").innerHTML = d.logs.map(g => `
|
|
107
|
-
<tr><td class="mono">${new Date(g.ts_ms).toLocaleTimeString()}</td><td>${g.token_label||""}</td>
|
|
108
|
-
<td>${g.provider||""}</td><td class="mono">${(g.model||"").slice(0,26)}</td>
|
|
109
|
-
<td>${g.status||""}</td><td>${g.prompt_tokens??"-"}</td><td>${g.completion_tokens??"-"}</td><td>${g.latency_ms||""}</td></tr>`).join("");
|
|
110
|
-
}
|
|
111
|
-
async function mint() {
|
|
112
|
-
const body = { label: val("t_label")||"machine", scope: (val("t_scope")||"*").split(",").map(s=>s.trim()), ttl_seconds: parseInt(val("t_ttl"))||null };
|
|
113
|
-
const d = await (await api("/admin/tokens", { method:"POST", body: JSON.stringify(body) })).json();
|
|
114
|
-
const el = document.getElementById("minted"); el.classList.remove("hide");
|
|
115
|
-
el.innerHTML = `<b>Token (shown once):</b><br/><span class="mono">${d.token}</span>`;
|
|
116
|
-
refreshTokens(); boot();
|
|
117
|
-
}
|
|
118
|
-
async function revoke(id) { await api("/admin/tokens/"+id, { method:"DELETE" }); refreshTokens(); }
|
|
119
|
-
function val(id){ return document.getElementById(id).value.trim(); }
|
|
120
|
-
|
|
121
|
-
if (A()) boot();
|
|
122
|
-
setInterval(() => { if (A() && !document.getElementById("app").classList.contains("hide")) { refreshLogs(); } }, 4000);
|
|
123
|
-
</script>
|
|
124
|
-
</body>
|
|
125
|
-
</html>
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|