proxyagent 0.7.0__tar.gz → 0.9.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.7.0
3
+ Version: 0.9.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
 
@@ -179,9 +179,9 @@ is to centralise *all* of them so the machine running the harness holds only a `
179
179
  | **Gemini CLI** | Google | API key · OAuth · Vertex |
180
180
 
181
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
182
+ **Every auth mode is wired**: API key, OAuth, AWS Bedrock (the proxy SigV4-signs the Claude-on-Bedrock
183
+ request itself), Azure, and Google Vertex (service-account JSON access token Claude-on-Vertex).
184
+ For Bedrock/Vertex the proxy holds the AWS/GCP credentials and signs upstream, so the machine needs no cloud creds at all. The model providers below are the *backends* for model-agnostic
185
185
  harnesses (aider, Cline…).
186
186
 
187
187
  ```bash
@@ -189,6 +189,7 @@ harnesses (aider, Cline…).
189
189
  proxyagent provider add anthropic --kind bedrock --key <AWS_SECRET> # + meta: access_key, region
190
190
  proxyagent provider add openai --kind azure --key <AZURE_KEY> # + meta: endpoint
191
191
  proxyagent provider add anthropic --kind oauth --key <OAUTH_TOKEN>
192
+ proxyagent provider add anthropic --kind vertex --key "$(cat sa.json)" # + meta: region
192
193
  ```
193
194
 
194
195
  ## Credential pools & failover
@@ -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
 
@@ -147,9 +147,9 @@ is to centralise *all* of them so the machine running the harness holds only a `
147
147
  | **Gemini CLI** | Google | API key · OAuth · Vertex |
148
148
 
149
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
150
+ **Every auth mode is wired**: API key, OAuth, AWS Bedrock (the proxy SigV4-signs the Claude-on-Bedrock
151
+ request itself), Azure, and Google Vertex (service-account JSON access token Claude-on-Vertex).
152
+ For Bedrock/Vertex the proxy holds the AWS/GCP credentials and signs upstream, so the machine needs no cloud creds at all. The model providers below are the *backends* for model-agnostic
153
153
  harnesses (aider, Cline…).
154
154
 
155
155
  ```bash
@@ -157,6 +157,7 @@ harnesses (aider, Cline…).
157
157
  proxyagent provider add anthropic --kind bedrock --key <AWS_SECRET> # + meta: access_key, region
158
158
  proxyagent provider add openai --kind azure --key <AZURE_KEY> # + meta: endpoint
159
159
  proxyagent provider add anthropic --kind oauth --key <OAUTH_TOKEN>
160
+ proxyagent provider add anthropic --kind vertex --key "$(cat sa.json)" # + meta: region
160
161
  ```
161
162
 
162
163
  ## Credential pools & failover
@@ -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.7.0"
19
+ __version__ = "0.9.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", "oauth", "bedrock", "azure"}
104
+ AUTH_READY = {"api_key", "oauth", "bedrock", "azure", "vertex"}
105
105
 
106
106
 
107
107
  @dataclass
@@ -70,6 +70,11 @@ def build_plans(provider, store: Store | None, body: dict) -> list[tuple]:
70
70
  plans.append(signers.bedrock_plan(c, body))
71
71
  except Exception: # noqa: BLE001 — skip a malformed bedrock cred
72
72
  pass
73
+ elif kind == "vertex":
74
+ try:
75
+ plans.append(signers.vertex_plan(c, body))
76
+ except Exception: # noqa: BLE001 — skip if SA invalid / token fetch fails
77
+ pass
73
78
  if provider.key:
74
79
  plans.append((provider.endpoint, {**JSON, **provider.auth_headers()}, raw))
75
80
  return plans
@@ -9,10 +9,12 @@ Vertex (GCP service-account → access token) lands next.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import base64
12
13
  import datetime
13
14
  import hashlib
14
15
  import hmac
15
16
  import json
17
+ import time
16
18
  from urllib.parse import quote
17
19
 
18
20
 
@@ -86,3 +88,67 @@ def bedrock_plan(cred: dict, body: dict):
86
88
  access_key=meta.get("access_key", ""), secret_key=cred.get("secret", ""),
87
89
  body=raw, session_token=meta.get("session_token"))
88
90
  return f"https://{host}{path}", headers, raw
91
+
92
+
93
+ # ------------------------------------------------------------------ #
94
+ # Google Vertex AI — service account → OAuth2 access token → Claude-on-Vertex.
95
+ # The proxy holds the service-account key; the machine holds none.
96
+ # ------------------------------------------------------------------ #
97
+
98
+ def _b64url(b: bytes) -> str:
99
+ return base64.urlsafe_b64encode(b).rstrip(b"=").decode("ascii")
100
+
101
+
102
+ _VERTEX_TOKEN_CACHE: dict[str, tuple[str, int]] = {} # client_email -> (token, expiry_ms)
103
+
104
+
105
+ def vertex_signed_assertion(sa: dict, *, now: int | None = None) -> str:
106
+ """A signed RS256 JWT bearer assertion for the service account (no network)."""
107
+ from cryptography.hazmat.primitives import hashes, serialization
108
+ from cryptography.hazmat.primitives.asymmetric import padding
109
+ iat = int(now if now is not None else time.time())
110
+ token_uri = sa.get("token_uri", "https://oauth2.googleapis.com/token")
111
+ header = {"alg": "RS256", "typ": "JWT"}
112
+ claims = {"iss": sa["client_email"], "scope": "https://www.googleapis.com/auth/cloud-platform",
113
+ "aud": token_uri, "iat": iat, "exp": iat + 3600}
114
+ signing_input = f"{_b64url(json.dumps(header).encode())}.{_b64url(json.dumps(claims).encode())}"
115
+ key = serialization.load_pem_private_key(sa["private_key"].encode(), password=None)
116
+ sig = key.sign(signing_input.encode(), padding.PKCS1v15(), hashes.SHA256())
117
+ return f"{signing_input}.{_b64url(sig)}"
118
+
119
+
120
+ def vertex_access_token(sa: dict) -> str:
121
+ """Exchange the SA assertion for a short-lived access token (cached ~1h)."""
122
+ import httpx
123
+ email = sa["client_email"]
124
+ nowms = int(time.time() * 1000)
125
+ cached = _VERTEX_TOKEN_CACHE.get(email)
126
+ if cached and cached[1] - 60_000 > nowms:
127
+ return cached[0]
128
+ token_uri = sa.get("token_uri", "https://oauth2.googleapis.com/token")
129
+ r = httpx.post(token_uri, data={
130
+ "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
131
+ "assertion": vertex_signed_assertion(sa)}, timeout=20)
132
+ r.raise_for_status()
133
+ body = r.json()
134
+ tok, ttl = body["access_token"], body.get("expires_in", 3600)
135
+ _VERTEX_TOKEN_CACHE[email] = (tok, nowms + ttl * 1000)
136
+ return tok
137
+
138
+
139
+ def vertex_url(project: str, region: str, model: str) -> str:
140
+ return (f"https://{region}-aiplatform.googleapis.com/v1/projects/{project}"
141
+ f"/locations/{region}/publishers/anthropic/models/{quote(model, safe='')}:rawPredict")
142
+
143
+
144
+ def vertex_plan(cred: dict, body: dict):
145
+ """(url, headers, body_bytes) for a Claude-on-Vertex rawPredict call."""
146
+ sa = json.loads(cred["secret"]) # the full service-account JSON
147
+ meta = cred.get("meta") or {}
148
+ region = meta.get("region") or sa.get("region") or "us-east5"
149
+ project = meta.get("project_id") or sa.get("project_id")
150
+ payload = {k: v for k, v in body.items() if k != "model"}
151
+ payload["anthropic_version"] = "vertex-2023-10-16"
152
+ raw = json.dumps(payload).encode("utf-8")
153
+ headers = {"content-type": "application/json", "Authorization": f"Bearer {vertex_access_token(sa)}"}
154
+ return vertex_url(project, region, body.get("model", "")), 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>`}
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 `<textarea id="f1_${uid}" placeholder="Paste the GCP service-account JSON" style="width:100%;min-height:92px;font:12px ui-monospace,monospace;border:none;border-radius:11px;background:var(--panel2);color:var(--txt);padding:10px 12px;outline:none"></textarea><input id="f3_${uid}" placeholder="region (default us-east5)" style="width:100%;margin-top:8px"/>`;
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"){secret=g("f1_"+uid);meta={region:g("f3_"+uid)||"us-east5"}}
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()}
160
162
 
163
+ let CATALOG=[];
161
164
  async function boot(){
162
165
  try{
163
166
  const u=await(await api("/admin/usage")).json();
@@ -167,61 +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 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("");
186
- }
187
- async function refreshProviders(){
188
- const d=await(await api("/admin/catalog")).json();const g=document.getElementById("provgrid");
189
- let connected=0;
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("");
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>
193
- ${on?'<span class="pill ok">on</span>':'<span class="pill no">off</span>'}</div>
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("");
198
- document.getElementById("s_prov").textContent=connected;
199
- }
200
- function openConnect(n){document.getElementById("c_"+n).classList.toggle("open")}
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();
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()}
223
184
  }
224
- async function disconnect(id){await api("/admin/providers/"+id,{method:"DELETE"});renderHarnesses();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")}
225
188
 
226
189
  async function refreshTokens(){const d=await(await api("/admin/tokens")).json();
227
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.7.0"
7
+ version = "0.9.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"
@@ -236,3 +236,28 @@ def test_bedrock_plan_and_build_plans():
236
236
  s.add_credential("anthropic", "awssecret", kind="bedrock", meta={"access_key": "AKID", "region": "us-east-1"})
237
237
  plans = build_plans(PROVIDERS["anthropic"], s, {"model": "claude-sonnet-4-5", "messages": []})
238
238
  assert len(plans) == 2 and plans[0][0].endswith("/v1/messages") and "bedrock-runtime" in plans[1][0]
239
+
240
+
241
+ def test_vertex_assertion_and_url():
242
+ import base64, json
243
+ from cryptography.hazmat.primitives import hashes, serialization
244
+ from cryptography.hazmat.primitives.asymmetric import padding, rsa
245
+ from proxyagent.signers import vertex_signed_assertion, vertex_url
246
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
247
+ pem = key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
248
+ serialization.NoEncryption()).decode()
249
+ sa = {"client_email": "svc@proj.iam.gserviceaccount.com", "private_key": pem,
250
+ "token_uri": "https://oauth2.googleapis.com/token"}
251
+ jwt = vertex_signed_assertion(sa, now=1700000000)
252
+ parts = jwt.split(".")
253
+ assert len(parts) == 3
254
+ b64d = lambda s: base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
255
+ header, claims = json.loads(b64d(parts[0])), json.loads(b64d(parts[1]))
256
+ assert header["alg"] == "RS256" and claims["iss"] == sa["client_email"]
257
+ assert claims["scope"].endswith("cloud-platform")
258
+ # signature must verify against the public key (raises on tamper)
259
+ key.public_key().verify(b64d(parts[2]), (parts[0] + "." + parts[1]).encode(),
260
+ padding.PKCS1v15(), hashes.SHA256())
261
+ assert vertex_url("myproj", "us-east5", "claude-sonnet-4-5") == (
262
+ "https://us-east5-aiplatform.googleapis.com/v1/projects/myproj/locations/us-east5"
263
+ "/publishers/anthropic/models/claude-sonnet-4-5:rawPredict")
File without changes
File without changes
File without changes