@wendellresearch-ai/benchwarmers 0.2.0 → 0.2.2

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.
Files changed (103) hide show
  1. package/AGENTS.md +152 -147
  2. package/BENCHMARKS.md +262 -262
  3. package/GATEWAY.md +415 -272
  4. package/NOTICE +28 -28
  5. package/PRIVACY.md +46 -46
  6. package/README.md +408 -314
  7. package/SECURITY.md +83 -83
  8. package/TERMS.md +22 -22
  9. package/USAGE.md +186 -163
  10. package/VERSION +1 -1
  11. package/bin/benchwarmers.js +16 -74
  12. package/cli.py +674 -0
  13. package/examples/config/gateway.json +5 -5
  14. package/examples/config/model-roles.json +11 -11
  15. package/gateway.py +939 -755
  16. package/launcher.py +120 -0
  17. package/mcp_server.py +266 -266
  18. package/metrics/alerts.py +116 -116
  19. package/metrics/aliases.py +41 -41
  20. package/metrics/bench_collections.py +141 -141
  21. package/metrics/catalog.py +304 -295
  22. package/metrics/client.py +804 -394
  23. package/metrics/cliproxy.py +191 -191
  24. package/metrics/connect.py +691 -691
  25. package/metrics/cost.py +171 -152
  26. package/metrics/decide.py +227 -227
  27. package/metrics/discover.py +318 -318
  28. package/metrics/endpoints.py +436 -436
  29. package/metrics/formats.py +1021 -0
  30. package/metrics/guard.py +647 -592
  31. package/metrics/learn.py +234 -221
  32. package/metrics/ledger.py +148 -0
  33. package/metrics/litellm_hub.py +206 -206
  34. package/metrics/model_roles.py +94 -84
  35. package/metrics/offload.py +166 -166
  36. package/metrics/owui.py +205 -205
  37. package/metrics/pacing.py +248 -0
  38. package/metrics/paths.py +60 -60
  39. package/metrics/quota.py +383 -0
  40. package/metrics/route.py +581 -579
  41. package/metrics/scheduler.py +275 -275
  42. package/metrics/sources.py +167 -167
  43. package/metrics/wrong.py +144 -144
  44. package/package.json +69 -65
  45. package/perf/README.md +89 -89
  46. package/perf/run.py +284 -261
  47. package/scripts/auto_retire.py +367 -367
  48. package/scripts/check.py +113 -106
  49. package/scripts/check_deprecations.py +378 -378
  50. package/scripts/check_themes.py +103 -103
  51. package/scripts/init.py +124 -124
  52. package/scripts/list_served.py +78 -78
  53. package/scripts/notice.py +78 -78
  54. package/scripts/onboard.py +410 -331
  55. package/scripts/provider_tags.py +129 -129
  56. package/scripts/reprefix.py +136 -136
  57. package/scripts/sync_config.py +105 -105
  58. package/scripts/test_auth.py +255 -255
  59. package/scripts/test_cli.py +189 -0
  60. package/scripts/test_client.py +277 -233
  61. package/scripts/test_connect.py +222 -222
  62. package/scripts/test_first_run.py +180 -180
  63. package/scripts/test_formats.py +549 -0
  64. package/scripts/test_hubs.py +328 -328
  65. package/scripts/test_launcher.py +67 -0
  66. package/scripts/test_llm_log.py +85 -81
  67. package/scripts/test_mcp.py +178 -178
  68. package/scripts/test_merged.py +215 -214
  69. package/scripts/test_no_owui.py +291 -291
  70. package/scripts/test_owui.py +90 -90
  71. package/scripts/test_quota.py +334 -0
  72. package/scripts/test_serve_edit.py +453 -438
  73. package/scripts/test_server_json.py +79 -0
  74. package/scripts/test_verdict.py +102 -98
  75. package/scripts/test_vision.py +231 -228
  76. package/serve.py +3972 -3764
  77. package/suite/mine.py +154 -154
  78. package/suite/pace.py +145 -145
  79. package/suite/run.py +1046 -1042
  80. package/suite/seed.py +421 -421
  81. package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
  82. package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
  83. package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
  84. package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
  85. package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
  86. package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
  87. package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
  88. package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
  89. package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
  90. package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
  91. package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
  92. package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
  93. package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
  94. package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
  95. package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
  96. package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
  97. package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
  98. package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
  99. package/ui/bench.css +430 -425
  100. package/ui/bench.js +1757 -1576
  101. package/ui/themes/dark.css +35 -35
  102. package/ui/themes/hardwood.css +52 -52
  103. package/ui/themes/rainforest.css +32 -32
@@ -1,691 +1,691 @@
1
- """Connect a provider by signing in on its own page, instead of pasting a key.
2
-
3
- python metrics/connect.py # each provider: its flow, and whether it can be used here
4
- python metrics/connect.py --selftest # every flow against a fake provider
5
-
6
- RELEASE-PLAN.md Phase 5, "Connect buttons", and decision 13: only flows a
7
- provider publishes for third-party apps. Never another tool's client, never a
8
- subscription meant for the provider's own tools (Claude plans, and the coding
9
- assistants' subscription logins that a proxy such as CLI Proxy API holds).
10
-
11
- FOUR FLOWS COVER EVERY PROVIDER
12
- -------------------------------
13
- - `key` PKCE, then the provider hands back an ordinary API key
14
- (OpenRouter, NanoGPT). Nothing expires, nothing to refresh.
15
- - `oauth` authorization code with PKCE, to a callback on this machine; an
16
- access token, and a refresh token when the provider gives one
17
- (Hugging Face, Pollinations, ChatGPT).
18
- - `device` the user types a short code on the provider's page; no callback,
19
- so it works where the browser is on another machine (GitHub).
20
- Any provider with a `device` URL also signs in this way when
21
- asked (`by_code`): the dashboard asks when it is not reached on
22
- this machine, where a callback to 127.0.0.1 could not arrive.
23
- - `local` nothing to sign in to here (Ollama: a local Ollama that is signed
24
- in to ollama.com serves its cloud models too).
25
-
26
- WHERE THINGS GO
27
- ---------------
28
- The key or access token goes where a pasted key goes: the llm CLI's keys.json
29
- under the endpoint's key name (`endpoints.set_key`). A refresh token goes in
30
- the same store as `<key name>.refresh`. The endpoint is saved by serve.py's
31
- `save_endpoint`, with the same test and rules as a typed one, plus a `connect`
32
- record (provider, when, expiry; never a secret). `fresh()` renews an expiring
33
- token just before a call; `metrics/client.py` asks it, and endpoints that need
34
- it are always called through that client, whatever BENCH_CLIENT says.
35
-
36
- ADDING A PROVIDER
37
- -----------------
38
- 1. Read its docs for a flow meant for third-party apps. If its only sign-in is
39
- a subscription login for its own tools, stop: decision 13 says no.
40
- 2. Add an entry to PROVIDERS. Fields:
41
- label, flow, base shown name; one of FLOWS; its OpenAI-compatible base
42
- authorize, token the authorize page; where the code (or device code,
43
- or refresh token) is exchanged
44
- device where a device code is requested (the device
45
- flow, or signing in by code); `device_json` when
46
- that request is JSON rather than a form
47
- client_id a PUBLIC client id, if the provider needs one. Never
48
- a secret: this file ships. Empty means "register an
49
- app first" (`register` says where), and an install
50
- can supply its own in config/connect.json
51
- scope, params authorize scope; extra authorize parameters, which
52
- may use {host_id} and {nonce}
53
- token_params extra fields on every token request
54
- loopback the callback host the provider accepts:
55
- "127.0.0.1" (default) or "localhost"
56
- entry fields for the endpoint: its `kind` (required, one
57
- of endpoints.KINDS); and when it is new, headers,
58
- `api: responses`, `models_url`
59
- docs, hint its docs; one line for when /models fails
60
- 3. Run `python metrics/connect.py --selftest`: it checks every entry's shape,
61
- and every flow against a fake provider.
62
- 4. Add an endpoint on the Endpoints page, pick the provider and sign in once for real.
63
- """
64
-
65
- from __future__ import annotations
66
-
67
- import base64
68
- import datetime as dt
69
- import hashlib
70
- import json
71
- import pathlib
72
- import re
73
- import secrets
74
- import sys
75
- import threading
76
- import time
77
- import urllib.error
78
- import urllib.parse
79
- import urllib.request
80
- import uuid
81
-
82
- sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
83
- import endpoints # noqa: E402
84
- import locked # noqa: E402
85
- from paths import DATA # noqa: E402
86
-
87
- CONFIG = DATA / "config" / "connect.json"
88
- FLOWS = ("key", "oauth", "device", "local")
89
- CALLBACK = "/connect/callback"
90
- PENDING_S = 900 # a sign-in left open longer is dropped (GitHub's device codes last 15 min)
91
- EARLY_S = 300 # renew a token this long before it expires
92
- UA = {"User-Agent": "benchwarmers", "Accept": "application/json"}
93
- REPO = "https://github.com/WendellResearch-AI/benchwarmers"
94
-
95
- PROVIDERS: dict[str, dict] = {
96
- "openrouter": {
97
- "label": "OpenRouter", "flow": "key", "base": "https://openrouter.ai/api/v1",
98
- "authorize": "https://openrouter.ai/auth",
99
- "token": "https://openrouter.ai/api/v1/auth/keys",
100
- "params": {"key_label": "Benchwarmers"}, "loopback": "localhost",
101
- # Who is calling, truthfully: some free models answer only named apps.
102
- "entry": {"kind": "aggregator", "headers": {"HTTP-Referer": REPO, "X-Title": "Benchwarmers"}},
103
- "docs": "https://openrouter.ai/docs/use-cases/oauth-pkce",
104
- },
105
- "nanogpt": {
106
- "label": "NanoGPT", "flow": "key", "base": "https://api.nano-gpt.com/api/v1",
107
- "authorize": "https://nano-gpt.com/auth",
108
- "token": "https://nano-gpt.com/api/v1/auth/keys",
109
- "scope": "api.use models.read", "params": {"client_name": "Benchwarmers"},
110
- "entry": {"kind": "aggregator"},
111
- "docs": "https://docs.nano-gpt.com/api-reference/miscellaneous/oauth-pkce",
112
- },
113
- "huggingface": {
114
- "label": "Hugging Face", "flow": "oauth", "base": "https://router.huggingface.co/v1",
115
- "authorize": "https://huggingface.co/oauth/authorize",
116
- "token": "https://huggingface.co/oauth/token",
117
- "device": "https://huggingface.co/oauth/device",
118
- "client_id": "", "scope": "openid inference-api",
119
- "register": "https://huggingface.co/settings/applications/new (no secret; redirect"
120
- " http://127.0.0.1/connect/callback; scopes openid, inference-api)",
121
- "entry": {"kind": "aggregator"}, # its router fronts many inference providers
122
- "docs": "https://huggingface.co/docs/hub/oauth",
123
- },
124
- "pollinations": {
125
- "label": "Pollinations", "flow": "oauth", "base": "https://gen.pollinations.ai/v1",
126
- "authorize": "https://enter.pollinations.ai/authorize",
127
- "token": "https://enter.pollinations.ai/api/oauth/token",
128
- "device": "https://enter.pollinations.ai/api/device/code", "device_json": True,
129
- "client_id": "", "params": {"models": "text", "expiry": "30"}, "loopback": "localhost",
130
- "register": "https://enter.pollinations.ai/keys (an app key, pk_...; redirect"
131
- " http://localhost/connect/callback)",
132
- "entry": {"kind": "aggregator"},
133
- "docs": "https://github.com/pollinations/pollinations/blob/main/BRING_YOUR_OWN_POLLEN.md",
134
- },
135
- "chatgpt": {
136
- "label": "ChatGPT plan", "flow": "oauth", "base": "https://api.openai.com/v1",
137
- "authorize": "https://auth.openai.com/api/accounts/authorize",
138
- "token": "https://auth.openai.com/api/accounts/oauth/token",
139
- # Registered during the first sign-in; the issued id comes back on the
140
- # callback and is used from then on.
141
- "client_id": "dynamic_agent_client",
142
- "scope": "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct",
143
- "params": {"agent_name_hint": "Benchwarmers", "ext_agent_host_id": "{host_id}",
144
- "nonce": "{nonce}", "resource": "https://api.openai.com/v1"},
145
- "token_params": {"resource": "https://api.openai.com/v1"},
146
- "entry": {"kind": "provider", "api": "responses"},
147
- "docs": "https://developers.openai.com/siwc/token-sharing-open-source",
148
- },
149
- "github": {
150
- "label": "GitHub Models", "flow": "device", "base": "https://models.github.ai/inference",
151
- "device": "https://github.com/login/device/code",
152
- "token": "https://github.com/login/oauth/access_token",
153
- "client_id": "",
154
- "register": "a GitHub App: Developer settings > GitHub Apps > New; Enable Device Flow;"
155
- " Account permissions > Models: Read",
156
- "entry": {"kind": "provider", "models_url": "https://models.github.ai/catalog/models"},
157
- "docs": "https://docs.github.com/en/github-models",
158
- },
159
- "ollama": {
160
- "label": "Ollama (this machine)", "flow": "local", "base": "http://localhost:11434/v1",
161
- "hint": "is Ollama running? For its cloud models, run `ollama signin` once",
162
- "entry": {"kind": "local"},
163
- "docs": "https://docs.ollama.com/openai",
164
- },
165
- }
166
-
167
-
168
- class ConnectError(Exception):
169
- """A sign-in that cannot go on; the text is for the person signing in."""
170
-
171
-
172
- _pending: dict[str, dict] = {} # id -> one sign-in in progress
173
- _lock = threading.Lock()
174
-
175
-
176
- def _config() -> dict:
177
- return locked.read_json(CONFIG)
178
-
179
-
180
- def host_id() -> str:
181
- """This install's stable id, made once (ChatGPT binds a sign-in to it). Not a secret."""
182
- def change(data: dict) -> str:
183
- return data.setdefault("host_id", f"urn:uuid:{uuid.uuid4()}")
184
- return locked.update(CONFIG, change)
185
-
186
-
187
- def client_id(provider: str) -> str:
188
- """The install's own (config/connect.json "client_ids"), else the shipped one."""
189
- return own_ids().get(provider) or PROVIDERS[provider].get("client_id", "")
190
-
191
-
192
- def own_ids() -> dict:
193
- """This install's own client ids, by provider: an app it registered itself
194
- (Use your own app), or the one ChatGPT issued it at the first sign-in."""
195
- ids = _config().get("client_ids")
196
- return ids if isinstance(ids, dict) else {}
197
-
198
-
199
- CLIENT_ID = re.compile(r"^[A-Za-z0-9._~:-]{3,200}$")
200
-
201
-
202
- def set_own_id(provider: str, cid: str) -> None:
203
- """Use your own app registration for `provider`, or "" to go back to the
204
- shipped one. Also the way out if a shipped app is ever withdrawn: every
205
- install can carry on with its own. Endpoints already signed in keep
206
- renewing with the id they were signed in with."""
207
- p = PROVIDERS.get(provider)
208
- if not p or not p.get("register"):
209
- raise ConnectError(f"{provider!r} has no app to register")
210
- cid = (cid or "").strip()
211
- if cid and not CLIENT_ID.match(cid):
212
- raise ConnectError("a client id is 3-200 letters, digits or . _ ~ : - (a public id, never a secret)")
213
-
214
- def change(data: dict) -> None:
215
- ids = data.setdefault("client_ids", {})
216
- if cid:
217
- ids[provider] = cid
218
- else:
219
- ids.pop(provider, None)
220
- locked.update(CONFIG, change)
221
-
222
-
223
- def ready(provider: str) -> str:
224
- """"" when this provider can be used here, else what is missing."""
225
- p = PROVIDERS[provider]
226
- if p["flow"] in ("oauth", "device") and not client_id(provider):
227
- return f"needs an app registration: under Use your own app, register {p.get('register', 'one')}"
228
- return ""
229
-
230
-
231
- def _post(url: str, fields: dict, as_json: bool = False) -> dict:
232
- """A token request. The reply as a dict, an OAuth error included."""
233
- import client # its no-redirect opener: a 3xx never gets the code
234
- body = json.dumps(fields).encode() if as_json else urllib.parse.urlencode(fields).encode()
235
- kind = "application/json" if as_json else "application/x-www-form-urlencoded"
236
- request = urllib.request.Request(url, body, {**UA, "Content-Type": kind})
237
- try:
238
- with client._OPENER.open(request, timeout=30) as r:
239
- return json.loads(r.read() or b"{}")
240
- except urllib.error.HTTPError as e:
241
- try:
242
- data = json.loads(e.read() or b"{}")
243
- except ValueError:
244
- data = {}
245
- return data if isinstance(data, dict) and data.get("error") else \
246
- {"error": f"HTTP {e.code}", "error_description": str(data)[:200]}
247
- except (urllib.error.URLError, OSError, ValueError) as e:
248
- return {"error": "unreachable", "error_description": str(getattr(e, "reason", e))}
249
-
250
-
251
- def _failed(reply: dict, what: str) -> ConnectError:
252
- why = reply.get("error_description") or reply.get("message") or reply.get("error")
253
- return ConnectError(f"{what}: {why}")
254
-
255
-
256
- def _tokens(record: dict, reply: dict, cid: str) -> dict:
257
- """What a successful token reply leaves for serve.py to save: for the
258
- endpoint the sign-in was started for (a provider can be several)."""
259
- secret = reply.get("key") or reply.get("access_token")
260
- if not secret:
261
- raise _failed(reply, "the provider sent no key")
262
- expires = None
263
- try:
264
- if reply.get("expires_in"):
265
- expires = (dt.datetime.now(dt.timezone.utc)
266
- + dt.timedelta(seconds=int(reply["expires_in"]))).isoformat(timespec="seconds")
267
- except (TypeError, ValueError):
268
- pass # an odd expiry: treated as none, and a 401 says when
269
- return {"provider": record["provider"], "endpoint": record.get("endpoint") or record["provider"],
270
- "secret": secret, "refresh": reply.get("refresh_token", ""), "expires": expires, "client_id": cid}
271
-
272
-
273
- def _sweep() -> None:
274
- now = time.time()
275
- for k in [k for k, v in _pending.items() if now - v["at"] > PENDING_S]:
276
- del _pending[k]
277
-
278
-
279
- def start(provider: str, port: int, endpoint: str = "", by_code: bool = False) -> dict:
280
- """Begin a sign-in for `endpoint` (default: the provider's id). {"url"} to
281
- open (key, oauth), or {"code", "verify"} to show (device, or `by_code`);
282
- either way an "id" to poll."""
283
- p = PROVIDERS.get(provider)
284
- if not p or p["flow"] == "local":
285
- raise ConnectError(f"{provider!r} has no sign-in")
286
- if (missing := ready(provider)):
287
- raise ConnectError(missing)
288
- if by_code and not p.get("device"):
289
- raise ConnectError(f"{p['label']} signs in only through a page on the machine Benchwarmers runs on;"
290
- " from here, add it with an API key instead")
291
- cid = client_id(provider)
292
- sid = secrets.token_urlsafe(24)
293
- record = {"provider": provider, "endpoint": endpoint or provider, "client_id": cid,
294
- "at": time.time(), "result": None}
295
- if p["flow"] == "device" or by_code:
296
- fields = {"client_id": cid, **({"scope": p["scope"]} if p.get("scope") else {})}
297
- reply = _post(p["device"], fields, as_json=bool(p.get("device_json")))
298
- if not reply.get("device_code"):
299
- raise _failed(reply, "the provider would not start a sign-in")
300
- interval = int(reply.get("interval", 5))
301
- record.update(device_code=reply["device_code"], interval=interval, next=time.time() + interval)
302
- out = {"id": sid, "code": reply.get("user_code", ""),
303
- "verify": reply.get("verification_uri_complete") or reply.get("verification_uri", "")}
304
- else:
305
- verifier = secrets.token_urlsafe(64)
306
- challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
307
- redirect = f"http://{p.get('loopback', '127.0.0.1')}:{port}{CALLBACK}"
308
- if p["flow"] == "key":
309
- # No registered redirect to match, and OpenRouter's docs never say it
310
- # sends `state` back: the sign-in's id rides in the path instead.
311
- redirect += "/" + sid
312
- extra = {k: v.format(host_id=host_id() if "{host_id}" in v else "",
313
- nonce=secrets.token_urlsafe(16)) for k, v in p.get("params", {}).items()}
314
- if p["flow"] == "key":
315
- query = {"callback_url": redirect}
316
- else:
317
- query = {"response_type": "code", "client_id": cid, "redirect_uri": redirect}
318
- query.update({"state": sid, "code_challenge": challenge, "code_challenge_method": "S256",
319
- **({"scope": p["scope"]} if p.get("scope") else {}), **extra})
320
- record.update(verifier=verifier, redirect=redirect)
321
- out = {"id": sid, "url": p["authorize"] + "?" + urllib.parse.urlencode(query)}
322
- with _lock:
323
- _sweep()
324
- _pending[sid] = record
325
- return out
326
-
327
-
328
- def callback_id(path: str, query: dict) -> str:
329
- """The sign-in a callback belongs to: `state`, or the id in its path (key flow)."""
330
- tail = path.split("?")[0][len(CALLBACK):].strip("/")
331
- return query.get("state") or tail
332
-
333
-
334
- def finish(query: dict) -> dict:
335
- """The callback: check the state, exchange the code, return _tokens' dict.
336
- The caller saves it and hands poll() the outcome with settle(), so the
337
- dashboard tab that started the sign-in can tell, and no secret waits here."""
338
- sid = query.get("state", "")
339
- with _lock: # checked and marked at once: a doubled callback
340
- _sweep() # (a prefetch, a retry) exchanges the code once
341
- record = _pending.get(sid)
342
- if not record or record.get("used") or "verifier" not in record:
343
- raise ConnectError("this sign-in is unknown or has expired; start it again from Benchwarmers")
344
- record["used"] = True
345
- p = PROVIDERS[record["provider"]]
346
- try:
347
- if query.get("error"):
348
- raise ConnectError(f"{p['label']} said: {query.get('error_description') or query['error']}")
349
- if not query.get("code"):
350
- raise ConnectError("the provider sent no code")
351
- if p["flow"] == "key":
352
- reply = _post(p["token"], {"code": query["code"], "code_verifier": record["verifier"],
353
- "code_challenge_method": "S256"}, as_json=True)
354
- cid = ""
355
- else:
356
- # ChatGPT's first sign-in issues this install its own client id,
357
- # kept at once (not with the endpoint, which a failed save loses)
358
- # because the next sign-in must use it.
359
- cid = query.get("client_id") or record["client_id"]
360
- if cid != record["client_id"]:
361
- locked.update(CONFIG, lambda data: data.setdefault("client_ids", {}).update(
362
- {record["provider"]: cid}))
363
- reply = _post(p["token"], {"grant_type": "authorization_code", "code": query["code"],
364
- "client_id": cid, "redirect_uri": record["redirect"],
365
- "code_verifier": record["verifier"], **p.get("token_params", {})})
366
- return _tokens(record, reply, cid)
367
- except ConnectError as exc:
368
- record["result"] = {"error": str(exc)}
369
- raise
370
-
371
-
372
- def settle(sid: str, outcome: dict) -> None:
373
- """What became of a finished sign-in, for poll() to report."""
374
- with _lock:
375
- if sid in _pending:
376
- _pending[sid]["result"] = outcome
377
-
378
-
379
- def poll(sid: str) -> dict:
380
- """{"state": "pending"}, {"state": "error", "error"}, or {"state": "done", ...}
381
- once: a device sign-in's tokens (the caller saves them), or what settle()
382
- recorded for a redirect sign-in."""
383
- with _lock:
384
- _sweep()
385
- record = _pending.get(sid)
386
- # One token request at a time: polls overlap when one is slow, and a
387
- # second request for a used device code would overwrite the tokens.
388
- due = bool(record) and "device_code" in record and record["result"] is None \
389
- and not record.get("asking") and time.time() >= record["next"]
390
- if due:
391
- record["asking"] = True
392
- if not record:
393
- return {"state": "error", "error": "this sign-in is unknown or has expired; start it again"}
394
- if due:
395
- try:
396
- p = PROVIDERS[record["provider"]]
397
- reply = _post(p["token"], {"client_id": record["client_id"], "device_code": record["device_code"],
398
- "grant_type": "urn:ietf:params:oauth:grant-type:device_code"})
399
- error = reply.get("error")
400
- if error == "slow_down":
401
- record["interval"] += 5
402
- if error in ("authorization_pending", "slow_down"):
403
- record["next"] = time.time() + record["interval"]
404
- elif error:
405
- record["result"] = {"error": str(_failed(reply, "the sign-in did not finish"))}
406
- else:
407
- try:
408
- record["result"] = _tokens(record, reply, record["client_id"])
409
- except ConnectError as exc:
410
- record["result"] = {"error": str(exc)}
411
- finally:
412
- record["asking"] = False
413
- result = record["result"]
414
- if result is None:
415
- return {"state": "pending"}
416
- with _lock:
417
- _pending.pop(sid, None) # handed over once, then forgotten
418
- return {"state": "error", **result} if "error" in result else {"state": "done", **result}
419
-
420
-
421
- def entry_field(tokens: dict) -> dict:
422
- """The endpoint's `connect` field for a fresh sign-in: no secrets."""
423
- return {"provider": tokens["provider"], "at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
424
- **({"expires": tokens["expires"]} if tokens.get("expires") else {}),
425
- **({"client_id": tokens["client_id"]} if tokens.get("client_id") else {}),
426
- **({"refreshes": True} if tokens.get("refresh") else {})}
427
-
428
-
429
- def fresh(key_name: str) -> str:
430
- """Renew the token behind `key_name` if it is about to expire. "" when it is
431
- good (or needs nothing), else why it cannot be used: the caller fails the
432
- call with that, and suite/run.py classes it `signin`, never a retirement."""
433
- name, entry = next(((n, e) for n, e in endpoints.load().items() if e.get("key") == key_name),
434
- (None, {}))
435
- conn = entry.get("connect") or {}
436
- if not (conn.get("refreshes") and conn.get("expires")):
437
- return ""
438
- if _seconds_left(conn) > EARLY_S:
439
- return ""
440
- # One renewal at a time, across the gateway's threads and other processes:
441
- # a refresh token is single-use, so two racing renewals leave one broken.
442
- with renewal_lock(key_name):
443
- conn = (endpoints.load().get(name) or {}).get("connect") or {}
444
- if _seconds_left(conn) > EARLY_S:
445
- return "" # another caller renewed it meanwhile
446
- p = PROVIDERS.get(conn.get("provider", ""), {})
447
- token = endpoints.keys().get(f"{key_name}.refresh", "")
448
- if not (p and token):
449
- return f"the sign-in for {name} has expired; sign in again on the Endpoints page"
450
- reply = _post(p["token"], {"grant_type": "refresh_token", "refresh_token": token,
451
- "client_id": conn.get("client_id") or client_id(conn["provider"]),
452
- **p.get("token_params", {})})
453
- try:
454
- tokens = _tokens({"provider": conn["provider"], "endpoint": name}, reply, conn.get("client_id", ""))
455
- except ConnectError as exc:
456
- return f"the sign-in for {name} could not be renewed ({exc}); sign in again on the Endpoints page"
457
- # The refresh token first: it is spent now, and an access token without
458
- # its replacement could never be renewed. A crash after it only costs
459
- # one more renewal.
460
- if tokens["refresh"]:
461
- endpoints.set_key(f"{key_name}.refresh", tokens["refresh"])
462
- endpoints.set_key(key_name, tokens["secret"])
463
- stamp = dt.datetime.now().astimezone().isoformat(timespec="seconds")
464
- locked.update(endpoints.FILE, lambda data: (data.get(name) or {}).get("connect", {}).update(
465
- expires=tokens["expires"], renewed=stamp)) # removed meanwhile: nothing to update
466
- return ""
467
-
468
-
469
- def renewal_lock(key_name: str):
470
- """Held to renew a token and to save a new sign-in, so one never overwrites the other."""
471
- return locked.lock(endpoints.FILE.with_name(f"connect-{key_name}"))
472
-
473
-
474
- def _seconds_left(conn: dict) -> float:
475
- try:
476
- return (dt.datetime.fromisoformat(conn["expires"]) - dt.datetime.now(dt.timezone.utc)).total_seconds()
477
- except (KeyError, TypeError, ValueError):
478
- return 0.0
479
-
480
-
481
- def standing(conn: dict | None) -> dict | None:
482
- """An endpoint's `connect` record as the dashboard shows it: who signed in,
483
- until when, whether that has passed. No secrets."""
484
- if not conn:
485
- return None
486
- gone = bool(conn.get("expires")) and not conn.get("refreshes") and _seconds_left(conn) <= 0
487
- p = PROVIDERS.get(conn.get("provider", ""), {})
488
- return {**conn, "label": p.get("label", ""), "flow": p.get("flow", ""), "expired": gone}
489
-
490
-
491
- def check() -> list[str]:
492
- """Every PROVIDERS entry has what its flow needs."""
493
- problems = []
494
- needs = {"key": ("authorize", "token"), "oauth": ("authorize", "token"),
495
- "device": ("device", "token"), "local": ()}
496
- for pid, p in PROVIDERS.items():
497
- if p.get("flow") not in FLOWS:
498
- problems.append(f"{pid}: flow must be one of {FLOWS}")
499
- continue
500
- if not endpoints.NAME.match(pid):
501
- problems.append(f"{pid}: the id names the endpoint, so it must match endpoints.NAME")
502
- for field in ("label", "base", "docs", *needs[p["flow"]]):
503
- if not p.get(field):
504
- problems.append(f"{pid}: {field} is missing")
505
- for field in ("base", "authorize", "token", "device", "docs"):
506
- url = p.get(field, "")
507
- if url and not url.startswith("https://") and not (pid == "ollama" and url.startswith("http://localhost")):
508
- problems.append(f"{pid}: {field} must be https")
509
- if p["flow"] in ("oauth", "device") and not p.get("client_id") and not p.get("register"):
510
- problems.append(f"{pid}: with no shipped client_id, say where to register one")
511
- if p.get("loopback", "127.0.0.1") not in ("127.0.0.1", "localhost"):
512
- problems.append(f"{pid}: loopback is 127.0.0.1 or localhost")
513
- for k in ("params", "token_params", "entry"):
514
- if not isinstance(p.get(k, {}), dict):
515
- problems.append(f"{pid}: {k} must be an object")
516
- if (p.get("entry") or {}).get("kind") not in set(endpoints.KINDS) - {"hub"}:
517
- problems.append(f"{pid}: entry.kind is provider, aggregator or local (a hub is added by hand)")
518
- return problems
519
-
520
-
521
- def main() -> None:
522
- for pid, p in PROVIDERS.items():
523
- print(f" {pid:<13} {p['flow']:<7} {ready(pid) or 'ready'}")
524
-
525
-
526
- def _selftest() -> None:
527
- """Shapes, then every flow against a fake provider: a PKCE check that really
528
- hashes, single-use state, device polling, and renewal under the lock."""
529
- import tempfile
530
- from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
531
-
532
- assert not (problems := check()), problems
533
- seen: dict = {"challenges": {}, "polls": 0}
534
-
535
- class Fake(BaseHTTPRequestHandler):
536
- def do_POST(self): # noqa: N802
537
- raw = self.rfile.read(int(self.headers.get("Content-Length") or 0)).decode()
538
- f = json.loads(raw) if self.headers.get("Content-Type", "").startswith("application/json") \
539
- else dict(urllib.parse.parse_qsl(raw))
540
- reply, status = {"error": "invalid_request"}, 400
541
- if self.path == "/device":
542
- reply, status = {"device_code": "dc", "user_code": "ABCD-1234",
543
- "verification_uri": "https://fake/device", "interval": 0}, 200
544
- elif f.get("grant_type", "").endswith("device_code"):
545
- seen["polls"] += 1
546
- reply, status = ({"error": "authorization_pending"}, 400) if seen["polls"] == 1 else \
547
- ({"access_token": "ghu_x", "refresh_token": "ghr_x", "expires_in": 28800}, 200)
548
- elif f.get("grant_type") == "refresh_token":
549
- reply, status = ({"access_token": "fresh", "refresh_token": "r2", "expires_in": 3600}, 200) \
550
- if f.get("refresh_token") == "r1" else ({"error": "invalid_grant"}, 400)
551
- elif "code" in f:
552
- want = seen["challenges"].get(f["code"])
553
- got = base64.urlsafe_b64encode(hashlib.sha256(f["code_verifier"].encode()).digest()).rstrip(b"=").decode()
554
- if want and want == got:
555
- reply, status = ({"key": "sk-key"} if self.path == "/keys" else
556
- {"access_token": "at", "refresh_token": "r1", "expires_in": 3600}), 200
557
- body = json.dumps(reply).encode()
558
- self.send_response(status)
559
- self.send_header("Content-Type", "application/json")
560
- self.send_header("Content-Length", str(len(body)))
561
- self.end_headers()
562
- self.wfile.write(body)
563
-
564
- def log_message(self, *_a):
565
- pass
566
-
567
- server = ThreadingHTTPServer(("127.0.0.1", 0), Fake)
568
- threading.Thread(target=server.serve_forever, daemon=True).start()
569
- fake = f"http://127.0.0.1:{server.server_port}"
570
- global CONFIG
571
- saved = (dict(PROVIDERS), CONFIG, endpoints.FILE, endpoints.LLM_DIR)
572
- with tempfile.TemporaryDirectory() as tmp:
573
- try:
574
- CONFIG, endpoints.FILE, endpoints.LLM_DIR = (pathlib.Path(tmp) / "connect.json",
575
- pathlib.Path(tmp) / "endpoints.json", pathlib.Path(tmp))
576
- PROVIDERS.update(
577
- k={**PROVIDERS["openrouter"], "authorize": fake + "/auth", "token": fake + "/keys"},
578
- o={**PROVIDERS["chatgpt"], "authorize": fake + "/auth", "token": fake + "/token"},
579
- d={**PROVIDERS["github"], "client_id": "Iv1.x", "device": fake + "/device", "token": fake + "/token"},
580
- h={**PROVIDERS["huggingface"]})
581
-
582
- def soon_past() -> str:
583
- return (dt.datetime.now(dt.timezone.utc) - dt.timedelta(seconds=5)).isoformat()
584
-
585
- def sign_in(pid: str, port: int = 8122) -> tuple[dict, dict]:
586
- url = start(pid, port)["url"]
587
- q = dict(urllib.parse.parse_qsl(urllib.parse.urlsplit(url).query))
588
- seen["challenges"]["c-" + pid] = q["code_challenge"]
589
- return q, finish({"state": q["state"], "code": "c-" + pid, "client_id": "issued"})
590
-
591
- q, got = sign_in("k")
592
- assert q["callback_url"] == "http://localhost:8122/connect/callback/" + q["state"] and q["key_label"], q
593
- assert callback_id(f"/connect/callback/{q['state']}?code=x", {"code": "x"}) == q["state"]
594
- assert callback_id("/connect/callback?state=s&code=x", {"state": "s"}) == "s"
595
- assert got["secret"] == "sk-key" and not got["refresh"] and got["expires"] is None, got
596
- assert got["endpoint"] == "k" # by default, the provider's id
597
- assert poll(q["state"]) == {"state": "pending"} # until it is saved
598
- settle(q["state"], {"ok": True, "served": 3})
599
- assert poll(q["state"]) == {"state": "done", "ok": True, "served": 3}
600
- assert poll(q["state"])["state"] == "error" # handed over once
601
- try:
602
- finish({"state": q["state"], "code": "c-k"})
603
- raise AssertionError("a used state was accepted twice")
604
- except ConnectError:
605
- pass
606
-
607
- q, got = sign_in("o", port=9000)
608
- assert q["redirect_uri"] == "http://127.0.0.1:9000/connect/callback", q
609
- assert q["ext_agent_host_id"] == host_id() and q["nonce"] and "{" not in q["nonce"], q
610
- assert q["resource"] == "https://api.openai.com/v1", q
611
- assert q["client_id"] == "dynamic_agent_client" and got["client_id"] == "issued", (q, got)
612
- assert client_id("o") == "issued" # kept for the next sign-in at once
613
- assert got["secret"] == "at" and got["refresh"] == "r1" and got["expires"], got
614
-
615
- # A wrong verifier is refused by the provider, and says so.
616
- url = start("o", 9000)["url"]
617
- state = dict(urllib.parse.parse_qsl(urllib.parse.urlsplit(url).query))["state"]
618
- seen["challenges"]["c-bad"] = "not-the-challenge"
619
- try:
620
- finish({"state": state, "code": "c-bad"})
621
- raise AssertionError("an exchange the provider refused looked fine")
622
- except ConnectError as exc:
623
- assert "no key" in str(exc), exc
624
- assert standing({"provider": "o", "expires": soon_past()})["expired"]
625
- assert not standing({"provider": "o", "expires": soon_past(), "refreshes": True})["expired"]
626
- assert poll(state)["state"] == "error"
627
- try:
628
- finish({"state": state, "error": "access_denied"})
629
- raise AssertionError("a finished sign-in was reused")
630
- except ConnectError:
631
- pass
632
-
633
- # Signing in by code where there is a device URL (JSON for Pollinations); refused where there is none.
634
- PROVIDERS["c"] = {**PROVIDERS["pollinations"], "client_id": "pk_x", "device": fake + "/device",
635
- "token": fake + "/token"}
636
- c = start("c", 8122, endpoint="c", by_code=True)
637
- assert c["code"] == "ABCD-1234" and poll(c["id"]) == {"state": "pending"}, c
638
- assert poll(c["id"])["state"] == "done"
639
- try:
640
- start("k", 8122, by_code=True)
641
- raise AssertionError("a provider with no device URL started a code sign-in")
642
- except ConnectError as exc:
643
- assert "API key" in str(exc)
644
- seen["polls"] = 0
645
-
646
- d = start("d", 8122, endpoint="d-2")
647
- assert d["code"] == "ABCD-1234" and poll(d["id"]) == {"state": "pending"}
648
- done = poll(d["id"])
649
- assert done["state"] == "done" and done["secret"] == "ghu_x" and done["refresh"] == "ghr_x", done
650
- assert done["endpoint"] == "d-2" # the one it was started for
651
-
652
- try:
653
- start("h", 8122)
654
- raise AssertionError("started a sign-in with no client id")
655
- except ConnectError as exc:
656
- assert "app registration" in str(exc)
657
- # Use your own app: set, used, cleared; refused for a provider with nothing to register.
658
- set_own_id("h", "hf-own")
659
- assert "client_id=hf-own" in start("h", 8122)["url"] and own_ids()["h"] == "hf-own"
660
- set_own_id("h", "")
661
- assert "h" not in own_ids() and ready("h")
662
- for bad in (("k", "x-id"), ("h", "has space"), ("h", "a" * 300)):
663
- try:
664
- set_own_id(*bad)
665
- raise AssertionError(f"accepted {bad}")
666
- except ConnectError:
667
- pass
668
- set_own_id("h", "hf-own")
669
-
670
- # Renewal: due, renewed once, written where a key goes; then a dead refresh token.
671
- soon = (dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=60)).isoformat()
672
- endpoints.FILE.write_text(json.dumps({"o": {"base": "https://x/v1", "key": "o", "connect": {
673
- "provider": "o", "expires": soon, "client_id": "issued", "refreshes": True}}}), encoding="utf-8")
674
- endpoints.set_key("o", "at")
675
- endpoints.set_key("o.refresh", "r1")
676
- assert fresh("o") == "" and endpoints.keys()["o"] == "fresh" and endpoints.keys()["o.refresh"] == "r2"
677
- assert _seconds_left(endpoints.load()["o"]["connect"]) > EARLY_S
678
- assert fresh("o") == "" # not due: no request
679
- locked.update(endpoints.FILE, lambda data: data["o"]["connect"].update(expires=soon))
680
- assert "sign in again" in fresh("o") # r2 is not one the fake accepts
681
- assert fresh("nobody") == ""
682
- finally:
683
- PROVIDERS.clear()
684
- PROVIDERS.update(saved[0])
685
- CONFIG, endpoints.FILE, endpoints.LLM_DIR = saved[1:]
686
- server.shutdown()
687
- print("ok")
688
-
689
-
690
- if __name__ == "__main__":
691
- _selftest() if "--selftest" in sys.argv else main()
1
+ """Connect a provider by signing in on its own page, instead of pasting a key.
2
+
3
+ python metrics/connect.py # each provider: its flow, and whether it can be used here
4
+ python metrics/connect.py --selftest # every flow against a fake provider
5
+
6
+ RELEASE-PLAN.md Phase 5, "Connect buttons", and decision 13: only flows a
7
+ provider publishes for third-party apps. Never another tool's client, never a
8
+ subscription meant for the provider's own tools (Claude plans, and the coding
9
+ assistants' subscription logins that a proxy such as CLI Proxy API holds).
10
+
11
+ FOUR FLOWS COVER EVERY PROVIDER
12
+ -------------------------------
13
+ - `key` PKCE, then the provider hands back an ordinary API key
14
+ (OpenRouter, NanoGPT). Nothing expires, nothing to refresh.
15
+ - `oauth` authorization code with PKCE, to a callback on this machine; an
16
+ access token, and a refresh token when the provider gives one
17
+ (Hugging Face, Pollinations, ChatGPT).
18
+ - `device` the user types a short code on the provider's page; no callback,
19
+ so it works where the browser is on another machine (GitHub).
20
+ Any provider with a `device` URL also signs in this way when
21
+ asked (`by_code`): the dashboard asks when it is not reached on
22
+ this machine, where a callback to 127.0.0.1 could not arrive.
23
+ - `local` nothing to sign in to here (Ollama: a local Ollama that is signed
24
+ in to ollama.com serves its cloud models too).
25
+
26
+ WHERE THINGS GO
27
+ ---------------
28
+ The key or access token goes where a pasted key goes: the llm CLI's keys.json
29
+ under the endpoint's key name (`endpoints.set_key`). A refresh token goes in
30
+ the same store as `<key name>.refresh`. The endpoint is saved by serve.py's
31
+ `save_endpoint`, with the same test and rules as a typed one, plus a `connect`
32
+ record (provider, when, expiry; never a secret). `fresh()` renews an expiring
33
+ token just before a call; `metrics/client.py` asks it, and endpoints that need
34
+ it are always called through that client, whatever BENCH_CLIENT says.
35
+
36
+ ADDING A PROVIDER
37
+ -----------------
38
+ 1. Read its docs for a flow meant for third-party apps. If its only sign-in is
39
+ a subscription login for its own tools, stop: decision 13 says no.
40
+ 2. Add an entry to PROVIDERS. Fields:
41
+ label, flow, base shown name; one of FLOWS; its OpenAI-compatible base
42
+ authorize, token the authorize page; where the code (or device code,
43
+ or refresh token) is exchanged
44
+ device where a device code is requested (the device
45
+ flow, or signing in by code); `device_json` when
46
+ that request is JSON rather than a form
47
+ client_id a PUBLIC client id, if the provider needs one. Never
48
+ a secret: this file ships. Empty means "register an
49
+ app first" (`register` says where), and an install
50
+ can supply its own in config/connect.json
51
+ scope, params authorize scope; extra authorize parameters, which
52
+ may use {host_id} and {nonce}
53
+ token_params extra fields on every token request
54
+ loopback the callback host the provider accepts:
55
+ "127.0.0.1" (default) or "localhost"
56
+ entry fields for the endpoint: its `kind` (required, one
57
+ of endpoints.KINDS); and when it is new, headers,
58
+ `api: responses`, `models_url`
59
+ docs, hint its docs; one line for when /models fails
60
+ 3. Run `python metrics/connect.py --selftest`: it checks every entry's shape,
61
+ and every flow against a fake provider.
62
+ 4. Add an endpoint on the Endpoints page, pick the provider and sign in once for real.
63
+ """
64
+
65
+ from __future__ import annotations
66
+
67
+ import base64
68
+ import datetime as dt
69
+ import hashlib
70
+ import json
71
+ import pathlib
72
+ import re
73
+ import secrets
74
+ import sys
75
+ import threading
76
+ import time
77
+ import urllib.error
78
+ import urllib.parse
79
+ import urllib.request
80
+ import uuid
81
+
82
+ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
83
+ import endpoints # noqa: E402
84
+ import locked # noqa: E402
85
+ from paths import DATA # noqa: E402
86
+
87
+ CONFIG = DATA / "config" / "connect.json"
88
+ FLOWS = ("key", "oauth", "device", "local")
89
+ CALLBACK = "/connect/callback"
90
+ PENDING_S = 900 # a sign-in left open longer is dropped (GitHub's device codes last 15 min)
91
+ EARLY_S = 300 # renew a token this long before it expires
92
+ UA = {"User-Agent": "benchwarmers", "Accept": "application/json"}
93
+ REPO = "https://github.com/WendellResearch-AI/benchwarmers"
94
+
95
+ PROVIDERS: dict[str, dict] = {
96
+ "openrouter": {
97
+ "label": "OpenRouter", "flow": "key", "base": "https://openrouter.ai/api/v1",
98
+ "authorize": "https://openrouter.ai/auth",
99
+ "token": "https://openrouter.ai/api/v1/auth/keys",
100
+ "params": {"key_label": "Benchwarmers"}, "loopback": "localhost",
101
+ # Who is calling, truthfully: some free models answer only named apps.
102
+ "entry": {"kind": "aggregator", "headers": {"HTTP-Referer": REPO, "X-Title": "Benchwarmers"}},
103
+ "docs": "https://openrouter.ai/docs/use-cases/oauth-pkce",
104
+ },
105
+ "nanogpt": {
106
+ "label": "NanoGPT", "flow": "key", "base": "https://api.nano-gpt.com/api/v1",
107
+ "authorize": "https://nano-gpt.com/auth",
108
+ "token": "https://nano-gpt.com/api/v1/auth/keys",
109
+ "scope": "api.use models.read", "params": {"client_name": "Benchwarmers"},
110
+ "entry": {"kind": "aggregator"},
111
+ "docs": "https://docs.nano-gpt.com/api-reference/miscellaneous/oauth-pkce",
112
+ },
113
+ "huggingface": {
114
+ "label": "Hugging Face", "flow": "oauth", "base": "https://router.huggingface.co/v1",
115
+ "authorize": "https://huggingface.co/oauth/authorize",
116
+ "token": "https://huggingface.co/oauth/token",
117
+ "device": "https://huggingface.co/oauth/device",
118
+ "client_id": "", "scope": "openid inference-api",
119
+ "register": "https://huggingface.co/settings/applications/new (no secret; redirect"
120
+ " http://127.0.0.1/connect/callback; scopes openid, inference-api)",
121
+ "entry": {"kind": "aggregator"}, # its router fronts many inference providers
122
+ "docs": "https://huggingface.co/docs/hub/oauth",
123
+ },
124
+ "pollinations": {
125
+ "label": "Pollinations", "flow": "oauth", "base": "https://gen.pollinations.ai/v1",
126
+ "authorize": "https://enter.pollinations.ai/authorize",
127
+ "token": "https://enter.pollinations.ai/api/oauth/token",
128
+ "device": "https://enter.pollinations.ai/api/device/code", "device_json": True,
129
+ "client_id": "", "params": {"models": "text", "expiry": "30"}, "loopback": "localhost",
130
+ "register": "https://enter.pollinations.ai/keys (an app key, pk_...; redirect"
131
+ " http://localhost/connect/callback)",
132
+ "entry": {"kind": "aggregator"},
133
+ "docs": "https://github.com/pollinations/pollinations/blob/main/BRING_YOUR_OWN_POLLEN.md",
134
+ },
135
+ "chatgpt": {
136
+ "label": "ChatGPT plan", "flow": "oauth", "base": "https://api.openai.com/v1",
137
+ "authorize": "https://auth.openai.com/api/accounts/authorize",
138
+ "token": "https://auth.openai.com/api/accounts/oauth/token",
139
+ # Registered during the first sign-in; the issued id comes back on the
140
+ # callback and is used from then on.
141
+ "client_id": "dynamic_agent_client",
142
+ "scope": "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct",
143
+ "params": {"agent_name_hint": "Benchwarmers", "ext_agent_host_id": "{host_id}",
144
+ "nonce": "{nonce}", "resource": "https://api.openai.com/v1"},
145
+ "token_params": {"resource": "https://api.openai.com/v1"},
146
+ "entry": {"kind": "provider", "api": "responses"},
147
+ "docs": "https://developers.openai.com/siwc/token-sharing-open-source",
148
+ },
149
+ "github": {
150
+ "label": "GitHub Models", "flow": "device", "base": "https://models.github.ai/inference",
151
+ "device": "https://github.com/login/device/code",
152
+ "token": "https://github.com/login/oauth/access_token",
153
+ "client_id": "",
154
+ "register": "a GitHub App: Developer settings > GitHub Apps > New; Enable Device Flow;"
155
+ " Account permissions > Models: Read",
156
+ "entry": {"kind": "provider", "models_url": "https://models.github.ai/catalog/models"},
157
+ "docs": "https://docs.github.com/en/github-models",
158
+ },
159
+ "ollama": {
160
+ "label": "Ollama (this machine)", "flow": "local", "base": "http://localhost:11434/v1",
161
+ "hint": "is Ollama running? For its cloud models, run `ollama signin` once",
162
+ "entry": {"kind": "local"},
163
+ "docs": "https://docs.ollama.com/openai",
164
+ },
165
+ }
166
+
167
+
168
+ class ConnectError(Exception):
169
+ """A sign-in that cannot go on; the text is for the person signing in."""
170
+
171
+
172
+ _pending: dict[str, dict] = {} # id -> one sign-in in progress
173
+ _lock = threading.Lock()
174
+
175
+
176
+ def _config() -> dict:
177
+ return locked.read_json(CONFIG)
178
+
179
+
180
+ def host_id() -> str:
181
+ """This install's stable id, made once (ChatGPT binds a sign-in to it). Not a secret."""
182
+ def change(data: dict) -> str:
183
+ return data.setdefault("host_id", f"urn:uuid:{uuid.uuid4()}")
184
+ return locked.update(CONFIG, change)
185
+
186
+
187
+ def client_id(provider: str) -> str:
188
+ """The install's own (config/connect.json "client_ids"), else the shipped one."""
189
+ return own_ids().get(provider) or PROVIDERS[provider].get("client_id", "")
190
+
191
+
192
+ def own_ids() -> dict:
193
+ """This install's own client ids, by provider: an app it registered itself
194
+ (Use your own app), or the one ChatGPT issued it at the first sign-in."""
195
+ ids = _config().get("client_ids")
196
+ return ids if isinstance(ids, dict) else {}
197
+
198
+
199
+ CLIENT_ID = re.compile(r"^[A-Za-z0-9._~:-]{3,200}$")
200
+
201
+
202
+ def set_own_id(provider: str, cid: str) -> None:
203
+ """Use your own app registration for `provider`, or "" to go back to the
204
+ shipped one. Also the way out if a shipped app is ever withdrawn: every
205
+ install can carry on with its own. Endpoints already signed in keep
206
+ renewing with the id they were signed in with."""
207
+ p = PROVIDERS.get(provider)
208
+ if not p or not p.get("register"):
209
+ raise ConnectError(f"{provider!r} has no app to register")
210
+ cid = (cid or "").strip()
211
+ if cid and not CLIENT_ID.match(cid):
212
+ raise ConnectError("a client id is 3-200 letters, digits or . _ ~ : - (a public id, never a secret)")
213
+
214
+ def change(data: dict) -> None:
215
+ ids = data.setdefault("client_ids", {})
216
+ if cid:
217
+ ids[provider] = cid
218
+ else:
219
+ ids.pop(provider, None)
220
+ locked.update(CONFIG, change)
221
+
222
+
223
+ def ready(provider: str) -> str:
224
+ """"" when this provider can be used here, else what is missing."""
225
+ p = PROVIDERS[provider]
226
+ if p["flow"] in ("oauth", "device") and not client_id(provider):
227
+ return f"needs an app registration: under Use your own app, register {p.get('register', 'one')}"
228
+ return ""
229
+
230
+
231
+ def _post(url: str, fields: dict, as_json: bool = False) -> dict:
232
+ """A token request. The reply as a dict, an OAuth error included."""
233
+ import client # its no-redirect opener: a 3xx never gets the code
234
+ body = json.dumps(fields).encode() if as_json else urllib.parse.urlencode(fields).encode()
235
+ kind = "application/json" if as_json else "application/x-www-form-urlencoded"
236
+ request = urllib.request.Request(url, body, {**UA, "Content-Type": kind})
237
+ try:
238
+ with client._OPENER.open(request, timeout=30) as r:
239
+ return json.loads(r.read() or b"{}")
240
+ except urllib.error.HTTPError as e:
241
+ try:
242
+ data = json.loads(e.read() or b"{}")
243
+ except ValueError:
244
+ data = {}
245
+ return data if isinstance(data, dict) and data.get("error") else \
246
+ {"error": f"HTTP {e.code}", "error_description": str(data)[:200]}
247
+ except (urllib.error.URLError, OSError, ValueError) as e:
248
+ return {"error": "unreachable", "error_description": str(getattr(e, "reason", e))}
249
+
250
+
251
+ def _failed(reply: dict, what: str) -> ConnectError:
252
+ why = reply.get("error_description") or reply.get("message") or reply.get("error")
253
+ return ConnectError(f"{what}: {why}")
254
+
255
+
256
+ def _tokens(record: dict, reply: dict, cid: str) -> dict:
257
+ """What a successful token reply leaves for serve.py to save: for the
258
+ endpoint the sign-in was started for (a provider can be several)."""
259
+ secret = reply.get("key") or reply.get("access_token")
260
+ if not secret:
261
+ raise _failed(reply, "the provider sent no key")
262
+ expires = None
263
+ try:
264
+ if reply.get("expires_in"):
265
+ expires = (dt.datetime.now(dt.timezone.utc)
266
+ + dt.timedelta(seconds=int(reply["expires_in"]))).isoformat(timespec="seconds")
267
+ except (TypeError, ValueError):
268
+ pass # an odd expiry: treated as none, and a 401 says when
269
+ return {"provider": record["provider"], "endpoint": record.get("endpoint") or record["provider"],
270
+ "secret": secret, "refresh": reply.get("refresh_token", ""), "expires": expires, "client_id": cid}
271
+
272
+
273
+ def _sweep() -> None:
274
+ now = time.time()
275
+ for k in [k for k, v in _pending.items() if now - v["at"] > PENDING_S]:
276
+ del _pending[k]
277
+
278
+
279
+ def start(provider: str, port: int, endpoint: str = "", by_code: bool = False) -> dict:
280
+ """Begin a sign-in for `endpoint` (default: the provider's id). {"url"} to
281
+ open (key, oauth), or {"code", "verify"} to show (device, or `by_code`);
282
+ either way an "id" to poll."""
283
+ p = PROVIDERS.get(provider)
284
+ if not p or p["flow"] == "local":
285
+ raise ConnectError(f"{provider!r} has no sign-in")
286
+ if (missing := ready(provider)):
287
+ raise ConnectError(missing)
288
+ if by_code and not p.get("device"):
289
+ raise ConnectError(f"{p['label']} signs in only through a page on the machine Benchwarmers runs on;"
290
+ " from here, add it with an API key instead")
291
+ cid = client_id(provider)
292
+ sid = secrets.token_urlsafe(24)
293
+ record = {"provider": provider, "endpoint": endpoint or provider, "client_id": cid,
294
+ "at": time.time(), "result": None}
295
+ if p["flow"] == "device" or by_code:
296
+ fields = {"client_id": cid, **({"scope": p["scope"]} if p.get("scope") else {})}
297
+ reply = _post(p["device"], fields, as_json=bool(p.get("device_json")))
298
+ if not reply.get("device_code"):
299
+ raise _failed(reply, "the provider would not start a sign-in")
300
+ interval = int(reply.get("interval", 5))
301
+ record.update(device_code=reply["device_code"], interval=interval, next=time.time() + interval)
302
+ out = {"id": sid, "code": reply.get("user_code", ""),
303
+ "verify": reply.get("verification_uri_complete") or reply.get("verification_uri", "")}
304
+ else:
305
+ verifier = secrets.token_urlsafe(64)
306
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
307
+ redirect = f"http://{p.get('loopback', '127.0.0.1')}:{port}{CALLBACK}"
308
+ if p["flow"] == "key":
309
+ # No registered redirect to match, and OpenRouter's docs never say it
310
+ # sends `state` back: the sign-in's id rides in the path instead.
311
+ redirect += "/" + sid
312
+ extra = {k: v.format(host_id=host_id() if "{host_id}" in v else "",
313
+ nonce=secrets.token_urlsafe(16)) for k, v in p.get("params", {}).items()}
314
+ if p["flow"] == "key":
315
+ query = {"callback_url": redirect}
316
+ else:
317
+ query = {"response_type": "code", "client_id": cid, "redirect_uri": redirect}
318
+ query.update({"state": sid, "code_challenge": challenge, "code_challenge_method": "S256",
319
+ **({"scope": p["scope"]} if p.get("scope") else {}), **extra})
320
+ record.update(verifier=verifier, redirect=redirect)
321
+ out = {"id": sid, "url": p["authorize"] + "?" + urllib.parse.urlencode(query)}
322
+ with _lock:
323
+ _sweep()
324
+ _pending[sid] = record
325
+ return out
326
+
327
+
328
+ def callback_id(path: str, query: dict) -> str:
329
+ """The sign-in a callback belongs to: `state`, or the id in its path (key flow)."""
330
+ tail = path.split("?")[0][len(CALLBACK):].strip("/")
331
+ return query.get("state") or tail
332
+
333
+
334
+ def finish(query: dict) -> dict:
335
+ """The callback: check the state, exchange the code, return _tokens' dict.
336
+ The caller saves it and hands poll() the outcome with settle(), so the
337
+ dashboard tab that started the sign-in can tell, and no secret waits here."""
338
+ sid = query.get("state", "")
339
+ with _lock: # checked and marked at once: a doubled callback
340
+ _sweep() # (a prefetch, a retry) exchanges the code once
341
+ record = _pending.get(sid)
342
+ if not record or record.get("used") or "verifier" not in record:
343
+ raise ConnectError("this sign-in is unknown or has expired; start it again from Benchwarmers")
344
+ record["used"] = True
345
+ p = PROVIDERS[record["provider"]]
346
+ try:
347
+ if query.get("error"):
348
+ raise ConnectError(f"{p['label']} said: {query.get('error_description') or query['error']}")
349
+ if not query.get("code"):
350
+ raise ConnectError("the provider sent no code")
351
+ if p["flow"] == "key":
352
+ reply = _post(p["token"], {"code": query["code"], "code_verifier": record["verifier"],
353
+ "code_challenge_method": "S256"}, as_json=True)
354
+ cid = ""
355
+ else:
356
+ # ChatGPT's first sign-in issues this install its own client id,
357
+ # kept at once (not with the endpoint, which a failed save loses)
358
+ # because the next sign-in must use it.
359
+ cid = query.get("client_id") or record["client_id"]
360
+ if cid != record["client_id"]:
361
+ locked.update(CONFIG, lambda data: data.setdefault("client_ids", {}).update(
362
+ {record["provider"]: cid}))
363
+ reply = _post(p["token"], {"grant_type": "authorization_code", "code": query["code"],
364
+ "client_id": cid, "redirect_uri": record["redirect"],
365
+ "code_verifier": record["verifier"], **p.get("token_params", {})})
366
+ return _tokens(record, reply, cid)
367
+ except ConnectError as exc:
368
+ record["result"] = {"error": str(exc)}
369
+ raise
370
+
371
+
372
+ def settle(sid: str, outcome: dict) -> None:
373
+ """What became of a finished sign-in, for poll() to report."""
374
+ with _lock:
375
+ if sid in _pending:
376
+ _pending[sid]["result"] = outcome
377
+
378
+
379
+ def poll(sid: str) -> dict:
380
+ """{"state": "pending"}, {"state": "error", "error"}, or {"state": "done", ...}
381
+ once: a device sign-in's tokens (the caller saves them), or what settle()
382
+ recorded for a redirect sign-in."""
383
+ with _lock:
384
+ _sweep()
385
+ record = _pending.get(sid)
386
+ # One token request at a time: polls overlap when one is slow, and a
387
+ # second request for a used device code would overwrite the tokens.
388
+ due = bool(record) and "device_code" in record and record["result"] is None \
389
+ and not record.get("asking") and time.time() >= record["next"]
390
+ if due:
391
+ record["asking"] = True
392
+ if not record:
393
+ return {"state": "error", "error": "this sign-in is unknown or has expired; start it again"}
394
+ if due:
395
+ try:
396
+ p = PROVIDERS[record["provider"]]
397
+ reply = _post(p["token"], {"client_id": record["client_id"], "device_code": record["device_code"],
398
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code"})
399
+ error = reply.get("error")
400
+ if error == "slow_down":
401
+ record["interval"] += 5
402
+ if error in ("authorization_pending", "slow_down"):
403
+ record["next"] = time.time() + record["interval"]
404
+ elif error:
405
+ record["result"] = {"error": str(_failed(reply, "the sign-in did not finish"))}
406
+ else:
407
+ try:
408
+ record["result"] = _tokens(record, reply, record["client_id"])
409
+ except ConnectError as exc:
410
+ record["result"] = {"error": str(exc)}
411
+ finally:
412
+ record["asking"] = False
413
+ result = record["result"]
414
+ if result is None:
415
+ return {"state": "pending"}
416
+ with _lock:
417
+ _pending.pop(sid, None) # handed over once, then forgotten
418
+ return {"state": "error", **result} if "error" in result else {"state": "done", **result}
419
+
420
+
421
+ def entry_field(tokens: dict) -> dict:
422
+ """The endpoint's `connect` field for a fresh sign-in: no secrets."""
423
+ return {"provider": tokens["provider"], "at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
424
+ **({"expires": tokens["expires"]} if tokens.get("expires") else {}),
425
+ **({"client_id": tokens["client_id"]} if tokens.get("client_id") else {}),
426
+ **({"refreshes": True} if tokens.get("refresh") else {})}
427
+
428
+
429
+ def fresh(key_name: str) -> str:
430
+ """Renew the token behind `key_name` if it is about to expire. "" when it is
431
+ good (or needs nothing), else why it cannot be used: the caller fails the
432
+ call with that, and suite/run.py classes it `signin`, never a retirement."""
433
+ name, entry = next(((n, e) for n, e in endpoints.load().items() if e.get("key") == key_name),
434
+ (None, {}))
435
+ conn = entry.get("connect") or {}
436
+ if not (conn.get("refreshes") and conn.get("expires")):
437
+ return ""
438
+ if _seconds_left(conn) > EARLY_S:
439
+ return ""
440
+ # One renewal at a time, across the gateway's threads and other processes:
441
+ # a refresh token is single-use, so two racing renewals leave one broken.
442
+ with renewal_lock(key_name):
443
+ conn = (endpoints.load().get(name) or {}).get("connect") or {}
444
+ if _seconds_left(conn) > EARLY_S:
445
+ return "" # another caller renewed it meanwhile
446
+ p = PROVIDERS.get(conn.get("provider", ""), {})
447
+ token = endpoints.keys().get(f"{key_name}.refresh", "")
448
+ if not (p and token):
449
+ return f"the sign-in for {name} has expired; sign in again on the Endpoints page"
450
+ reply = _post(p["token"], {"grant_type": "refresh_token", "refresh_token": token,
451
+ "client_id": conn.get("client_id") or client_id(conn["provider"]),
452
+ **p.get("token_params", {})})
453
+ try:
454
+ tokens = _tokens({"provider": conn["provider"], "endpoint": name}, reply, conn.get("client_id", ""))
455
+ except ConnectError as exc:
456
+ return f"the sign-in for {name} could not be renewed ({exc}); sign in again on the Endpoints page"
457
+ # The refresh token first: it is spent now, and an access token without
458
+ # its replacement could never be renewed. A crash after it only costs
459
+ # one more renewal.
460
+ if tokens["refresh"]:
461
+ endpoints.set_key(f"{key_name}.refresh", tokens["refresh"])
462
+ endpoints.set_key(key_name, tokens["secret"])
463
+ stamp = dt.datetime.now().astimezone().isoformat(timespec="seconds")
464
+ locked.update(endpoints.FILE, lambda data: (data.get(name) or {}).get("connect", {}).update(
465
+ expires=tokens["expires"], renewed=stamp)) # removed meanwhile: nothing to update
466
+ return ""
467
+
468
+
469
+ def renewal_lock(key_name: str):
470
+ """Held to renew a token and to save a new sign-in, so one never overwrites the other."""
471
+ return locked.lock(endpoints.FILE.with_name(f"connect-{key_name}"))
472
+
473
+
474
+ def _seconds_left(conn: dict) -> float:
475
+ try:
476
+ return (dt.datetime.fromisoformat(conn["expires"]) - dt.datetime.now(dt.timezone.utc)).total_seconds()
477
+ except (KeyError, TypeError, ValueError):
478
+ return 0.0
479
+
480
+
481
+ def standing(conn: dict | None) -> dict | None:
482
+ """An endpoint's `connect` record as the dashboard shows it: who signed in,
483
+ until when, whether that has passed. No secrets."""
484
+ if not conn:
485
+ return None
486
+ gone = bool(conn.get("expires")) and not conn.get("refreshes") and _seconds_left(conn) <= 0
487
+ p = PROVIDERS.get(conn.get("provider", ""), {})
488
+ return {**conn, "label": p.get("label", ""), "flow": p.get("flow", ""), "expired": gone}
489
+
490
+
491
+ def check() -> list[str]:
492
+ """Every PROVIDERS entry has what its flow needs."""
493
+ problems = []
494
+ needs = {"key": ("authorize", "token"), "oauth": ("authorize", "token"),
495
+ "device": ("device", "token"), "local": ()}
496
+ for pid, p in PROVIDERS.items():
497
+ if p.get("flow") not in FLOWS:
498
+ problems.append(f"{pid}: flow must be one of {FLOWS}")
499
+ continue
500
+ if not endpoints.NAME.match(pid):
501
+ problems.append(f"{pid}: the id names the endpoint, so it must match endpoints.NAME")
502
+ for field in ("label", "base", "docs", *needs[p["flow"]]):
503
+ if not p.get(field):
504
+ problems.append(f"{pid}: {field} is missing")
505
+ for field in ("base", "authorize", "token", "device", "docs"):
506
+ url = p.get(field, "")
507
+ if url and not url.startswith("https://") and not (pid == "ollama" and url.startswith("http://localhost")):
508
+ problems.append(f"{pid}: {field} must be https")
509
+ if p["flow"] in ("oauth", "device") and not p.get("client_id") and not p.get("register"):
510
+ problems.append(f"{pid}: with no shipped client_id, say where to register one")
511
+ if p.get("loopback", "127.0.0.1") not in ("127.0.0.1", "localhost"):
512
+ problems.append(f"{pid}: loopback is 127.0.0.1 or localhost")
513
+ for k in ("params", "token_params", "entry"):
514
+ if not isinstance(p.get(k, {}), dict):
515
+ problems.append(f"{pid}: {k} must be an object")
516
+ if (p.get("entry") or {}).get("kind") not in set(endpoints.KINDS) - {"hub"}:
517
+ problems.append(f"{pid}: entry.kind is provider, aggregator or local (a hub is added by hand)")
518
+ return problems
519
+
520
+
521
+ def main() -> None:
522
+ for pid, p in PROVIDERS.items():
523
+ print(f" {pid:<13} {p['flow']:<7} {ready(pid) or 'ready'}")
524
+
525
+
526
+ def _selftest() -> None:
527
+ """Shapes, then every flow against a fake provider: a PKCE check that really
528
+ hashes, single-use state, device polling, and renewal under the lock."""
529
+ import tempfile
530
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
531
+
532
+ assert not (problems := check()), problems
533
+ seen: dict = {"challenges": {}, "polls": 0}
534
+
535
+ class Fake(BaseHTTPRequestHandler):
536
+ def do_POST(self): # noqa: N802
537
+ raw = self.rfile.read(int(self.headers.get("Content-Length") or 0)).decode()
538
+ f = json.loads(raw) if self.headers.get("Content-Type", "").startswith("application/json") \
539
+ else dict(urllib.parse.parse_qsl(raw))
540
+ reply, status = {"error": "invalid_request"}, 400
541
+ if self.path == "/device":
542
+ reply, status = {"device_code": "dc", "user_code": "ABCD-1234",
543
+ "verification_uri": "https://fake/device", "interval": 0}, 200
544
+ elif f.get("grant_type", "").endswith("device_code"):
545
+ seen["polls"] += 1
546
+ reply, status = ({"error": "authorization_pending"}, 400) if seen["polls"] == 1 else \
547
+ ({"access_token": "ghu_x", "refresh_token": "ghr_x", "expires_in": 28800}, 200)
548
+ elif f.get("grant_type") == "refresh_token":
549
+ reply, status = ({"access_token": "fresh", "refresh_token": "r2", "expires_in": 3600}, 200) \
550
+ if f.get("refresh_token") == "r1" else ({"error": "invalid_grant"}, 400)
551
+ elif "code" in f:
552
+ want = seen["challenges"].get(f["code"])
553
+ got = base64.urlsafe_b64encode(hashlib.sha256(f["code_verifier"].encode()).digest()).rstrip(b"=").decode()
554
+ if want and want == got:
555
+ reply, status = ({"key": "sk-key"} if self.path == "/keys" else
556
+ {"access_token": "at", "refresh_token": "r1", "expires_in": 3600}), 200
557
+ body = json.dumps(reply).encode()
558
+ self.send_response(status)
559
+ self.send_header("Content-Type", "application/json")
560
+ self.send_header("Content-Length", str(len(body)))
561
+ self.end_headers()
562
+ self.wfile.write(body)
563
+
564
+ def log_message(self, *_a):
565
+ pass
566
+
567
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Fake)
568
+ threading.Thread(target=server.serve_forever, daemon=True).start()
569
+ fake = f"http://127.0.0.1:{server.server_port}"
570
+ global CONFIG
571
+ saved = (dict(PROVIDERS), CONFIG, endpoints.FILE, endpoints.LLM_DIR)
572
+ with tempfile.TemporaryDirectory() as tmp:
573
+ try:
574
+ CONFIG, endpoints.FILE, endpoints.LLM_DIR = (pathlib.Path(tmp) / "connect.json",
575
+ pathlib.Path(tmp) / "endpoints.json", pathlib.Path(tmp))
576
+ PROVIDERS.update(
577
+ k={**PROVIDERS["openrouter"], "authorize": fake + "/auth", "token": fake + "/keys"},
578
+ o={**PROVIDERS["chatgpt"], "authorize": fake + "/auth", "token": fake + "/token"},
579
+ d={**PROVIDERS["github"], "client_id": "Iv1.x", "device": fake + "/device", "token": fake + "/token"},
580
+ h={**PROVIDERS["huggingface"]})
581
+
582
+ def soon_past() -> str:
583
+ return (dt.datetime.now(dt.timezone.utc) - dt.timedelta(seconds=5)).isoformat()
584
+
585
+ def sign_in(pid: str, port: int = 8122) -> tuple[dict, dict]:
586
+ url = start(pid, port)["url"]
587
+ q = dict(urllib.parse.parse_qsl(urllib.parse.urlsplit(url).query))
588
+ seen["challenges"]["c-" + pid] = q["code_challenge"]
589
+ return q, finish({"state": q["state"], "code": "c-" + pid, "client_id": "issued"})
590
+
591
+ q, got = sign_in("k")
592
+ assert q["callback_url"] == "http://localhost:8122/connect/callback/" + q["state"] and q["key_label"], q
593
+ assert callback_id(f"/connect/callback/{q['state']}?code=x", {"code": "x"}) == q["state"]
594
+ assert callback_id("/connect/callback?state=s&code=x", {"state": "s"}) == "s"
595
+ assert got["secret"] == "sk-key" and not got["refresh"] and got["expires"] is None, got
596
+ assert got["endpoint"] == "k" # by default, the provider's id
597
+ assert poll(q["state"]) == {"state": "pending"} # until it is saved
598
+ settle(q["state"], {"ok": True, "served": 3})
599
+ assert poll(q["state"]) == {"state": "done", "ok": True, "served": 3}
600
+ assert poll(q["state"])["state"] == "error" # handed over once
601
+ try:
602
+ finish({"state": q["state"], "code": "c-k"})
603
+ raise AssertionError("a used state was accepted twice")
604
+ except ConnectError:
605
+ pass
606
+
607
+ q, got = sign_in("o", port=9000)
608
+ assert q["redirect_uri"] == "http://127.0.0.1:9000/connect/callback", q
609
+ assert q["ext_agent_host_id"] == host_id() and q["nonce"] and "{" not in q["nonce"], q
610
+ assert q["resource"] == "https://api.openai.com/v1", q
611
+ assert q["client_id"] == "dynamic_agent_client" and got["client_id"] == "issued", (q, got)
612
+ assert client_id("o") == "issued" # kept for the next sign-in at once
613
+ assert got["secret"] == "at" and got["refresh"] == "r1" and got["expires"], got
614
+
615
+ # A wrong verifier is refused by the provider, and says so.
616
+ url = start("o", 9000)["url"]
617
+ state = dict(urllib.parse.parse_qsl(urllib.parse.urlsplit(url).query))["state"]
618
+ seen["challenges"]["c-bad"] = "not-the-challenge"
619
+ try:
620
+ finish({"state": state, "code": "c-bad"})
621
+ raise AssertionError("an exchange the provider refused looked fine")
622
+ except ConnectError as exc:
623
+ assert "no key" in str(exc), exc
624
+ assert standing({"provider": "o", "expires": soon_past()})["expired"]
625
+ assert not standing({"provider": "o", "expires": soon_past(), "refreshes": True})["expired"]
626
+ assert poll(state)["state"] == "error"
627
+ try:
628
+ finish({"state": state, "error": "access_denied"})
629
+ raise AssertionError("a finished sign-in was reused")
630
+ except ConnectError:
631
+ pass
632
+
633
+ # Signing in by code where there is a device URL (JSON for Pollinations); refused where there is none.
634
+ PROVIDERS["c"] = {**PROVIDERS["pollinations"], "client_id": "pk_x", "device": fake + "/device",
635
+ "token": fake + "/token"}
636
+ c = start("c", 8122, endpoint="c", by_code=True)
637
+ assert c["code"] == "ABCD-1234" and poll(c["id"]) == {"state": "pending"}, c
638
+ assert poll(c["id"])["state"] == "done"
639
+ try:
640
+ start("k", 8122, by_code=True)
641
+ raise AssertionError("a provider with no device URL started a code sign-in")
642
+ except ConnectError as exc:
643
+ assert "API key" in str(exc)
644
+ seen["polls"] = 0
645
+
646
+ d = start("d", 8122, endpoint="d-2")
647
+ assert d["code"] == "ABCD-1234" and poll(d["id"]) == {"state": "pending"}
648
+ done = poll(d["id"])
649
+ assert done["state"] == "done" and done["secret"] == "ghu_x" and done["refresh"] == "ghr_x", done
650
+ assert done["endpoint"] == "d-2" # the one it was started for
651
+
652
+ try:
653
+ start("h", 8122)
654
+ raise AssertionError("started a sign-in with no client id")
655
+ except ConnectError as exc:
656
+ assert "app registration" in str(exc)
657
+ # Use your own app: set, used, cleared; refused for a provider with nothing to register.
658
+ set_own_id("h", "hf-own")
659
+ assert "client_id=hf-own" in start("h", 8122)["url"] and own_ids()["h"] == "hf-own"
660
+ set_own_id("h", "")
661
+ assert "h" not in own_ids() and ready("h")
662
+ for bad in (("k", "x-id"), ("h", "has space"), ("h", "a" * 300)):
663
+ try:
664
+ set_own_id(*bad)
665
+ raise AssertionError(f"accepted {bad}")
666
+ except ConnectError:
667
+ pass
668
+ set_own_id("h", "hf-own")
669
+
670
+ # Renewal: due, renewed once, written where a key goes; then a dead refresh token.
671
+ soon = (dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=60)).isoformat()
672
+ endpoints.FILE.write_text(json.dumps({"o": {"base": "https://x/v1", "key": "o", "connect": {
673
+ "provider": "o", "expires": soon, "client_id": "issued", "refreshes": True}}}), encoding="utf-8")
674
+ endpoints.set_key("o", "at")
675
+ endpoints.set_key("o.refresh", "r1")
676
+ assert fresh("o") == "" and endpoints.keys()["o"] == "fresh" and endpoints.keys()["o.refresh"] == "r2"
677
+ assert _seconds_left(endpoints.load()["o"]["connect"]) > EARLY_S
678
+ assert fresh("o") == "" # not due: no request
679
+ locked.update(endpoints.FILE, lambda data: data["o"]["connect"].update(expires=soon))
680
+ assert "sign in again" in fresh("o") # r2 is not one the fake accepts
681
+ assert fresh("nobody") == ""
682
+ finally:
683
+ PROVIDERS.clear()
684
+ PROVIDERS.update(saved[0])
685
+ CONFIG, endpoints.FILE, endpoints.LLM_DIR = saved[1:]
686
+ server.shutdown()
687
+ print("ok")
688
+
689
+
690
+ if __name__ == "__main__":
691
+ _selftest() if "--selftest" in sys.argv else main()