proxyagent 0.5.0__tar.gz → 0.6.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.5.0 → proxyagent-0.6.0}/PKG-INFO +13 -1
- {proxyagent-0.5.0 → proxyagent-0.6.0}/README.md +12 -0
- {proxyagent-0.5.0 → proxyagent-0.6.0}/proxyagent/__init__.py +1 -1
- {proxyagent-0.5.0 → proxyagent-0.6.0}/proxyagent/providers.py +69 -41
- {proxyagent-0.5.0 → proxyagent-0.6.0}/proxyagent/server.py +8 -5
- {proxyagent-0.5.0 → proxyagent-0.6.0}/proxyagent/store.py +37 -20
- {proxyagent-0.5.0 → proxyagent-0.6.0}/proxyagent/ui/index.html +55 -45
- {proxyagent-0.5.0 → proxyagent-0.6.0}/pyproject.toml +1 -1
- {proxyagent-0.5.0 → proxyagent-0.6.0}/tests/test_proxy.py +14 -0
- {proxyagent-0.5.0 → proxyagent-0.6.0}/.gitignore +0 -0
- {proxyagent-0.5.0 → proxyagent-0.6.0}/proxyagent/aliases.py +0 -0
- {proxyagent-0.5.0 → proxyagent-0.6.0}/proxyagent/cli.py +0 -0
- {proxyagent-0.5.0 → proxyagent-0.6.0}/proxyagent/config.py +0 -0
- {proxyagent-0.5.0 → proxyagent-0.6.0}/proxyagent/crypto.py +0 -0
- {proxyagent-0.5.0 → proxyagent-0.6.0}/proxyagent/db.py +0 -0
- {proxyagent-0.5.0 → proxyagent-0.6.0}/proxyagent/harness.py +0 -0
- {proxyagent-0.5.0 → proxyagent-0.6.0}/proxyagent/pricing.py +0 -0
- {proxyagent-0.5.0 → proxyagent-0.6.0}/proxyagent/security.py +0 -0
- {proxyagent-0.5.0 → proxyagent-0.6.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.6.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>
|
|
@@ -183,6 +183,18 @@ Bedrock / Vertex / OAuth-refresh (the cloud-credential paths enterprises actuall
|
|
|
183
183
|
being built out — the proxy holds the AWS/GCP creds and signs upstream, so the machine needs
|
|
184
184
|
none. The model providers below are the *backends* for model-agnostic harnesses (aider, Cline…).
|
|
185
185
|
|
|
186
|
+
## Credential pools & failover
|
|
187
|
+
A provider isn't one key — it's a **pool**. Add as many credentials as you want, across
|
|
188
|
+
auth types (several API keys, OAuth tokens, …); each is managed individually in the
|
|
189
|
+
dashboard. The proxy rotates through the pool, **failing over** to the next credential on
|
|
190
|
+
any `429` / `5xx` — so a rate-limited or dead key never takes you down.
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
proxyagent provider add anthropic --key sk-ant-aaa # additive — builds the pool
|
|
194
|
+
proxyagent provider add anthropic --key sk-ant-bbb
|
|
195
|
+
proxyagent provider add anthropic --key <oauth> --kind oauth
|
|
196
|
+
```
|
|
197
|
+
|
|
186
198
|
## Per-token budgets
|
|
187
199
|
Cap what any token can spend; once its summed cost crosses the cap, the proxy returns **402**.
|
|
188
200
|
```bash
|
|
@@ -151,6 +151,18 @@ Bedrock / Vertex / OAuth-refresh (the cloud-credential paths enterprises actuall
|
|
|
151
151
|
being built out — the proxy holds the AWS/GCP creds and signs upstream, so the machine needs
|
|
152
152
|
none. The model providers below are the *backends* for model-agnostic harnesses (aider, Cline…).
|
|
153
153
|
|
|
154
|
+
## Credential pools & failover
|
|
155
|
+
A provider isn't one key — it's a **pool**. Add as many credentials as you want, across
|
|
156
|
+
auth types (several API keys, OAuth tokens, …); each is managed individually in the
|
|
157
|
+
dashboard. The proxy rotates through the pool, **failing over** to the next credential on
|
|
158
|
+
any `429` / `5xx` — so a rate-limited or dead key never takes you down.
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
proxyagent provider add anthropic --key sk-ant-aaa # additive — builds the pool
|
|
162
|
+
proxyagent provider add anthropic --key sk-ant-bbb
|
|
163
|
+
proxyagent provider add anthropic --key <oauth> --kind oauth
|
|
164
|
+
```
|
|
165
|
+
|
|
154
166
|
## Per-token budgets
|
|
155
167
|
Cap what any token can spend; once its summed cost crosses the cap, the proxy returns **402**.
|
|
156
168
|
```bash
|
|
@@ -16,19 +16,35 @@ from . import pricing
|
|
|
16
16
|
from .config import Config, PROVIDERS
|
|
17
17
|
from .store import Store, now_ms
|
|
18
18
|
|
|
19
|
+
FAILOVER_STATUS = {429, 500, 502, 503, 504, 529}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _headers_for(provider, secret: str, kind: str) -> dict:
|
|
23
|
+
if kind != "api_key": # oauth / bearer
|
|
24
|
+
return {"Authorization": f"Bearer {secret}", **provider.extra_headers}
|
|
25
|
+
if provider.auth_style == "x-api-key":
|
|
26
|
+
return {"x-api-key": secret, **provider.extra_headers}
|
|
27
|
+
return {"Authorization": f"Bearer {secret}", **provider.extra_headers}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_candidates(provider, store: Store | None) -> list[dict]:
|
|
31
|
+
"""Every usable auth header-set for a provider, in rotation order: the stored pool
|
|
32
|
+
(api_key then oauth creds), then the env key as a last resort. The forwarder tries
|
|
33
|
+
them in turn, rotating past any that 429/5xx — that's the failover."""
|
|
34
|
+
out: list[dict] = []
|
|
35
|
+
if store:
|
|
36
|
+
for c in store.get_credentials(provider.name, kind="api_key"):
|
|
37
|
+
out.append(_headers_for(provider, c["secret"], "api_key"))
|
|
38
|
+
for c in store.get_credentials(provider.name, kind="oauth"):
|
|
39
|
+
out.append(_headers_for(provider, c["secret"], "oauth"))
|
|
40
|
+
if provider.key:
|
|
41
|
+
out.append(provider.auth_headers())
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
|
|
19
45
|
def resolve_auth(provider, store: Store | None) -> tuple[dict, bool]:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
cred = store.get_credential(provider.name) if store else None
|
|
23
|
-
if cred:
|
|
24
|
-
secret, kind = cred["secret"], cred["kind"]
|
|
25
|
-
if kind == "oauth":
|
|
26
|
-
return {"Authorization": f"Bearer {secret}", **provider.extra_headers}, True
|
|
27
|
-
if provider.auth_style == "x-api-key":
|
|
28
|
-
return {"x-api-key": secret, **provider.extra_headers}, True
|
|
29
|
-
return {"Authorization": f"Bearer {secret}", **provider.extra_headers}, True
|
|
30
|
-
headers = provider.auth_headers()
|
|
31
|
-
return headers, bool(headers)
|
|
46
|
+
cands = resolve_candidates(provider, store)
|
|
47
|
+
return (cands[0], True) if cands else ({}, False)
|
|
32
48
|
|
|
33
49
|
|
|
34
50
|
def scope_allows(scope: list[str], provider: str, model: str) -> bool:
|
|
@@ -70,15 +86,14 @@ async def forward(
|
|
|
70
86
|
return 200, {"content-type": "text/event-stream"}, _mock_stream(provider.shape, payload), None
|
|
71
87
|
return 200, {"content-type": "application/json"}, payload, None
|
|
72
88
|
|
|
73
|
-
|
|
74
|
-
if not
|
|
89
|
+
candidates = resolve_candidates(provider, store)
|
|
90
|
+
if not candidates:
|
|
75
91
|
return 502, {}, {"error": f"provider '{provider_name}' not configured on the proxy "
|
|
76
92
|
f"(set {provider.key_env} or `proxyagent provider add {provider_name}`)"}, None
|
|
77
93
|
|
|
78
94
|
url = provider.endpoint
|
|
79
|
-
headers = {"content-type": "application/json", **auth}
|
|
80
95
|
|
|
81
|
-
def _log(status, ptok, ctok, err=None):
|
|
96
|
+
def _log(status, ptok, ctok, err=None, attempt=0):
|
|
82
97
|
store.log_request(
|
|
83
98
|
token_id=token["id"], token_label=token.get("label"), provider=provider_name,
|
|
84
99
|
model=model, status=status, prompt_tokens=ptok, completion_tokens=ctok,
|
|
@@ -93,36 +108,49 @@ async def forward(
|
|
|
93
108
|
status = 200
|
|
94
109
|
try:
|
|
95
110
|
async with httpx.AsyncClient(timeout=config.request_timeout) as client:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
111
|
+
for i, auth in enumerate(candidates):
|
|
112
|
+
async with client.stream(
|
|
113
|
+
"POST", url, headers={"content-type": "application/json", **auth}, json=body
|
|
114
|
+
) as resp:
|
|
115
|
+
status = resp.status_code
|
|
116
|
+
if status in FAILOVER_STATUS and i < len(candidates) - 1:
|
|
117
|
+
await resp.aread() # drain + rotate to the next credential
|
|
118
|
+
continue
|
|
119
|
+
async for chunk in resp.aiter_raw():
|
|
120
|
+
text = chunk.decode("utf-8", "ignore")
|
|
121
|
+
if '"output_tokens"' in text or '"completion_tokens"' in text:
|
|
122
|
+
try:
|
|
123
|
+
for line in text.splitlines():
|
|
124
|
+
if line.startswith("data:"):
|
|
125
|
+
d = json.loads(line[5:].strip())
|
|
126
|
+
usage = d.get("usage") or (d.get("message") or {}).get("usage") or {}
|
|
127
|
+
ptok = usage.get("input_tokens") or usage.get("prompt_tokens") or ptok
|
|
128
|
+
ctok = usage.get("output_tokens") or usage.get("completion_tokens") or ctok
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
yield chunk
|
|
132
|
+
break
|
|
112
133
|
finally:
|
|
113
134
|
_log(status, ptok, ctok)
|
|
114
135
|
return 200, {"content-type": "text/event-stream"}, _gen(), None
|
|
115
136
|
|
|
116
|
-
# Non-streaming.
|
|
137
|
+
# Non-streaming, with credential failover.
|
|
138
|
+
last_status, last_payload = 502, {"error": "all credentials failed"}
|
|
117
139
|
async with httpx.AsyncClient(timeout=config.request_timeout) as client:
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
140
|
+
for i, auth in enumerate(candidates):
|
|
141
|
+
resp = await client.post(url, headers={"content-type": "application/json", **auth}, json=body)
|
|
142
|
+
if resp.status_code in FAILOVER_STATUS and i < len(candidates) - 1:
|
|
143
|
+
last_status = resp.status_code
|
|
144
|
+
continue
|
|
145
|
+
try:
|
|
146
|
+
payload = resp.json()
|
|
147
|
+
except Exception:
|
|
148
|
+
payload = {"error": resp.text}
|
|
149
|
+
ptok, ctok = _extract_usage(provider.shape, payload if isinstance(payload, dict) else {})
|
|
150
|
+
_log(resp.status_code, ptok, ctok, None if resp.is_success else str(payload)[:300])
|
|
151
|
+
return resp.status_code, {"content-type": "application/json"}, payload, None
|
|
152
|
+
_log(last_status, None, None, "all credentials failed")
|
|
153
|
+
return last_status, {"content-type": "application/json"}, last_payload, None
|
|
126
154
|
|
|
127
155
|
|
|
128
156
|
# ------------------------------------------------------------------ #
|
|
@@ -247,18 +247,21 @@ def create_app(config: Config | None = None) -> FastAPI:
|
|
|
247
247
|
async def catalog(authorization: str | None = Header(None),
|
|
248
248
|
x_admin_token: str | None = Header(None)):
|
|
249
249
|
require_admin(authorization, x_admin_token)
|
|
250
|
-
|
|
250
|
+
pool: dict[str, list] = {}
|
|
251
|
+
for c in store.list_credentials():
|
|
252
|
+
if c["active"]:
|
|
253
|
+
pool.setdefault(c["provider"], []).append(c)
|
|
251
254
|
out = []
|
|
252
255
|
for name, prov in PROVIDERS.items():
|
|
253
256
|
meta = CATALOG.get(name, {})
|
|
254
|
-
|
|
257
|
+
creds = pool.get(name, [])
|
|
255
258
|
out.append({
|
|
256
259
|
"name": name, "label": meta.get("label", name.title()),
|
|
257
260
|
"kinds": meta.get("kinds", ["api_key"]), "color": meta.get("color", "#888"),
|
|
258
261
|
"models": meta.get("models", []), "shape": prov.shape,
|
|
259
|
-
"via_env": bool(prov.key), "via_store": bool(
|
|
260
|
-
"
|
|
261
|
-
|
|
262
|
+
"via_env": bool(prov.key), "via_store": bool(creds),
|
|
263
|
+
"creds": [{"id": c["id"], "kind": c["kind"], "masked": c.get("masked"),
|
|
264
|
+
"label": c.get("label")} for c in creds],
|
|
262
265
|
"endpoint": prov.endpoint,
|
|
263
266
|
})
|
|
264
267
|
return {"providers": out, "encryption": crypto.encryption_available()}
|
|
@@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS proxy_agent_tokens (
|
|
|
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',
|
|
29
29
|
secret TEXT NOT NULL, refresh TEXT, expires_ms BIGINT, label TEXT,
|
|
30
|
-
created_ms BIGINT, meta_json TEXT, active INTEGER NOT NULL DEFAULT 1
|
|
30
|
+
created_ms BIGINT, meta_json TEXT, active INTEGER NOT NULL DEFAULT 1, masked TEXT
|
|
31
31
|
);
|
|
32
32
|
CREATE TABLE IF NOT EXISTS proxy_agent_calls (
|
|
33
33
|
id TEXT PRIMARY KEY, ts_ms BIGINT, token_id TEXT, token_label TEXT,
|
|
@@ -48,10 +48,12 @@ class Store:
|
|
|
48
48
|
self.db.executescript(_SCHEMA)
|
|
49
49
|
self.backend = "postgres" if self.db.pg else "sqlite"
|
|
50
50
|
# migrate older DBs created before budget_usd existed
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
for stmt in ("ALTER TABLE proxy_agent_tokens ADD COLUMN budget_usd DOUBLE PRECISION",
|
|
52
|
+
"ALTER TABLE proxy_agent_keys ADD COLUMN masked TEXT"):
|
|
53
|
+
try:
|
|
54
|
+
self.db.execute(stmt)
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
55
57
|
|
|
56
58
|
# -- machine tokens ---------------------------------------------------- #
|
|
57
59
|
|
|
@@ -98,40 +100,55 @@ class Store:
|
|
|
98
100
|
# -- provider credentials (proxy_agent_keys) --------------------------- #
|
|
99
101
|
|
|
100
102
|
def add_credential(self, provider, secret, *, kind="api_key", refresh=None,
|
|
101
|
-
expires_ms=None, label=None, meta=None, replace=
|
|
103
|
+
expires_ms=None, label=None, meta=None, replace=False):
|
|
104
|
+
"""Add a credential to a provider's POOL. A provider can hold many credentials
|
|
105
|
+
across auth types (several api_keys, oauth tokens, bedrock, vertex…). replace=True
|
|
106
|
+
swaps out other creds of the SAME kind; default keeps them (for failover/rotation)."""
|
|
102
107
|
cid = "key_" + uuid.uuid4().hex[:12]
|
|
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
108
|
if replace:
|
|
106
|
-
self.db.execute("UPDATE proxy_agent_keys SET active=0 WHERE provider=?",
|
|
109
|
+
self.db.execute("UPDATE proxy_agent_keys SET active=0 WHERE provider=? AND kind=?",
|
|
110
|
+
(provider, kind))
|
|
107
111
|
self.db.execute(
|
|
108
112
|
"""INSERT INTO proxy_agent_keys
|
|
109
|
-
(id, provider, kind, secret, refresh, expires_ms, label, created_ms, meta_json, active)
|
|
110
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)""",
|
|
113
|
+
(id, provider, kind, secret, refresh, expires_ms, label, created_ms, meta_json, active, masked)
|
|
114
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)""",
|
|
111
115
|
(cid, provider, kind, crypto.encrypt(secret),
|
|
112
116
|
crypto.encrypt(refresh) if refresh else None, expires_ms, label, now_ms(),
|
|
113
|
-
json.dumps(meta or {})),
|
|
117
|
+
json.dumps(meta or {}), mask(secret)),
|
|
114
118
|
)
|
|
115
119
|
return cid
|
|
116
120
|
|
|
117
|
-
def
|
|
118
|
-
"""Active credential for a provider, decrypted. None → fall back to env."""
|
|
119
|
-
r = self.db.fetchone(
|
|
120
|
-
"SELECT * FROM proxy_agent_keys WHERE provider=? AND active=1 ORDER BY created_ms DESC",
|
|
121
|
-
(provider,))
|
|
122
|
-
if not r:
|
|
123
|
-
return None
|
|
121
|
+
def _decrypt_row(self, r):
|
|
124
122
|
r = dict(r)
|
|
125
123
|
r["secret"] = crypto.decrypt(r["secret"])
|
|
126
124
|
if r.get("refresh"):
|
|
127
125
|
r["refresh"] = crypto.decrypt(r["refresh"])
|
|
126
|
+
if r.get("meta_json"):
|
|
127
|
+
try:
|
|
128
|
+
r["meta"] = json.loads(r["meta_json"])
|
|
129
|
+
except Exception:
|
|
130
|
+
r["meta"] = {}
|
|
128
131
|
return r
|
|
129
132
|
|
|
133
|
+
def get_credential(self, provider, kind=None):
|
|
134
|
+
"""Most-recent active credential (optionally of a kind), decrypted."""
|
|
135
|
+
creds = self.get_credentials(provider, kind=kind)
|
|
136
|
+
return creds[-1] if creds else None
|
|
137
|
+
|
|
138
|
+
def get_credentials(self, provider, kind=None):
|
|
139
|
+
"""The provider's whole active pool, decrypted, oldest→newest (rotation order)."""
|
|
140
|
+
q = "SELECT * FROM proxy_agent_keys WHERE provider=? AND active=1"
|
|
141
|
+
args = [provider]
|
|
142
|
+
if kind:
|
|
143
|
+
q += " AND kind=?"
|
|
144
|
+
args.append(kind)
|
|
145
|
+
return [self._decrypt_row(r) for r in self.db.fetchall(q + " ORDER BY created_ms", tuple(args))]
|
|
146
|
+
|
|
130
147
|
def list_credentials(self):
|
|
131
148
|
rows = self.db.fetchall("SELECT * FROM proxy_agent_keys ORDER BY created_ms DESC")
|
|
132
149
|
# never return the secret material
|
|
133
150
|
return [{"id": r["id"], "provider": r["provider"], "kind": r["kind"],
|
|
134
|
-
"label": r["label"], "active": bool(r["active"]),
|
|
151
|
+
"label": r["label"], "active": bool(r["active"]), "masked": r.get("masked"),
|
|
135
152
|
"created_ms": r["created_ms"]} for r in rows]
|
|
136
153
|
|
|
137
154
|
def remove_credential(self, cid):
|
|
@@ -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>
|
|
@@ -143,8 +146,16 @@ const MARK={
|
|
|
143
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"/>',
|
|
144
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"/>',
|
|
145
148
|
};
|
|
146
|
-
|
|
147
|
-
|
|
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
|
+
}
|
|
148
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})`}
|
|
149
160
|
|
|
150
161
|
async function boot(){
|
|
@@ -181,20 +192,19 @@ async function connectHarness(name,provider){const key=document.getElementById("
|
|
|
181
192
|
async function refreshProviders(){
|
|
182
193
|
const d=await(await api("/admin/catalog")).json();const g=document.getElementById("provgrid");
|
|
183
194
|
let connected=0;
|
|
184
|
-
g.innerHTML=d.providers.map(p=>{const on=p.via_env||
|
|
185
|
-
const
|
|
195
|
+
g.innerHTML=d.providers.map(p=>{const creds=p.creds||[];const on=p.via_env||creds.length;if(on)connected++;
|
|
196
|
+
const credrows=creds.map(c=>`<div class="row" style="justify-content:space-between;gap:6px"><span class="badge on">${c.kind} · ${c.masked||""}</span><button class="danger sm" onclick="disconnect('${c.id}')">remove</button></div>`).join("");
|
|
186
197
|
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
198
|
${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("")}${
|
|
189
|
-
<div
|
|
190
|
-
<div class="row"
|
|
199
|
+
<div class="badges">${p.kinds.map(k=>`<span class="badge">${k}</span>`).join("")}${p.via_env?'<span class="badge on">env</span>':''}</div>
|
|
200
|
+
<div style="display:flex;flex-direction:column;gap:6px;margin:6px 0 11px">${credrows||'<span class="muted" style="font-size:11.5px">No keys yet</span>'}</div>
|
|
201
|
+
<div class="row"><button class="sm" onclick="openConnect('${p.name}')">+ Add credential</button></div>
|
|
191
202
|
<div class="connect" id="c_${p.name}">
|
|
192
|
-
|
|
193
|
-
<
|
|
194
|
-
|
|
203
|
+
${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"/>`}
|
|
204
|
+
<input id="k_${p.name}" type="password" placeholder="${p.name} key / token"/>
|
|
205
|
+
<div class="row"><button class="sm" onclick="connect('${p.name}')">Add</button><button class="ghost sm" onclick="openConnect('${p.name}')">Cancel</button></div>
|
|
195
206
|
</div></div>`}).join("");
|
|
196
207
|
document.getElementById("s_prov").textContent=connected;
|
|
197
|
-
document.getElementById("enc2")&&0;
|
|
198
208
|
}
|
|
199
209
|
function openConnect(n){document.getElementById("c_"+n).classList.toggle("open")}
|
|
200
210
|
async function connect(n){const key=document.getElementById("k_"+n).value.trim();if(!key)return;
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "proxyagent"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.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"
|
|
@@ -188,3 +188,17 @@ def test_harness_catalog():
|
|
|
188
188
|
cc = next(x for x in h if x["name"] == "claude-code")
|
|
189
189
|
assert {a["mode"] for a in cc["auth"]} == {"api_key", "oauth", "bedrock", "vertex"}
|
|
190
190
|
assert any(a["mode"] == "api_key" and a["ready"] for a in cc["auth"])
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_credential_pool_and_failover_order():
|
|
194
|
+
from proxyagent.providers import resolve_candidates
|
|
195
|
+
s = Store(":memory:")
|
|
196
|
+
s.add_credential("openai", "sk-1", kind="api_key") # additive by default →
|
|
197
|
+
s.add_credential("openai", "sk-2", kind="api_key") # a pool of two keys
|
|
198
|
+
creds = s.get_credentials("openai", kind="api_key")
|
|
199
|
+
assert [c["secret"] for c in creds] == ["sk-1", "sk-2"] # oldest→newest rotation order
|
|
200
|
+
cands = resolve_candidates(PROVIDERS["openai"], s)
|
|
201
|
+
assert cands[0]["Authorization"] == "Bearer sk-1"
|
|
202
|
+
assert cands[1]["Authorization"] == "Bearer sk-2" # failover tries #2 next
|
|
203
|
+
listed = s.list_credentials()
|
|
204
|
+
assert len(listed) == 2 and all("secret" not in c and c["masked"] for c in listed)
|
|
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
|
|
File without changes
|