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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxyagent
3
- Version: 0.5.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,7 +16,7 @@ from typing import Optional
16
16
 
17
17
  from .harness import run # noqa: F401 (the headline SDK call)
18
18
 
19
- __version__ = "0.5.0"
19
+ __version__ = "0.6.0"
20
20
  __all__ = ["run", "serve", "create_app", "Config", "Admin", "__version__"]
21
21
 
22
22
 
@@ -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
- """Auth headers for an upstream call. A stored credential (proxy_agent_keys) wins
21
- over the env key; returns ({}, False) when nothing is configured."""
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
- auth, ok = resolve_auth(provider, store)
74
- if not ok:
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
- async with client.stream("POST", url, headers=headers, json=body) as resp:
97
- status = resp.status_code
98
- async for chunk in resp.aiter_raw():
99
- # Best-effort usage capture from the final SSE event.
100
- text = chunk.decode("utf-8", "ignore")
101
- if '"output_tokens"' in text or '"completion_tokens"' in text:
102
- try:
103
- for line in text.splitlines():
104
- if line.startswith("data:"):
105
- d = json.loads(line[5:].strip())
106
- usage = d.get("usage") or (d.get("message") or {}).get("usage") or {}
107
- ptok = usage.get("input_tokens") or usage.get("prompt_tokens") or ptok
108
- ctok = usage.get("output_tokens") or usage.get("completion_tokens") or ctok
109
- except Exception:
110
- pass
111
- yield chunk
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
- resp = await client.post(url, headers=headers, json=body)
119
- try:
120
- payload = resp.json()
121
- except Exception:
122
- payload = {"error": resp.text}
123
- ptok, ctok = _extract_usage(provider.shape, payload if isinstance(payload, dict) else {})
124
- _log(resp.status_code, ptok, ctok, None if resp.is_success else str(payload)[:300])
125
- return resp.status_code, {"content-type": "application/json"}, payload, None
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
- stored = {c["provider"]: c for c in store.list_credentials() if c["active"]}
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
- cred = stored.get(name)
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(cred),
260
- "cred_id": cred["id"] if cred else None,
261
- "cred_kind": cred["kind"] if cred else None,
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
- try:
52
- self.db.execute("ALTER TABLE proxy_agent_tokens ADD COLUMN budget_usd DOUBLE PRECISION")
53
- except Exception:
54
- pass
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=True):
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=?", (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 get_credential(self, provider):
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:#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}
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:14px 26px;border-bottom:1px solid var(--line);background:rgba(10,11,13,.8);backdrop-filter:blur(12px)}
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: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}
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: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}
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
- 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>`}
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||p.via_store;if(on)connected++;
185
- const how=p.via_store?`stored ${p.cred_kind}`:(p.via_env?"env":"");
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("")}${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>
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
- <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>
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.5.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