proxyagent 0.8.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.8.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>
@@ -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
@@ -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.8.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
@@ -144,7 +144,7 @@ function logo(name,color){color=color||"#888";
144
144
  function fieldsFor(uid,kind){
145
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
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>`;
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
148
  return `<input id="f1_${uid}" type="password" placeholder="${kind==='oauth'?'OAuth access token':'API key'}" style="width:100%"/>`;
149
149
  }
150
150
  function onKind(uid){document.getElementById("ff_"+uid).innerHTML=fieldsFor(uid,document.getElementById("kind_"+uid).value)}
@@ -153,7 +153,7 @@ async function submitCred(uid,provider){
153
153
  let secret,meta={};
154
154
  if(kind==="bedrock"){secret=g("f2_"+uid);meta={access_key:g("f1_"+uid),region:g("f3_"+uid)||"us-east-1"}}
155
155
  else if(kind==="azure"){secret=g("f2_"+uid);meta={endpoint:g("f1_"+uid)}}
156
- else if(kind==="vertex"){return}
156
+ else if(kind==="vertex"){secret=g("f1_"+uid);meta={region:g("f3_"+uid)||"us-east5"}}
157
157
  else{secret=g("f1_"+uid)}
158
158
  if(!secret)return;
159
159
  await api("/admin/providers",{method:"POST",body:JSON.stringify({provider,secret,kind,meta})});refreshKeys();
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "proxyagent"
7
- version = "0.8.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