proxyagent 0.6.0__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.6.0
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,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
@@ -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.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,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
@@ -181,14 +181,9 @@ 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;
@@ -199,18 +194,34 @@ async function refreshProviders(){
199
194
  <div class="badges">${p.kinds.map(k=>`<span class="badge">${k}</span>`).join("")}${p.via_env?'<span class="badge on">env</span>':''}</div>
200
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>
201
196
  <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("");
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
199
  }
209
200
  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()}
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()}
214
225
 
215
226
  async function refreshTokens(){const d=await(await api("/admin/tokens")).json();
216
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.6.0"
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"
@@ -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