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.
- {proxyagent-0.6.0 → proxyagent-0.8.0}/PKG-INFO +18 -10
- {proxyagent-0.6.0 → proxyagent-0.8.0}/README.md +17 -9
- {proxyagent-0.6.0 → proxyagent-0.8.0}/proxyagent/__init__.py +1 -1
- {proxyagent-0.6.0 → proxyagent-0.8.0}/proxyagent/config.py +1 -1
- {proxyagent-0.6.0 → proxyagent-0.8.0}/proxyagent/providers.py +39 -15
- {proxyagent-0.6.0 → proxyagent-0.8.0}/proxyagent/server.py +17 -7
- proxyagent-0.8.0/proxyagent/signers.py +88 -0
- {proxyagent-0.6.0 → proxyagent-0.8.0}/proxyagent/ui/index.html +64 -90
- {proxyagent-0.6.0 → proxyagent-0.8.0}/pyproject.toml +1 -1
- {proxyagent-0.6.0 → proxyagent-0.8.0}/tests/test_proxy.py +34 -0
- {proxyagent-0.6.0 → proxyagent-0.8.0}/.gitignore +0 -0
- {proxyagent-0.6.0 → proxyagent-0.8.0}/proxyagent/aliases.py +0 -0
- {proxyagent-0.6.0 → proxyagent-0.8.0}/proxyagent/cli.py +0 -0
- {proxyagent-0.6.0 → proxyagent-0.8.0}/proxyagent/crypto.py +0 -0
- {proxyagent-0.6.0 → proxyagent-0.8.0}/proxyagent/db.py +0 -0
- {proxyagent-0.6.0 → proxyagent-0.8.0}/proxyagent/harness.py +0 -0
- {proxyagent-0.6.0 → proxyagent-0.8.0}/proxyagent/pricing.py +0 -0
- {proxyagent-0.6.0 → proxyagent-0.8.0}/proxyagent/security.py +0 -0
- {proxyagent-0.6.0 → proxyagent-0.8.0}/proxyagent/store.py +0 -0
- {proxyagent-0.6.0 → proxyagent-0.8.0}/proxyagent/tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: proxyagent
|
|
3
|
-
Version: 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
|
|
102
|
+
`proxyagent serve` ships a dashboard at `/` (reveal the admin token with
|
|
103
103
|
`proxyagent admin-token`):
|
|
104
104
|
|
|
105
|
-
- **
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
182
|
-
Bedrock
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
70
|
+
`proxyagent serve` ships a dashboard at `/` (reveal the admin token with
|
|
71
71
|
`proxyagent admin-token`):
|
|
72
72
|
|
|
73
|
-
- **
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
150
|
-
Bedrock
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
@@ -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
|
-
|
|
90
|
-
if not
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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,
|
|
141
|
-
resp = await client.post(url, headers=
|
|
142
|
-
if resp.status_code in FAILOVER_STATUS and i < len(
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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:
|
|
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
|
-
.
|
|
23
|
-
.
|
|
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
|
-
|
|
34
|
-
input,select{
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
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="
|
|
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="
|
|
84
|
-
<
|
|
85
|
-
<div class="
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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));["
|
|
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
|
-
//
|
|
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
|
-
|
|
173
|
+
refreshKeys();refreshTokens();refreshAliases();refreshLogs();
|
|
171
174
|
}catch(e){document.getElementById("gateerr").textContent="Invalid admin token."}
|
|
172
175
|
}
|
|
173
|
-
async function
|
|
174
|
-
const d=await(await api("/admin/
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
210
|
-
|
|
211
|
-
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|