proxyagent 0.4.0__tar.gz → 0.5.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.4.0
3
+ Version: 0.5.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>
@@ -168,6 +168,27 @@ proxyagent.run("claude-code", goal="build the app",
168
168
  proxy="https://proxy.you.com", token=token)
169
169
  ```
170
170
 
171
+ ## Harnesses & auth modes
172
+ You run an **agent harness**, and each one can authenticate several ways. The proxy's job
173
+ is to centralise *all* of them so the machine running the harness holds only a `pa_` token:
174
+
175
+ | Harness | Provider | Auth modes |
176
+ |---|---|---|
177
+ | **Claude Code** | Anthropic | API key · OAuth (subscription) · AWS Bedrock · Google Vertex |
178
+ | **Codex** | OpenAI | API key · OAuth (ChatGPT) · Azure |
179
+ | **Gemini CLI** | Google | API key · OAuth · Vertex |
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…).
185
+
186
+ ## Per-token budgets
187
+ Cap what any token can spend; once its summed cost crosses the cap, the proxy returns **402**.
188
+ ```bash
189
+ proxyagent token new ci --budget 5.00 # this token may spend at most $5
190
+ ```
191
+
171
192
  ## Supported providers
172
193
  `anthropic` · `openai` · `gemini` · `groq` · `openrouter` · `mistral` · `deepseek` ·
173
194
  `xai` · `together` — Anthropic uses its Messages API; the rest are OpenAI-compatible.
@@ -136,6 +136,27 @@ proxyagent.run("claude-code", goal="build the app",
136
136
  proxy="https://proxy.you.com", token=token)
137
137
  ```
138
138
 
139
+ ## Harnesses & auth modes
140
+ You run an **agent harness**, and each one can authenticate several ways. The proxy's job
141
+ is to centralise *all* of them so the machine running the harness holds only a `pa_` token:
142
+
143
+ | Harness | Provider | Auth modes |
144
+ |---|---|---|
145
+ | **Claude Code** | Anthropic | API key · OAuth (subscription) · AWS Bedrock · Google Vertex |
146
+ | **Codex** | OpenAI | API key · OAuth (ChatGPT) · Azure |
147
+ | **Gemini CLI** | Google | API key · OAuth · Vertex |
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…).
153
+
154
+ ## Per-token budgets
155
+ Cap what any token can spend; once its summed cost crosses the cap, the proxy returns **402**.
156
+ ```bash
157
+ proxyagent token new ci --budget 5.00 # this token may spend at most $5
158
+ ```
159
+
139
160
  ## Supported providers
140
161
  `anthropic` · `openai` · `gemini` · `groq` · `openrouter` · `mistral` · `deepseek` ·
141
162
  `xai` · `together` — Anthropic uses its Messages API; the rest are OpenAI-compatible.
@@ -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.4.0"
19
+ __version__ = "0.5.0"
20
20
  __all__ = ["run", "serve", "create_app", "Config", "Admin", "__version__"]
21
21
 
22
22
 
@@ -219,16 +219,19 @@ def token_new(
219
219
  scope: list[str] = typer.Option(["*"], "--scope", help="Allowed provider:model globs, e.g. anthropic:claude-*"),
220
220
  ttl: Optional[int] = typer.Option(None, "--ttl", help="Seconds until expiry."),
221
221
  rate: int = typer.Option(0, "--rate", help="Max requests/min (0 = unlimited)."),
222
+ budget: Optional[float] = typer.Option(None, "--budget", help="Max $ this token may spend."),
222
223
  proxy: str = typer.Option("http://127.0.0.1:8080", "--proxy"),
223
224
  admin: Optional[str] = typer.Option(None, "--admin"),
224
225
  ):
225
226
  """Mint a machine token — give it to a remote machine; it holds no real key."""
226
227
  if not _is_remote(proxy, admin):
227
- plain, _ = _local_store().create_token(label, list(scope), ttl_seconds=ttl, rate_limit=rate)
228
+ plain, _ = _local_store().create_token(label, list(scope), ttl_seconds=ttl,
229
+ rate_limit=rate, budget_usd=budget)
228
230
  else:
229
231
  with _admin_client(proxy, admin) as c:
230
232
  r = c.post("/admin/tokens", json={"label": label, "scope": list(scope),
231
- "ttl_seconds": ttl, "rate_limit": rate})
233
+ "ttl_seconds": ttl, "rate_limit": rate,
234
+ "budget_usd": budget})
232
235
  if r.status_code >= 400:
233
236
  err.print(f"[red]✗[/red] {r.text}"); raise typer.Exit(1)
234
237
  plain = r.json()["token"]
@@ -83,6 +83,27 @@ CATALOG: dict[str, dict] = {
83
83
  }
84
84
 
85
85
 
86
+ # Agent harnesses (what you actually RUN) and the auth modes each supports. The model
87
+ # providers above are the *backends*; these are the agents. Auth mode availability is
88
+ # what makes the proxy valuable — it can centralise all of them so the machine holds none.
89
+ HARNESSES: dict[str, dict] = {
90
+ "claude-code": {"label": "Claude Code", "provider": "anthropic", "color": "#D97757",
91
+ "install": "npm i -g @anthropic-ai/claude-code",
92
+ "auth": ["api_key", "oauth", "bedrock", "vertex"]},
93
+ "codex": {"label": "Codex", "provider": "openai", "color": "#10A37F",
94
+ "install": "npm i -g @openai/codex",
95
+ "auth": ["api_key", "oauth", "azure"]},
96
+ "gemini-cli": {"label": "Gemini CLI", "provider": "gemini", "color": "#4285F4",
97
+ "install": "npm i -g @google/gemini-cli",
98
+ "auth": ["api_key", "oauth", "vertex"]},
99
+ }
100
+ AUTH_LABELS = {"api_key": "API key", "oauth": "OAuth", "bedrock": "AWS Bedrock",
101
+ "vertex": "Google Vertex", "azure": "Azure"}
102
+ # Auth modes that are fully wired today (just a key swap). Others are surfaced in the
103
+ # UI as "available" and built out (Bedrock SigV4 / Vertex token / OAuth refresh).
104
+ AUTH_READY = {"api_key"}
105
+
106
+
86
107
  @dataclass
87
108
  class Config:
88
109
  home: Path = HOME
@@ -15,7 +15,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
15
15
  from pydantic import BaseModel
16
16
 
17
17
  from . import aliases, crypto
18
- from .config import CATALOG, Config, PROVIDERS
18
+ from .config import AUTH_LABELS, AUTH_READY, CATALOG, HARNESSES, Config, PROVIDERS
19
19
  from .providers import forward, scope_allows
20
20
  from .security import token_matches
21
21
  from .store import Store, now_ms
@@ -29,6 +29,7 @@ class TokenBody(BaseModel):
29
29
  scope: list[str] = ["*"]
30
30
  ttl_seconds: int | None = None
31
31
  rate_limit: int = 0
32
+ budget_usd: float | None = None
32
33
 
33
34
 
34
35
  class ProviderBody(BaseModel):
@@ -66,6 +67,9 @@ def create_app(config: Config | None = None) -> FastAPI:
66
67
  raise HTTPException(401, "token expired")
67
68
  if row["rate_limit"] and store.recent_request_count(row["id"]) >= row["rate_limit"]:
68
69
  raise HTTPException(429, "rate limit exceeded")
70
+ budget = row.get("budget_usd")
71
+ if budget is not None and store.token_spend(row["id"]) >= budget:
72
+ raise HTTPException(402, f"token budget of ${budget:.4f} exhausted")
69
73
  store.touch_token(row["id"])
70
74
  return row
71
75
 
@@ -147,10 +151,10 @@ def create_app(config: Config | None = None) -> FastAPI:
147
151
  async def create_token(body: TokenBody, authorization: str | None = Header(None),
148
152
  x_admin_token: str | None = Header(None)):
149
153
  require_admin(authorization, x_admin_token)
150
- plain, row = store.create_token(body.label, body.scope,
151
- ttl_seconds=body.ttl_seconds, rate_limit=body.rate_limit)
154
+ plain, row = store.create_token(body.label, body.scope, ttl_seconds=body.ttl_seconds,
155
+ rate_limit=body.rate_limit, budget_usd=body.budget_usd)
152
156
  return {"token": plain, "id": row["id"], "label": row["label"],
153
- "scope": body.scope, "note": "shown once — store it now"}
157
+ "scope": body.scope, "budget_usd": body.budget_usd, "note": "shown once — store it now"}
154
158
 
155
159
  @app.get("/admin/tokens")
156
160
  async def list_tokens_ep(authorization: str | None = Header(None),
@@ -161,7 +165,8 @@ def create_app(config: Config | None = None) -> FastAPI:
161
165
  out.append({"id": t["id"], "label": t["label"], "masked": t["masked"],
162
166
  "scope": _json.loads(t["scope_json"]), "revoked": bool(t["revoked"]),
163
167
  "rate_limit": t["rate_limit"], "expires_ms": t["expires_ms"],
164
- "last_used_ms": t["last_used_ms"]})
168
+ "last_used_ms": t["last_used_ms"], "budget_usd": t.get("budget_usd"),
169
+ "spent_usd": round(store.token_spend(t["id"]), 6)})
165
170
  return {"tokens": out}
166
171
 
167
172
  @app.delete("/admin/tokens/{tid}")
@@ -218,6 +223,26 @@ def create_app(config: Config | None = None) -> FastAPI:
218
223
  raise HTTPException(404, "no such credential")
219
224
  return {"ok": True}
220
225
 
226
+ @app.get("/admin/harnesses")
227
+ async def harnesses(authorization: str | None = Header(None),
228
+ x_admin_token: str | None = Header(None)):
229
+ require_admin(authorization, x_admin_token)
230
+ stored = {c["provider"] for c in store.list_credentials() if c["active"]}
231
+ out = []
232
+ for name, h in HARNESSES.items():
233
+ prov = PROVIDERS.get(h["provider"])
234
+ configured = bool(prov and prov.key) or h["provider"] in stored
235
+ out.append({
236
+ "name": name, "label": h["label"], "provider": h["provider"],
237
+ "color": h["color"], "install": h["install"],
238
+ "auth": [{"mode": m, "label": AUTH_LABELS.get(m, m),
239
+ "ready": m in AUTH_READY,
240
+ "connected": m == "api_key" and configured}
241
+ for m in h["auth"]],
242
+ "configured": configured,
243
+ })
244
+ return {"harnesses": out}
245
+
221
246
  @app.get("/admin/catalog")
222
247
  async def catalog(authorization: str | None = Header(None),
223
248
  x_admin_token: str | None = Header(None)):
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS proxy_agent_tokens (
22
22
  id TEXT PRIMARY KEY, hash TEXT NOT NULL UNIQUE, label TEXT,
23
23
  scope_json TEXT NOT NULL DEFAULT '["*"]', rate_limit INTEGER NOT NULL DEFAULT 0,
24
24
  created_ms BIGINT, expires_ms BIGINT, revoked INTEGER NOT NULL DEFAULT 0,
25
- last_used_ms BIGINT, masked TEXT
25
+ last_used_ms BIGINT, masked TEXT, budget_usd DOUBLE PRECISION
26
26
  );
27
27
  CREATE TABLE IF NOT EXISTS proxy_agent_keys (
28
28
  id TEXT PRIMARY KEY, provider TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'api_key',
@@ -47,22 +47,32 @@ class Store:
47
47
  self.db = DB(str(path), url=url)
48
48
  self.db.executescript(_SCHEMA)
49
49
  self.backend = "postgres" if self.db.pg else "sqlite"
50
+ # migrate older DBs created before budget_usd existed
51
+ try:
52
+ self.db.execute("ALTER TABLE proxy_agent_tokens ADD COLUMN budget_usd DOUBLE PRECISION")
53
+ except Exception:
54
+ pass
50
55
 
51
56
  # -- machine tokens ---------------------------------------------------- #
52
57
 
53
- def create_token(self, label, scope, *, ttl_seconds=None, rate_limit=0):
58
+ def create_token(self, label, scope, *, ttl_seconds=None, rate_limit=0, budget_usd=None):
54
59
  plain = new_token()
55
60
  tid = "tok_" + uuid.uuid4().hex[:12]
56
61
  expires = now_ms() + ttl_seconds * 1000 if ttl_seconds else None
57
62
  self.db.execute(
58
63
  """INSERT INTO proxy_agent_tokens
59
- (id, hash, label, scope_json, rate_limit, created_ms, expires_ms, masked)
60
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
64
+ (id, hash, label, scope_json, rate_limit, created_ms, expires_ms, masked, budget_usd)
65
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
61
66
  (tid, hash_token(plain), label, json.dumps(scope), rate_limit, now_ms(),
62
- expires, mask(plain)),
67
+ expires, mask(plain), budget_usd),
63
68
  )
64
69
  return plain, self.get_token(tid)
65
70
 
71
+ def token_spend(self, token_id: str) -> float:
72
+ r = self.db.fetchone(
73
+ "SELECT COALESCE(SUM(cost_usd),0) s FROM proxy_agent_calls WHERE token_id=?", (token_id,))
74
+ return float((r or {}).get("s", 0) or 0)
75
+
66
76
  def get_token(self, tid):
67
77
  return self.db.fetchone("SELECT * FROM proxy_agent_tokens WHERE id=?", (tid,))
68
78
 
@@ -88,10 +98,12 @@ class Store:
88
98
  # -- provider credentials (proxy_agent_keys) --------------------------- #
89
99
 
90
100
  def add_credential(self, provider, secret, *, kind="api_key", refresh=None,
91
- expires_ms=None, label=None, meta=None):
101
+ expires_ms=None, label=None, meta=None, replace=True):
92
102
  cid = "key_" + uuid.uuid4().hex[:12]
93
- # one active credential per provider: deactivate older ones
94
- self.db.execute("UPDATE proxy_agent_keys SET active=0 WHERE provider=?", (provider,))
103
+ # replace=True (default, the UI "connect"): swap the key. replace=False adds an
104
+ # ADDITIONAL active key to the provider's pool for failover/rotation.
105
+ if replace:
106
+ self.db.execute("UPDATE proxy_agent_keys SET active=0 WHERE provider=?", (provider,))
95
107
  self.db.execute(
96
108
  """INSERT INTO proxy_agent_keys
97
109
  (id, provider, kind, secret, refresh, expires_ms, label, created_ms, meta_json, active)
@@ -70,13 +70,20 @@
70
70
  </div>
71
71
 
72
72
  <div class="tabs">
73
- <div class="tab on" data-t="providers" onclick="tab('providers')">Providers</div>
73
+ <div class="tab on" data-t="harnesses" onclick="tab('harnesses')">Harnesses</div>
74
+ <div class="tab" data-t="providers" onclick="tab('providers')">Model endpoints</div>
74
75
  <div class="tab" data-t="tokens" onclick="tab('tokens')">Machine tokens</div>
75
76
  <div class="tab" data-t="models" onclick="tab('models')">Model routing</div>
76
77
  <div class="tab" data-t="activity" onclick="tab('activity')">Activity</div>
77
78
  </div>
78
79
 
79
- <section id="t_providers">
80
+ <section id="t_harnesses">
81
+ <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>
82
+ <div class="grid" id="harngrid"></div>
83
+ </section>
84
+
85
+ <section id="t_providers" class="hide">
86
+ <p class="muted" style="margin:0 0 14px">Raw model backends for model-agnostic harnesses (aider, Cline, Codex pointed elsewhere…).</p>
80
87
  <div class="grid" id="provgrid"></div>
81
88
  </section>
82
89
 
@@ -86,12 +93,13 @@
86
93
  <div class="row">
87
94
  <input id="tk_label" placeholder="label (e.g. macbook-01)"/>
88
95
  <input id="tk_scope" placeholder="scope: * or anthropic:claude-*" style="flex:1"/>
89
- <input id="tk_ttl" type="number" placeholder="ttl (s)" style="width:110px"/>
96
+ <input id="tk_ttl" type="number" placeholder="ttl (s)" style="width:100px"/>
97
+ <input id="tk_budget" type="number" step="0.01" placeholder="budget $" style="width:100px"/>
90
98
  <button onclick="mintToken()">Mint</button>
91
99
  </div>
92
100
  <div id="tk_out" class="tok hide"></div>
93
101
  </div>
94
- <div class="card"><table><thead><tr><th>ID</th><th>Label</th><th>Token</th><th>Scope</th><th>Status</th><th></th></tr></thead><tbody id="toks"></tbody></table></div>
102
+ <div class="card"><table><thead><tr><th>ID</th><th>Label</th><th>Token</th><th>Scope</th><th>Budget</th><th>Status</th><th></th></tr></thead><tbody id="toks"></tbody></table></div>
95
103
  </section>
96
104
 
97
105
  <section id="t_models" class="hide">
@@ -121,7 +129,7 @@ async function api(p,o={}){const r=await fetch(p,{...o,headers:{...H(),...(o.hea
121
129
  function val(id){return document.getElementById(id).value.trim()}
122
130
  function saveAdmin(){const v=document.getElementById("admintok").value.trim();if(v){localStorage.setItem("pa_admin",v);boot()}}
123
131
  function logout(){localStorage.removeItem("pa_admin");document.getElementById("app").classList.add("hide");document.getElementById("gate").classList.remove("hide")}
124
- function tab(t){document.querySelectorAll(".tab").forEach(e=>e.classList.toggle("on",e.dataset.t===t));["providers","tokens","models","activity"].forEach(s=>document.getElementById("t_"+s).classList.toggle("hide",s!==t))}
132
+ 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))}
125
133
 
126
134
  // ---- provider logos (inline, brand-tinted) ----
127
135
  const MARK={
@@ -148,9 +156,28 @@ async function boot(){
148
156
  document.getElementById("s_cost").textContent="$"+(u.usage.cost_usd||0).toFixed(4);
149
157
  document.getElementById("badge_backend").textContent=u.backend;
150
158
  document.getElementById("enc").textContent=u.encryption?"🔒 encrypted at rest":"⚠ encryption off";
151
- refreshProviders();refreshTokens();refreshAliases();refreshLogs();
159
+ renderHarnesses();refreshProviders();refreshTokens();refreshAliases();refreshLogs();
152
160
  }catch(e){document.getElementById("gateerr").textContent="Invalid admin token."}
153
161
  }
162
+ async function renderHarnesses(){
163
+ const d=await(await api("/admin/harnesses")).json();
164
+ document.getElementById("harngrid").innerHTML=d.harnesses.map(h=>{
165
+ const chips=h.auth.map(a=>{
166
+ const cls=a.connected?"badge on":(a.ready?"badge":"badge");
167
+ const tag=a.connected?"✓ connected":(a.ready?"connect":"soon");
168
+ return `<span class="${cls}" title="${a.ready?'available now':'coming soon'}">${a.label} · ${tag}</span>`}).join("");
169
+ 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>
170
+ ${h.configured?'<span class="pill ok">ready</span>':'<span class="pill no">connect a key</span>'}</div>
171
+ <div class="badges">${chips}</div>
172
+ <div class="models mono" style="font-size:11px">${h.install}</div>
173
+ <div class="row"><button class="sm" onclick="openConnect('h_${h.name}')">Connect API key</button></div>
174
+ <div class="connect" id="c_h_${h.name}">
175
+ <input id="k_h_${h.name}" type="password" placeholder="${h.provider} API key"/>
176
+ <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>
177
+ </div></div>`}).join("");
178
+ }
179
+ async function connectHarness(name,provider){const key=document.getElementById("k_h_"+name).value.trim();if(!key)return;
180
+ await api("/admin/providers",{method:"POST",body:JSON.stringify({provider,secret:key,kind:"api_key"})});renderHarnesses();refreshProviders()}
154
181
  async function refreshProviders(){
155
182
  const d=await(await api("/admin/catalog")).json();const g=document.getElementById("provgrid");
156
183
  let connected=0;
@@ -176,8 +203,8 @@ async function connect(n){const key=document.getElementById("k_"+n).value.trim()
176
203
  async function disconnect(id){await api("/admin/providers/"+id,{method:"DELETE"});refreshProviders()}
177
204
 
178
205
  async function refreshTokens(){const d=await(await api("/admin/tokens")).json();
179
- 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>${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("")}
180
- async function mintToken(){const body={label:val("tk_label")||"machine",scope:(val("tk_scope")||"*").split(",").map(s=>s.trim()),ttl_seconds:parseInt(val("tk_ttl"))||null};
206
+ 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("")}
207
+ async function mintToken(){const body={label:val("tk_label")||"machine",scope:(val("tk_scope")||"*").split(",").map(s=>s.trim()),ttl_seconds:parseInt(val("tk_ttl"))||null,budget_usd:parseFloat(val("tk_budget"))||null};
181
208
  const d=await(await api("/admin/tokens",{method:"POST",body:JSON.stringify(body)})).json();
182
209
  const el=document.getElementById("tk_out");el.classList.remove("hide");el.innerHTML=`<b>Token (shown once):</b><br/><span class="mono">${d.token}</span>`;refreshTokens()}
183
210
  async function revokeTok(id){await api("/admin/tokens/"+id,{method:"DELETE"});refreshTokens()}
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "proxyagent"
7
- version = "0.4.0"
7
+ version = "0.5.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"
@@ -165,3 +165,26 @@ def test_model_remap_reroutes_provider():
165
165
  _aliases.set_map({"gpt-4o": "anthropic:mock"})
166
166
  assert remap("openai", "gpt-4o") == ("anthropic", "mock")
167
167
  assert remap("openai", "gpt-4o-mini") == ("openai", "gpt-4o-mini") # no match
168
+
169
+
170
+ def test_budget_exhaustion_returns_402():
171
+ c = _client()
172
+ r = c.post("/admin/tokens", headers=ADMIN, json={"scope": ["*"], "budget_usd": 0.001})
173
+ tok, tid = r.json()["token"], r.json()["id"]
174
+ # under budget (mock costs $0) → ok
175
+ assert c.post("/anthropic/v1/messages", headers={"x-api-key": tok},
176
+ json={"model": "mock", "messages": [{"role": "user", "content": "hi"}]}).status_code == 200
177
+ # record spend over the cap, then the next call is blocked
178
+ c.app.state.store.log_request(token_id=tid, provider="anthropic", model="x", status=200, cost_usd=0.05)
179
+ assert c.post("/anthropic/v1/messages", headers={"x-api-key": tok},
180
+ json={"model": "mock", "messages": []}).status_code == 402
181
+
182
+
183
+ def test_harness_catalog():
184
+ c = _client()
185
+ h = c.get("/admin/harnesses", headers=ADMIN).json()["harnesses"]
186
+ names = {x["name"] for x in h}
187
+ assert {"claude-code", "codex", "gemini-cli"} <= names
188
+ cc = next(x for x in h if x["name"] == "claude-code")
189
+ assert {a["mode"] for a in cc["auth"]} == {"api_key", "oauth", "bedrock", "vertex"}
190
+ assert any(a["mode"] == "api_key" and a["ready"] for a in cc["auth"])
File without changes
File without changes