proxyagent 0.6.0__tar.gz → 0.8.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.6.0
3
+ Version: 0.8.0
4
4
  Summary: Run any agent (Claude, Codex, custom) on any machine — with no API key on the machine. A secure, self-hosted proxy for models and tools.
5
5
  Project-URL: Homepage, https://github.com/teddyoweh/proxyagent
6
6
  Author-email: Spawn Labs <teddy@spawnlabs.ai>
@@ -99,13 +99,13 @@ claude -p "ship it"
99
99
  ```
100
100
 
101
101
  ## The dashboard
102
- `proxyagent serve` ships a real dashboard at `/` (reveal the admin token with
102
+ `proxyagent serve` ships a dashboard at `/` (reveal the admin token with
103
103
  `proxyagent admin-token`):
104
104
 
105
- - **Providers** — a branded catalog of every supported provider; **connect/disconnect**
106
- with a key right from the UI, see which auth types each supports (api_key / oauth) and
107
- whether it's on via env or stored credentials.
108
- - **Machine tokens** — mint (scoped/TTL), list, revoke.
105
+ - **Access keys** — the credentials you create. Each is a provider + an auth type
106
+ (Anthropic · API key, Anthropic · Bedrock, OpenAI · Azure, …); pick the type, enter the
107
+ key/fields, done. Listed with provider logo · auth type · masked key · remove.
108
+ - **Machine tokens** — mint (scoped / TTL / budget), list, revoke.
109
109
  - **Model routing** — add/remove model remaps (e.g. `* → mock` for offline).
110
110
  - **Activity** — live request log with usage + cost, and headline stats.
111
111
 
@@ -178,10 +178,18 @@ 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
+ ```
185
193
 
186
194
  ## Credential pools & failover
187
195
  A provider isn't one key — it's a **pool**. Add as many credentials as you want, across
@@ -67,13 +67,13 @@ claude -p "ship it"
67
67
  ```
68
68
 
69
69
  ## The dashboard
70
- `proxyagent serve` ships a real dashboard at `/` (reveal the admin token with
70
+ `proxyagent serve` ships a dashboard at `/` (reveal the admin token with
71
71
  `proxyagent admin-token`):
72
72
 
73
- - **Providers** — a branded catalog of every supported provider; **connect/disconnect**
74
- with a key right from the UI, see which auth types each supports (api_key / oauth) and
75
- whether it's on via env or stored credentials.
76
- - **Machine tokens** — mint (scoped/TTL), list, revoke.
73
+ - **Access keys** — the credentials you create. Each is a provider + an auth type
74
+ (Anthropic · API key, Anthropic · Bedrock, OpenAI · Azure, …); pick the type, enter the
75
+ key/fields, done. Listed with provider logo · auth type · masked key · remove.
76
+ - **Machine tokens** — mint (scoped / TTL / budget), list, revoke.
77
77
  - **Model routing** — add/remove model remaps (e.g. `* → mock` for offline).
78
78
  - **Activity** — live request log with usage + cost, and headline stats.
79
79
 
@@ -146,10 +146,18 @@ 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
+ ```
153
161
 
154
162
  ## Credential pools & failover
155
163
  A provider isn't one key — it's a **pool**. Add as many credentials as you want, across
@@ -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.6.0"
19
+ __version__ = "0.8.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,7 +12,7 @@ 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
 
@@ -47,6 +47,34 @@ def resolve_auth(provider, store: Store | None) -> tuple[dict, bool]:
47
47
  return (cands[0], True) if cands else ({}, False)
48
48
 
49
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
76
+
77
+
50
78
  def scope_allows(scope: list[str], provider: str, model: str) -> bool:
51
79
  """A scope entry is a glob over 'provider:model', e.g. 'anthropic:claude-*', or '*'."""
52
80
  target = f"{provider}:{model or '*'}"
@@ -86,14 +114,12 @@ async def forward(
86
114
  return 200, {"content-type": "text/event-stream"}, _mock_stream(provider.shape, payload), None
87
115
  return 200, {"content-type": "application/json"}, payload, None
88
116
 
89
- candidates = resolve_candidates(provider, store)
90
- if not candidates:
117
+ plans = build_plans(provider, store, body)
118
+ if not plans:
91
119
  return 502, {}, {"error": f"provider '{provider_name}' not configured on the proxy "
92
120
  f"(set {provider.key_env} or `proxyagent provider add {provider_name}`)"}, None
93
121
 
94
- url = provider.endpoint
95
-
96
- def _log(status, ptok, ctok, err=None, attempt=0):
122
+ def _log(status, ptok, ctok, err=None):
97
123
  store.log_request(
98
124
  token_id=token["id"], token_label=token.get("label"), provider=provider_name,
99
125
  model=model, status=status, prompt_tokens=ptok, completion_tokens=ctok,
@@ -108,12 +134,10 @@ async def forward(
108
134
  status = 200
109
135
  try:
110
136
  async with httpx.AsyncClient(timeout=config.request_timeout) as client:
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:
137
+ for i, (url, headers, raw) in enumerate(plans):
138
+ async with client.stream("POST", url, headers=headers, content=raw) as resp:
115
139
  status = resp.status_code
116
- if status in FAILOVER_STATUS and i < len(candidates) - 1:
140
+ if status in FAILOVER_STATUS and i < len(plans) - 1:
117
141
  await resp.aread() # drain + rotate to the next credential
118
142
  continue
119
143
  async for chunk in resp.aiter_raw():
@@ -134,12 +158,12 @@ async def forward(
134
158
  _log(status, ptok, ctok)
135
159
  return 200, {"content-type": "text/event-stream"}, _gen(), None
136
160
 
137
- # Non-streaming, with credential failover.
161
+ # Non-streaming, with credential failover across the pool.
138
162
  last_status, last_payload = 502, {"error": "all credentials failed"}
139
163
  async with httpx.AsyncClient(timeout=config.request_timeout) as client:
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:
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:
143
167
  last_status = resp.status_code
144
168
  continue
145
169
  try:
@@ -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
 
@@ -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
@@ -12,42 +12,33 @@
12
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
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}
15
+ main{max-width:1040px;margin:0 auto;padding:8px 28px 60px}
16
16
  .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin-bottom:26px}
17
17
  .card{background:var(--panel);border-radius:18px;padding:20px}
18
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
19
  .tabs{display:flex;gap:6px;margin-bottom:20px}
20
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}
21
+ .tab:hover,.tab.on{color:var(--txt);background:var(--panel)}
22
+ .tile{width:38px;height:38px;border-radius:11px;display:grid;place-items:center;flex:none;font-weight:800;font-size:16px;overflow:hidden}
23
+ .tile img{width:21px;height:21px;display:block}.tile .fbm{display:none}.tile.fb img{display:none}.tile.fb .fbm{display:flex;align-items:center;justify-content:center}
31
24
  .badge{font-size:10.5px;padding:3px 9px;border-radius:8px;background:var(--soft);color:var(--dim);text-transform:uppercase;letter-spacing:.04em}
32
25
  .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)}
26
+ input,select{font:inherit;border-radius:11px;border:none;background:var(--panel2);color:var(--txt);padding:10px 12px;outline:none;box-shadow:inset 0 0 0 1px transparent}
27
+ input:focus,select:focus{box-shadow:inset 0 0 0 1px rgba(52,211,158,.45)}input::placeholder{color:#5a606b}
28
+ button{font:inherit;background:var(--grn);color:#04130d;border:none;border-radius:11px;padding:10px 15px;font-weight:660;cursor:pointer}button:hover{filter:brightness(1.07)}
38
29
  button.ghost{background:var(--soft);color:var(--dim)}button.ghost:hover{color:var(--txt)}
39
30
  button.danger{background:rgba(246,120,138,.12);color:var(--red)}button.danger:hover{background:rgba(246,120,138,.2)}
40
31
  button.sm{padding:7px 12px;font-size:12.5px;border-radius:9px}
41
32
  .row{display:flex;gap:9px;flex-wrap:wrap;align-items:center}
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}
33
+ table{width:100%;border-collapse:collapse}td,th{text-align:left;padding:11px 12px;font-size:13px;vertical-align:middle}
44
34
  thead th{color:var(--dim);font-weight:520;font-size:11px;text-transform:uppercase;letter-spacing:.06em}
45
35
  tbody tr{transition:background .12s}tbody tr:hover{background:#1a1d23}
46
36
  .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
37
  .gate{max-width:430px;margin:100px auto;text-align:center}.gate input{width:100%;margin:14px 0}
48
38
  .tok{background:var(--panel2);padding:13px;border-radius:12px;word-break:break-all;margin-top:13px;font-size:13px}
49
- .hide{display:none}.muted{color:var(--dim)}.h{display:flex;justify-content:space-between;align-items:center;margin:0 0 12px}
39
+ .hide{display:none}.muted{color:var(--dim)}.h{display:flex;justify-content:space-between;align-items:center;margin:0 0 14px}
50
40
  h2{font-size:13px;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);margin:0}
41
+ .empty{text-align:center;color:var(--dim);padding:46px}
51
42
  </style>
52
43
  </head>
53
44
  <body>
@@ -66,28 +57,30 @@
66
57
  </header>
67
58
  <main>
68
59
  <div class="stats">
60
+ <div class="card stat"><div class="n" id="s_keys">0</div><div class="l">Access keys</div></div>
69
61
  <div class="card stat"><div class="n" id="s_req">0</div><div class="l">Requests</div></div>
70
- <div class="card stat"><div class="n" id="s_tok">0</div><div class="l">Tokens (in/out)</div></div>
62
+ <div class="card stat"><div class="n" id="s_tok">0/0</div><div class="l">Tokens (in/out)</div></div>
71
63
  <div class="card stat"><div class="n" id="s_cost" style="color:var(--grn)">$0</div><div class="l">Cost</div></div>
72
- <div class="card stat"><div class="n" id="s_prov">0</div><div class="l">Providers connected</div></div>
73
64
  </div>
74
65
 
75
66
  <div class="tabs">
76
- <div class="tab on" data-t="harnesses" onclick="tab('harnesses')">Harnesses</div>
77
- <div class="tab" data-t="providers" onclick="tab('providers')">Model endpoints</div>
67
+ <div class="tab on" data-t="keys" onclick="tab('keys')">Access keys</div>
78
68
  <div class="tab" data-t="tokens" onclick="tab('tokens')">Machine tokens</div>
79
69
  <div class="tab" data-t="models" onclick="tab('models')">Model routing</div>
80
70
  <div class="tab" data-t="activity" onclick="tab('activity')">Activity</div>
81
71
  </div>
82
72
 
83
- <section id="t_harnesses">
84
- <p class="muted" style="margin:0 0 14px">The agents you run. Connect each auth method once — the machine running the harness then holds only a <code>pa_</code> token.</p>
85
- <div class="grid" id="harngrid"></div>
86
- </section>
87
-
88
- <section id="t_providers" class="hide">
89
- <p class="muted" style="margin:0 0 14px">Raw model backends for model-agnostic harnesses (aider, Cline, Codex pointed elsewhere…).</p>
90
- <div class="grid" id="provgrid"></div>
73
+ <section id="t_keys">
74
+ <div class="h"><h2>Access keys</h2><button onclick="openCreate()">+ Create access key</button></div>
75
+ <div class="card hide" id="createForm" style="margin-bottom:16px">
76
+ <div class="row" style="margin-bottom:11px">
77
+ <select id="cf_provider" onchange="onProvider()" style="flex:1"></select>
78
+ <select id="kind_cf" onchange="onKind('cf')" style="width:140px"></select>
79
+ </div>
80
+ <div id="ff_cf"></div>
81
+ <div class="row" style="margin-top:11px"><button onclick="createKey()">Create access key</button><button class="ghost" onclick="openCreate()">Cancel</button></div>
82
+ </div>
83
+ <div id="keylist"></div>
91
84
  </section>
92
85
 
93
86
  <section id="t_tokens" class="hide">
@@ -109,8 +102,7 @@
109
102
  <div class="card" style="margin-bottom:16px">
110
103
  <div class="h"><h2>Remap a model</h2></div>
111
104
  <div class="row">
112
- <input id="al_match" placeholder="match: * or gpt-4o"/>
113
- <span class="muted">→</span>
105
+ <input id="al_match" placeholder="match: * or gpt-4o"/><span class="muted">→</span>
114
106
  <input id="al_target" placeholder="target: mock or anthropic:claude-sonnet-4-5" style="flex:1"/>
115
107
  <button onclick="setAlias()">Map</button>
116
108
  </div>
@@ -132,32 +124,43 @@ async function api(p,o={}){const r=await fetch(p,{...o,headers:{...H(),...(o.hea
132
124
  function val(id){return document.getElementById(id).value.trim()}
133
125
  function saveAdmin(){const v=document.getElementById("admintok").value.trim();if(v){localStorage.setItem("pa_admin",v);boot()}}
134
126
  function logout(){localStorage.removeItem("pa_admin");document.getElementById("app").classList.add("hide");document.getElementById("gate").classList.remove("hide")}
135
- function tab(t){document.querySelectorAll(".tab").forEach(e=>e.classList.toggle("on",e.dataset.t===t));["harnesses","providers","tokens","models","activity"].forEach(s=>document.getElementById("t_"+s).classList.toggle("hide",s!==t))}
127
+ function tab(t){document.querySelectorAll(".tab").forEach(e=>e.classList.toggle("on",e.dataset.t===t));["keys","tokens","models","activity"].forEach(s=>document.getElementById("t_"+s).classList.toggle("hide",s!==t))}
136
128
 
137
- // ---- provider logos (inline, brand-tinted) ----
129
+ // logos: real brand marks via the Simple Icons CDN, tinted; fall back to a drawn mark offline.
138
130
  const MARK={
139
131
  anthropic:'<path d="M9.6 3 3 21h4.1l1.2-3.5h5.9L15.4 21H20L13.4 3H9.6Zm-.4 10.5 1.9-5.4 2 5.4H9.2Z"/>',
140
132
  openai:'<path d="M12 2.6c1.9 0 3.5 1.3 4 3a4.2 4.2 0 0 1 2.3 6.8 4.2 4.2 0 0 1-4 5.8 4.2 4.2 0 0 1-8.6-1A4.2 4.2 0 0 1 5.7 12 4.2 4.2 0 0 1 8 5.5a4.2 4.2 0 0 1 4-2.9Zm0 4.6a4.8 4.8 0 1 0 0 9.6 4.8 4.8 0 0 0 0-9.6Z"/>',
141
133
  gemini:'<path d="M12 2c.4 4.6 3.4 7.6 8 8-4.6.4-7.6 3.4-8 8-.4-4.6-3.4-7.6-8-8 4.6-.4 7.6-3.4 8-8Z"/>',
142
- groq:'<path d="M12 3a6 6 0 1 0 4.2 10.3l-2-2A3.2 3.2 0 1 1 12 6.2c.9 0 1.6.3 2.2.8L16.3 5A6 6 0 0 0 12 3Zm2 7v3.3h2.6V10H14Z"/>',
143
- openrouter:'<path d="M3 8h6l3 4 3-4h6M3 16h6l3-4M21 8l-3 8h-3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
144
- mistral:'<g><rect x="3" y="4" width="3.4" height="3.4"/><rect x="17.6" y="4" width="3.4" height="3.4"/><rect x="7.3" y="8.3" width="3.4" height="3.4"/><rect x="13.3" y="8.3" width="3.4" height="3.4"/><rect x="3" y="12.6" width="3.4" height="3.4"/><rect x="17.6" y="12.6" width="3.4" height="3.4"/><rect x="3" y="16.9" width="18" height="3.1"/></g>',
145
- deepseek:'<path d="M4 9c3 0 4 2 7 2s4-3 8-2c-1 4-5 7-9 7-3 0-6-2-6-5 0-1 0-2 0-2Zm12 1.5a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"/>',
146
134
  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"/>',
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"/>',
148
135
  };
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
136
  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
- }
159
137
  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})`}
138
+ function logo(name,color){color=color||"#888";
139
+ const mark=MARK[name]?`<svg viewBox="0 0 24 24" width="21" height="21" fill="currentColor">${MARK[name]}</svg>`:`<b>${(name[0]||"?").toUpperCase()}</b>`;
140
+ const inner=SLUG[name]?`<img src="https://cdn.simpleicons.org/${SLUG[name]}/${color.replace("#","")}" alt="${name}" onerror="this.closest('.tile').classList.add('fb')"/><span class="fbm">${mark}</span>`:`<span class="fbm" style="display:flex">${mark}</span>`;
141
+ return `<div class="tile" style="background:${hexa(color,.14)};color:${color}">${inner}</div>`}
160
142
 
143
+ // the auth-type fields (the "provider" is essentially the auth type)
144
+ function fieldsFor(uid,kind){
145
+ if(kind==="bedrock")return `<div class="row"><input id="f1_${uid}" placeholder="AWS access key id" style="flex:1"/><input id="f3_${uid}" placeholder="region (us-east-1)" style="width:150px"/></div><input id="f2_${uid}" type="password" placeholder="AWS secret access key" style="width:100%;margin-top:8px"/>`;
146
+ if(kind==="azure")return `<input id="f1_${uid}" placeholder="Azure endpoint URL (…/chat/completions?api-version=…)" style="width:100%"/><input id="f2_${uid}" type="password" placeholder="api-key" style="width:100%;margin-top:8px"/>`;
147
+ if(kind==="vertex")return `<span class="muted" style="font-size:12px">Vertex (service-account JSON) — coming next.</span>`;
148
+ return `<input id="f1_${uid}" type="password" placeholder="${kind==='oauth'?'OAuth access token':'API key'}" style="width:100%"/>`;
149
+ }
150
+ function onKind(uid){document.getElementById("ff_"+uid).innerHTML=fieldsFor(uid,document.getElementById("kind_"+uid).value)}
151
+ async function submitCred(uid,provider){
152
+ const kind=document.getElementById("kind_"+uid).value,g=id=>{const e=document.getElementById(id);return e?e.value.trim():""};
153
+ let secret,meta={};
154
+ if(kind==="bedrock"){secret=g("f2_"+uid);meta={access_key:g("f1_"+uid),region:g("f3_"+uid)||"us-east-1"}}
155
+ else if(kind==="azure"){secret=g("f2_"+uid);meta={endpoint:g("f1_"+uid)}}
156
+ else if(kind==="vertex"){return}
157
+ else{secret=g("f1_"+uid)}
158
+ if(!secret)return;
159
+ await api("/admin/providers",{method:"POST",body:JSON.stringify({provider,secret,kind,meta})});refreshKeys();
160
+ }
161
+ async function disconnect(id){await api("/admin/providers/"+id,{method:"DELETE"});refreshKeys()}
162
+
163
+ let CATALOG=[];
161
164
  async function boot(){
162
165
  try{
163
166
  const u=await(await api("/admin/usage")).json();
@@ -167,50 +170,21 @@ async function boot(){
167
170
  document.getElementById("s_cost").textContent="$"+(u.usage.cost_usd||0).toFixed(4);
168
171
  document.getElementById("badge_backend").textContent=u.backend;
169
172
  document.getElementById("enc").textContent=u.encryption?"🔒 encrypted at rest":"⚠ encryption off";
170
- renderHarnesses();refreshProviders();refreshTokens();refreshAliases();refreshLogs();
173
+ refreshKeys();refreshTokens();refreshAliases();refreshLogs();
171
174
  }catch(e){document.getElementById("gateerr").textContent="Invalid admin token."}
172
175
  }
173
- async function renderHarnesses(){
174
- const d=await(await api("/admin/harnesses")).json();
175
- document.getElementById("harngrid").innerHTML=d.harnesses.map(h=>{
176
- const chips=h.auth.map(a=>{
177
- const cls=a.connected?"badge on":(a.ready?"badge":"badge");
178
- const tag=a.connected?" connected":(a.ready?"connect":"soon");
179
- return `<span class="${cls}" title="${a.ready?'available now':'coming soon'}">${a.label} · ${tag}</span>`}).join("");
180
- return `<div class="prov"><div class="top">${logo(h.provider,h.color)}<div style="flex:1"><h3>${h.label}</h3><div class="ep">runs on ${h.provider}</div></div>
181
- ${h.configured?'<span class="pill ok">ready</span>':'<span class="pill no">connect a key</span>'}</div>
182
- <div class="badges">${chips}</div>
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("");
189
- }
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
- async function refreshProviders(){
193
- const d=await(await api("/admin/catalog")).json();const g=document.getElementById("provgrid");
194
- let connected=0;
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("");
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>
198
- ${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("")}${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>
202
- <div class="connect" id="c_${p.name}">
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>
206
- </div></div>`}).join("");
207
- document.getElementById("s_prov").textContent=connected;
176
+ async function refreshKeys(){
177
+ const d=await(await api("/admin/catalog")).json();CATALOG=d.providers;
178
+ const keys=[];d.providers.forEach(p=>{(p.creds||[]).forEach(c=>keys.push({...c,provider:p.name,plabel:p.label,color:p.color}));if(p.via_env)keys.push({provider:p.name,plabel:p.label,color:p.color,kind:"api_key",masked:"env",id:null})});
179
+ document.getElementById("s_keys").textContent=keys.length;
180
+ document.getElementById("keylist").innerHTML=keys.length
181
+ ?`<div class="card"><table><thead><tr><th></th><th>Provider</th><th>Auth type</th><th>Key</th><th></th></tr></thead><tbody>${keys.map(k=>`<tr><td style="width:50px">${logo(k.provider,k.color)}</td><td>${k.plabel}</td><td><span class="badge on">${k.kind}</span></td><td class="mono">${k.masked||""}</td><td>${k.id?`<button class="danger sm" onclick="disconnect('${k.id}')">remove</button>`:'<span class="muted" style="font-size:11px">from env</span>'}</td></tr>`).join("")}</tbody></table></div>`
182
+ :`<div class="card empty">No access keys yet.<br/><span style="font-size:12.5px">Click <b style="color:var(--txt)">+ Create access key</b> to add one.</span></div>`;
183
+ const ps=document.getElementById("cf_provider");if(ps&&!ps.dataset.init){ps.dataset.init="1";ps.innerHTML=CATALOG.map(p=>`<option value="${p.name}">${p.label}</option>`).join("");onProvider()}
208
184
  }
209
- function openConnect(n){document.getElementById("c_"+n).classList.toggle("open")}
210
- async function connect(n){const key=document.getElementById("k_"+n).value.trim();if(!key)return;
211
- const kindEl=document.getElementById("kind_"+n);const kind=kindEl?kindEl.value:"api_key";
212
- await api("/admin/providers",{method:"POST",body:JSON.stringify({provider:n,secret:key,kind})});refreshProviders()}
213
- async function disconnect(id){await api("/admin/providers/"+id,{method:"DELETE"});refreshProviders()}
185
+ function onProvider(){const p=CATALOG.find(x=>x.name===val("cf_provider"))||{};const ks=document.getElementById("kind_cf");ks.innerHTML=(p.kinds||["api_key"]).map(k=>`<option value="${k}">${k}</option>`).join("");onKind("cf")}
186
+ function openCreate(){document.getElementById("createForm").classList.toggle("hide")}
187
+ async function createKey(){await submitCred("cf",val("cf_provider"));document.getElementById("createForm").classList.add("hide")}
214
188
 
215
189
  async function refreshTokens(){const d=await(await api("/admin/tokens")).json();
216
190
  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.6.0"
7
+ version = "0.8.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"
@@ -202,3 +202,37 @@ def test_credential_pool_and_failover_order():
202
202
  assert cands[1]["Authorization"] == "Bearer sk-2" # failover tries #2 next
203
203
  listed = s.list_credentials()
204
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