proxyagent 0.5.1__tar.gz → 0.7.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.1
3
+ Version: 0.7.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>
@@ -178,10 +178,30 @@ is to centralise *all* of them so the machine running the harness holds only a `
178
178
  | **Codex** | OpenAI | API key · OAuth (ChatGPT) · Azure |
179
179
  | **Gemini CLI** | Google | API key · OAuth · Vertex |
180
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…).
181
+ Connect each mode in the dashboard's **Harnesses** tab (or `proxyagent provider add … --kind`).
182
+ **API key, OAuth, AWS Bedrock, and Azure are wired today** for Bedrock the proxy holds the AWS
183
+ credentials and **SigV4-signs** the Claude-on-Bedrock request itself, so the machine needs no
184
+ AWS at all. (Vertex lands next.) The model providers below are the *backends* for model-agnostic
185
+ harnesses (aider, Cline…).
186
+
187
+ ```bash
188
+ # the cloud-credential paths — the machine that runs the harness holds none of these:
189
+ proxyagent provider add anthropic --kind bedrock --key <AWS_SECRET> # + meta: access_key, region
190
+ proxyagent provider add openai --kind azure --key <AZURE_KEY> # + meta: endpoint
191
+ proxyagent provider add anthropic --kind oauth --key <OAUTH_TOKEN>
192
+ ```
193
+
194
+ ## Credential pools & failover
195
+ A provider isn't one key — it's a **pool**. Add as many credentials as you want, across
196
+ auth types (several API keys, OAuth tokens, …); each is managed individually in the
197
+ dashboard. The proxy rotates through the pool, **failing over** to the next credential on
198
+ any `429` / `5xx` — so a rate-limited or dead key never takes you down.
199
+
200
+ ```bash
201
+ proxyagent provider add anthropic --key sk-ant-aaa # additive — builds the pool
202
+ proxyagent provider add anthropic --key sk-ant-bbb
203
+ proxyagent provider add anthropic --key <oauth> --kind oauth
204
+ ```
185
205
 
186
206
  ## Per-token budgets
187
207
  Cap what any token can spend; once its summed cost crosses the cap, the proxy returns **402**.
@@ -146,10 +146,30 @@ is to centralise *all* of them so the machine running the harness holds only a `
146
146
  | **Codex** | OpenAI | API key · OAuth (ChatGPT) · Azure |
147
147
  | **Gemini CLI** | Google | API key · OAuth · Vertex |
148
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…).
149
+ Connect each mode in the dashboard's **Harnesses** tab (or `proxyagent provider add … --kind`).
150
+ **API key, OAuth, AWS Bedrock, and Azure are wired today** for Bedrock the proxy holds the AWS
151
+ credentials and **SigV4-signs** the Claude-on-Bedrock request itself, so the machine needs no
152
+ AWS at all. (Vertex lands next.) The model providers below are the *backends* for model-agnostic
153
+ harnesses (aider, Cline…).
154
+
155
+ ```bash
156
+ # the cloud-credential paths — the machine that runs the harness holds none of these:
157
+ proxyagent provider add anthropic --kind bedrock --key <AWS_SECRET> # + meta: access_key, region
158
+ proxyagent provider add openai --kind azure --key <AZURE_KEY> # + meta: endpoint
159
+ proxyagent provider add anthropic --kind oauth --key <OAUTH_TOKEN>
160
+ ```
161
+
162
+ ## Credential pools & failover
163
+ A provider isn't one key — it's a **pool**. Add as many credentials as you want, across
164
+ auth types (several API keys, OAuth tokens, …); each is managed individually in the
165
+ dashboard. The proxy rotates through the pool, **failing over** to the next credential on
166
+ any `429` / `5xx` — so a rate-limited or dead key never takes you down.
167
+
168
+ ```bash
169
+ proxyagent provider add anthropic --key sk-ant-aaa # additive — builds the pool
170
+ proxyagent provider add anthropic --key sk-ant-bbb
171
+ proxyagent provider add anthropic --key <oauth> --kind oauth
172
+ ```
153
173
 
154
174
  ## Per-token budgets
155
175
  Cap what any token can spend; once its summed cost crosses the cap, the proxy returns **402**.
@@ -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.1"
19
+ __version__ = "0.7.0"
20
20
  __all__ = ["run", "serve", "create_app", "Config", "Admin", "__version__"]
21
21
 
22
22
 
@@ -101,7 +101,7 @@ AUTH_LABELS = {"api_key": "API key", "oauth": "OAuth", "bedrock": "AWS Bedrock",
101
101
  "vertex": "Google Vertex", "azure": "Azure"}
102
102
  # Auth modes that are fully wired today (just a key swap). Others are surfaced in the
103
103
  # UI as "available" and built out (Bedrock SigV4 / Vertex token / OAuth refresh).
104
- AUTH_READY = {"api_key"}
104
+ AUTH_READY = {"api_key", "oauth", "bedrock", "azure"}
105
105
 
106
106
 
107
107
  @dataclass
@@ -12,23 +12,67 @@ import json
12
12
 
13
13
  import httpx
14
14
 
15
- from . import pricing
15
+ from . import pricing, signers
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)
48
+
49
+
50
+ def build_plans(provider, store: Store | None, body: dict) -> list[tuple]:
51
+ """Every way to fulfil this request, in rotation order, as (url, headers, body_bytes).
52
+ Each credential kind maps to its own upstream + signing: api_key/oauth the provider
53
+ endpoint; azure a custom deployment URL; bedrock → SigV4-signed Claude-on-Bedrock."""
54
+ plans: list[tuple] = []
55
+ raw = json.dumps(body).encode("utf-8")
56
+ JSON = {"content-type": "application/json"}
57
+ if store:
58
+ for c in store.get_credentials(provider.name):
59
+ kind, meta = c["kind"], (c.get("meta") or {})
60
+ if kind == "api_key":
61
+ plans.append((provider.endpoint, {**JSON, **_headers_for(provider, c["secret"], "api_key")}, raw))
62
+ elif kind == "oauth":
63
+ plans.append((provider.endpoint, {**JSON, "Authorization": f"Bearer {c['secret']}", **provider.extra_headers}, raw))
64
+ elif kind == "azure":
65
+ ep = (meta.get("endpoint") or "").rstrip("/")
66
+ if ep:
67
+ plans.append((ep, {**JSON, "api-key": c["secret"]}, raw))
68
+ elif kind == "bedrock":
69
+ try:
70
+ plans.append(signers.bedrock_plan(c, body))
71
+ except Exception: # noqa: BLE001 — skip a malformed bedrock cred
72
+ pass
73
+ if provider.key:
74
+ plans.append((provider.endpoint, {**JSON, **provider.auth_headers()}, raw))
75
+ return plans
32
76
 
33
77
 
34
78
  def scope_allows(scope: list[str], provider: str, model: str) -> bool:
@@ -70,14 +114,11 @@ async def forward(
70
114
  return 200, {"content-type": "text/event-stream"}, _mock_stream(provider.shape, payload), None
71
115
  return 200, {"content-type": "application/json"}, payload, None
72
116
 
73
- auth, ok = resolve_auth(provider, store)
74
- if not ok:
117
+ plans = build_plans(provider, store, body)
118
+ if not plans:
75
119
  return 502, {}, {"error": f"provider '{provider_name}' not configured on the proxy "
76
120
  f"(set {provider.key_env} or `proxyagent provider add {provider_name}`)"}, None
77
121
 
78
- url = provider.endpoint
79
- headers = {"content-type": "application/json", **auth}
80
-
81
122
  def _log(status, ptok, ctok, err=None):
82
123
  store.log_request(
83
124
  token_id=token["id"], token_label=token.get("label"), provider=provider_name,
@@ -93,36 +134,47 @@ async def forward(
93
134
  status = 200
94
135
  try:
95
136
  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
137
+ for i, (url, headers, raw) in enumerate(plans):
138
+ async with client.stream("POST", url, headers=headers, content=raw) as resp:
139
+ status = resp.status_code
140
+ if status in FAILOVER_STATUS and i < len(plans) - 1:
141
+ await resp.aread() # drain + rotate to the next credential
142
+ continue
143
+ async for chunk in resp.aiter_raw():
144
+ text = chunk.decode("utf-8", "ignore")
145
+ if '"output_tokens"' in text or '"completion_tokens"' in text:
146
+ try:
147
+ for line in text.splitlines():
148
+ if line.startswith("data:"):
149
+ d = json.loads(line[5:].strip())
150
+ usage = d.get("usage") or (d.get("message") or {}).get("usage") or {}
151
+ ptok = usage.get("input_tokens") or usage.get("prompt_tokens") or ptok
152
+ ctok = usage.get("output_tokens") or usage.get("completion_tokens") or ctok
153
+ except Exception:
154
+ pass
155
+ yield chunk
156
+ break
112
157
  finally:
113
158
  _log(status, ptok, ctok)
114
159
  return 200, {"content-type": "text/event-stream"}, _gen(), None
115
160
 
116
- # Non-streaming.
161
+ # Non-streaming, with credential failover across the pool.
162
+ last_status, last_payload = 502, {"error": "all credentials failed"}
117
163
  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
164
+ for i, (url, headers, raw) in enumerate(plans):
165
+ resp = await client.post(url, headers=headers, content=raw)
166
+ if resp.status_code in FAILOVER_STATUS and i < len(plans) - 1:
167
+ last_status = resp.status_code
168
+ continue
169
+ try:
170
+ payload = resp.json()
171
+ except Exception:
172
+ payload = {"error": resp.text}
173
+ ptok, ctok = _extract_usage(provider.shape, payload if isinstance(payload, dict) else {})
174
+ _log(resp.status_code, ptok, ctok, None if resp.is_success else str(payload)[:300])
175
+ return resp.status_code, {"content-type": "application/json"}, payload, None
176
+ _log(last_status, None, None, "all credentials failed")
177
+ return last_status, {"content-type": "application/json"}, last_payload, None
126
178
 
127
179
 
128
180
  # ------------------------------------------------------------------ #
@@ -35,9 +35,10 @@ class TokenBody(BaseModel):
35
35
  class ProviderBody(BaseModel):
36
36
  provider: str
37
37
  secret: str
38
- kind: str = "api_key" # api_key | oauth
38
+ kind: str = "api_key" # api_key | oauth | bedrock | azure | vertex
39
39
  label: str | None = None
40
40
  refresh: str | None = None
41
+ meta: dict | None = None # bedrock: {access_key, region}; azure: {endpoint}
41
42
 
42
43
 
43
44
  def create_app(config: Config | None = None) -> FastAPI:
@@ -204,7 +205,7 @@ def create_app(config: Config | None = None) -> FastAPI:
204
205
  if body.provider not in PROVIDERS:
205
206
  raise HTTPException(400, f"unknown provider; known: {list(PROVIDERS)}")
206
207
  cid = store.add_credential(body.provider, body.secret, kind=body.kind,
207
- label=body.label, refresh=body.refresh)
208
+ label=body.label, refresh=body.refresh, meta=body.meta)
208
209
  return {"id": cid, "provider": body.provider, "kind": body.kind,
209
210
  "stored": "encrypted" if crypto.encryption_available() else "plaintext"}
210
211
 
@@ -227,19 +228,28 @@ def create_app(config: Config | None = None) -> FastAPI:
227
228
  async def harnesses(authorization: str | None = Header(None),
228
229
  x_admin_token: str | None = Header(None)):
229
230
  require_admin(authorization, x_admin_token)
230
- stored = {c["provider"] for c in store.list_credentials() if c["active"]}
231
+ kinds_by_prov: dict[str, set] = {}
232
+ for c in store.list_credentials():
233
+ if c["active"]:
234
+ kinds_by_prov.setdefault(c["provider"], set()).add(c["kind"])
231
235
  out = []
232
236
  for name, h in HARNESSES.items():
233
237
  prov = PROVIDERS.get(h["provider"])
234
- configured = bool(prov and prov.key) or h["provider"] in stored
238
+ have = kinds_by_prov.get(h["provider"], set())
239
+ env_key = bool(prov and prov.key)
240
+
241
+ def _conn(m):
242
+ if m == "api_key":
243
+ return env_key or "api_key" in have
244
+ return m in have
245
+
235
246
  out.append({
236
247
  "name": name, "label": h["label"], "provider": h["provider"],
237
248
  "color": h["color"], "install": h["install"],
238
249
  "auth": [{"mode": m, "label": AUTH_LABELS.get(m, m),
239
- "ready": m in AUTH_READY,
240
- "connected": m == "api_key" and configured}
250
+ "ready": m in AUTH_READY, "connected": _conn(m)}
241
251
  for m in h["auth"]],
242
- "configured": configured,
252
+ "configured": env_key or bool(have),
243
253
  })
244
254
  return {"harnesses": out}
245
255
 
@@ -247,18 +257,21 @@ def create_app(config: Config | None = None) -> FastAPI:
247
257
  async def catalog(authorization: str | None = Header(None),
248
258
  x_admin_token: str | None = Header(None)):
249
259
  require_admin(authorization, x_admin_token)
250
- stored = {c["provider"]: c for c in store.list_credentials() if c["active"]}
260
+ pool: dict[str, list] = {}
261
+ for c in store.list_credentials():
262
+ if c["active"]:
263
+ pool.setdefault(c["provider"], []).append(c)
251
264
  out = []
252
265
  for name, prov in PROVIDERS.items():
253
266
  meta = CATALOG.get(name, {})
254
- cred = stored.get(name)
267
+ creds = pool.get(name, [])
255
268
  out.append({
256
269
  "name": name, "label": meta.get("label", name.title()),
257
270
  "kinds": meta.get("kinds", ["api_key"]), "color": meta.get("color", "#888"),
258
271
  "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,
272
+ "via_env": bool(prov.key), "via_store": bool(creds),
273
+ "creds": [{"id": c["id"], "kind": c["kind"], "masked": c.get("masked"),
274
+ "label": c.get("label")} for c in creds],
262
275
  "endpoint": prov.endpoint,
263
276
  })
264
277
  return {"providers": out, "encryption": crypto.encryption_available()}
@@ -0,0 +1,88 @@
1
+ """Request signers for the cloud auth modes — so the proxy holds the cloud credentials
2
+ and the machine running the harness holds none.
3
+
4
+ * AWS SigV4 (Bedrock) — full signature, no boto3 dependency.
5
+ * Azure OpenAI — api-key header to a custom deployment endpoint.
6
+
7
+ Vertex (GCP service-account → access token) lands next.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import datetime
13
+ import hashlib
14
+ import hmac
15
+ import json
16
+ from urllib.parse import quote
17
+
18
+
19
+ def _sign(key: bytes, msg: str) -> bytes:
20
+ return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
21
+
22
+
23
+ def sigv4_headers(*, method: str, host: str, path: str, region: str, service: str,
24
+ access_key: str, secret_key: str, body: bytes,
25
+ session_token: str | None = None,
26
+ content_type: str = "application/json", now: datetime.datetime | None = None) -> dict:
27
+ """AWS Signature Version 4 headers for a request. `path` must already be URI-encoded."""
28
+ now = now or datetime.datetime.now(datetime.timezone.utc)
29
+ amzdate = now.strftime("%Y%m%dT%H%M%SZ")
30
+ datestamp = now.strftime("%Y%m%d")
31
+ payload_hash = hashlib.sha256(body).hexdigest()
32
+
33
+ hdrs = {"content-type": content_type, "host": host, "x-amz-date": amzdate}
34
+ if session_token:
35
+ hdrs["x-amz-security-token"] = session_token
36
+ signed_headers = ";".join(sorted(hdrs))
37
+ canonical_headers = "".join(f"{k}:{hdrs[k]}\n" for k in sorted(hdrs))
38
+ canonical_request = f"{method}\n{path}\n\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
39
+
40
+ scope = f"{datestamp}/{region}/{service}/aws4_request"
41
+ string_to_sign = (f"AWS4-HMAC-SHA256\n{amzdate}\n{scope}\n"
42
+ f"{hashlib.sha256(canonical_request.encode()).hexdigest()}")
43
+ k_date = _sign(("AWS4" + secret_key).encode("utf-8"), datestamp)
44
+ k_region = _sign(k_date, region)
45
+ k_service = _sign(k_region, service)
46
+ k_signing = _sign(k_service, "aws4_request")
47
+ signature = hmac.new(k_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
48
+
49
+ auth = (f"AWS4-HMAC-SHA256 Credential={access_key}/{scope}, "
50
+ f"SignedHeaders={signed_headers}, Signature={signature}")
51
+ out = {"Authorization": auth, "x-amz-date": amzdate, "content-type": content_type}
52
+ if session_token:
53
+ out["x-amz-security-token"] = session_token
54
+ return out
55
+
56
+
57
+ # Map a few common Anthropic model ids → their Bedrock ids (best-effort; pass a bedrock id directly to skip).
58
+ def bedrock_model_id(model: str) -> str:
59
+ if model.startswith("anthropic.") or ":" in model:
60
+ return model
61
+ table = {
62
+ "claude-opus-4": "anthropic.claude-opus-4-20250514-v1:0",
63
+ "claude-sonnet-4-5": "anthropic.claude-sonnet-4-5-20250929-v1:0",
64
+ "claude-sonnet-4": "anthropic.claude-sonnet-4-20250514-v1:0",
65
+ "claude-3-5-sonnet": "anthropic.claude-3-5-sonnet-20241022-v2:0",
66
+ "claude-3-5-haiku": "anthropic.claude-3-5-haiku-20241022-v1:0",
67
+ }
68
+ for prefix, bid in table.items():
69
+ if model.startswith(prefix):
70
+ return bid
71
+ return model
72
+
73
+
74
+ def bedrock_plan(cred: dict, body: dict):
75
+ """(url, headers, body_bytes) for a Claude-on-Bedrock invoke, SigV4-signed here."""
76
+ meta = cred.get("meta") or {}
77
+ region = meta.get("region", "us-east-1")
78
+ model_id = bedrock_model_id(body.get("model", ""))
79
+ payload = {k: v for k, v in body.items() if k != "model"}
80
+ payload["anthropic_version"] = "bedrock-2023-05-31"
81
+ raw = json.dumps(payload).encode("utf-8")
82
+ host = f"bedrock-runtime.{region}.amazonaws.com"
83
+ path = f"/model/{quote(model_id, safe='')}/invoke"
84
+ headers = sigv4_headers(
85
+ method="POST", host=host, path=path, region=region, service="bedrock",
86
+ access_key=meta.get("access_key", ""), secret_key=cred.get("secret", ""),
87
+ body=raw, session_token=meta.get("session_token"))
88
+ return f"https://{host}{path}", headers, raw
@@ -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):
@@ -181,37 +181,47 @@ async function renderHarnesses(){
181
181
  ${h.configured?'<span class="pill ok">ready</span>':'<span class="pill no">connect a key</span>'}</div>
182
182
  <div class="badges">${chips}</div>
183
183
  <div class="models mono" style="font-size:11px">${h.install}</div>
184
- <div class="row"><button class="sm" onclick="openConnect('h_${h.name}')">Connect API key</button></div>
185
- <div class="connect" id="c_h_${h.name}">
186
- <input id="k_h_${h.name}" type="password" placeholder="${h.provider} API key"/>
187
- <div class="row"><button class="sm" onclick="connectHarness('${h.name}','${h.provider}')">Save</button><button class="ghost sm" onclick="openConnect('h_${h.name}')">Cancel</button></div>
188
- </div></div>`}).join("");
184
+ <div class="row"><button class="sm" onclick="openConnect('h_${h.name}')">+ Connect auth</button></div>
185
+ <div class="connect" id="c_h_${h.name}">${connectForm('h_'+h.name, h.auth.map(a=>a.mode), h.provider)}</div></div>`}).join("");
189
186
  }
190
- async function connectHarness(name,provider){const key=document.getElementById("k_h_"+name).value.trim();if(!key)return;
191
- await api("/admin/providers",{method:"POST",body:JSON.stringify({provider,secret:key,kind:"api_key"})});renderHarnesses();refreshProviders()}
192
187
  async function refreshProviders(){
193
188
  const d=await(await api("/admin/catalog")).json();const g=document.getElementById("provgrid");
194
189
  let connected=0;
195
- g.innerHTML=d.providers.map(p=>{const on=p.via_env||p.via_store;if(on)connected++;
196
- const how=p.via_store?`stored ${p.cred_kind}`:(p.via_env?"env":"");
190
+ g.innerHTML=d.providers.map(p=>{const creds=p.creds||[];const on=p.via_env||creds.length;if(on)connected++;
191
+ 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("");
197
192
  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>
198
193
  ${on?'<span class="pill ok">on</span>':'<span class="pill no">off</span>'}</div>
199
- <div class="badges">${p.kinds.map(k=>`<span class="badge">${k}</span>`).join("")}${on?`<span class="badge on">${how}</span>`:""}</div>
200
- <div class="models">${(p.models||[]).slice(0,2).join(" · ")}</div>
201
- <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>
202
- <div class="connect" id="c_${p.name}">
203
- <input id="k_${p.name}" type="password" placeholder="${p.name} API key${p.kinds.includes('oauth')?' / OAuth token':''}"/>
204
- <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"/>`}
205
- <button class="sm" onclick="connect('${p.name}')">Save</button><button class="ghost sm" onclick="openConnect('${p.name}')">Cancel</button></div>
206
- </div></div>`}).join("");
194
+ <div class="badges">${p.kinds.map(k=>`<span class="badge">${k}</span>`).join("")}${p.via_env?'<span class="badge on">env</span>':''}</div>
195
+ <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>
196
+ <div class="row"><button class="sm" onclick="openConnect('${p.name}')">+ Add credential</button></div>
197
+ <div class="connect" id="c_${p.name}">${connectForm(p.name,p.kinds,p.name)}</div></div>`}).join("");
207
198
  document.getElementById("s_prov").textContent=connected;
208
- document.getElementById("enc2")&&0;
209
199
  }
210
200
  function openConnect(n){document.getElementById("c_"+n).classList.toggle("open")}
211
- async function connect(n){const key=document.getElementById("k_"+n).value.trim();if(!key)return;
212
- const kindEl=document.getElementById("kind_"+n);const kind=kindEl?kindEl.value:"api_key";
213
- await api("/admin/providers",{method:"POST",body:JSON.stringify({provider:n,secret:key,kind})});refreshProviders()}
214
- async function disconnect(id){await api("/admin/providers/"+id,{method:"DELETE"});refreshProviders()}
201
+ function fieldsFor(uid,kind){
202
+ if(kind==="bedrock")return `<input id="f1_${uid}" placeholder="AWS access key id"/><input id="f2_${uid}" type="password" placeholder="AWS secret access key"/><input id="f3_${uid}" placeholder="region (default us-east-1)"/>`;
203
+ if(kind==="azure")return `<input id="f1_${uid}" placeholder="Azure endpoint URL (…/chat/completions?api-version=…)"/><input id="f2_${uid}" type="password" placeholder="api-key"/>`;
204
+ if(kind==="vertex")return `<span class="muted" style="font-size:12px">Vertex (service-account JSON) — coming next.</span>`;
205
+ return `<input id="f1_${uid}" type="password" placeholder="${kind==='oauth'?'OAuth access token':'API key'}"/>`;
206
+ }
207
+ function onKind(uid){document.getElementById("ff_"+uid).innerHTML=fieldsFor(uid,document.getElementById("kind_"+uid).value)}
208
+ function connectForm(uid,kinds,provider){
209
+ return `<select id="kind_${uid}" onchange="onKind('${uid}')">${kinds.map(k=>`<option value="${k}">${k}</option>`).join("")}</select>
210
+ <div id="ff_${uid}">${fieldsFor(uid,kinds[0])}</div>
211
+ <div class="row"><button class="sm" onclick="submitCred('${uid}','${provider}')">Add credential</button><button class="ghost sm" onclick="openConnect('${uid}')">Cancel</button></div>`;
212
+ }
213
+ async function submitCred(uid,provider){
214
+ const kind=document.getElementById("kind_"+uid).value,g=id=>{const e=document.getElementById(id);return e?e.value.trim():""};
215
+ let secret,meta={};
216
+ if(kind==="bedrock"){secret=g("f2_"+uid);meta={access_key:g("f1_"+uid),region:g("f3_"+uid)||"us-east-1"}}
217
+ else if(kind==="azure"){secret=g("f2_"+uid);meta={endpoint:g("f1_"+uid)}}
218
+ else if(kind==="vertex"){return}
219
+ else{secret=g("f1_"+uid)}
220
+ if(!secret)return;
221
+ await api("/admin/providers",{method:"POST",body:JSON.stringify({provider,secret,kind,meta})});
222
+ renderHarnesses();refreshProviders();
223
+ }
224
+ async function disconnect(id){await api("/admin/providers/"+id,{method:"DELETE"});renderHarnesses();refreshProviders()}
215
225
 
216
226
  async function refreshTokens(){const d=await(await api("/admin/tokens")).json();
217
227
  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("")}
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "proxyagent"
7
- version = "0.5.1"
7
+ version = "0.7.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,51 @@ 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)
205
+
206
+
207
+ def test_sigv4_structure_and_determinism():
208
+ import datetime, re
209
+ from proxyagent.signers import sigv4_headers
210
+ now = datetime.datetime(2015, 8, 30, 12, 36, 0, tzinfo=datetime.timezone.utc)
211
+ kw = dict(method="POST", host="bedrock-runtime.us-east-1.amazonaws.com", path="/model/x/invoke",
212
+ region="us-east-1", service="bedrock", access_key="AKIDEXAMPLE",
213
+ secret_key="wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", body=b'{"a":1}', now=now)
214
+ h1, h2 = sigv4_headers(**kw), sigv4_headers(**kw)
215
+ assert h1 == h2 # deterministic at a fixed time
216
+ a = h1["Authorization"]
217
+ assert a.startswith("AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20150830/us-east-1/bedrock/aws4_request")
218
+ assert "SignedHeaders=content-type;host;x-amz-date" in a
219
+ assert re.search(r"Signature=[0-9a-f]{64}$", a) and h1["x-amz-date"] == "20150830T123600Z"
220
+
221
+
222
+ def test_bedrock_plan_and_build_plans():
223
+ from proxyagent.signers import bedrock_plan
224
+ from proxyagent.providers import build_plans
225
+ url, headers, raw = bedrock_plan(
226
+ {"secret": "sk", "meta": {"access_key": "AKID", "region": "us-west-2"}},
227
+ {"model": "claude-sonnet-4-5", "max_tokens": 10, "messages": []})
228
+ assert url.startswith("https://bedrock-runtime.us-west-2.amazonaws.com/model/") and url.endswith("/invoke")
229
+ import json
230
+ b = json.loads(raw)
231
+ assert b["anthropic_version"] == "bedrock-2023-05-31" and "model" not in b
232
+ assert headers["Authorization"].startswith("AWS4-HMAC-SHA256")
233
+ # a provider can mix api_key + bedrock in its pool; both become plans
234
+ s = Store(":memory:")
235
+ s.add_credential("anthropic", "sk-1", kind="api_key")
236
+ s.add_credential("anthropic", "awssecret", kind="bedrock", meta={"access_key": "AKID", "region": "us-east-1"})
237
+ plans = build_plans(PROVIDERS["anthropic"], s, {"model": "claude-sonnet-4-5", "messages": []})
238
+ assert len(plans) == 2 and plans[0][0].endswith("/v1/messages") and "bedrock-runtime" in plans[1][0]
File without changes
File without changes
File without changes