@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.
- package/AGENTS.md +152 -147
- package/BENCHMARKS.md +262 -262
- package/GATEWAY.md +415 -272
- package/NOTICE +28 -28
- package/PRIVACY.md +46 -46
- package/README.md +408 -314
- package/SECURITY.md +83 -83
- package/TERMS.md +22 -22
- package/USAGE.md +186 -163
- package/VERSION +1 -1
- package/bin/benchwarmers.js +16 -74
- package/cli.py +674 -0
- package/examples/config/gateway.json +5 -5
- package/examples/config/model-roles.json +11 -11
- package/gateway.py +939 -755
- package/launcher.py +120 -0
- package/mcp_server.py +266 -266
- package/metrics/alerts.py +116 -116
- package/metrics/aliases.py +41 -41
- package/metrics/bench_collections.py +141 -141
- package/metrics/catalog.py +304 -295
- package/metrics/client.py +804 -394
- package/metrics/cliproxy.py +191 -191
- package/metrics/connect.py +691 -691
- package/metrics/cost.py +171 -152
- package/metrics/decide.py +227 -227
- package/metrics/discover.py +318 -318
- package/metrics/endpoints.py +436 -436
- package/metrics/formats.py +1021 -0
- package/metrics/guard.py +647 -592
- package/metrics/learn.py +234 -221
- package/metrics/ledger.py +148 -0
- package/metrics/litellm_hub.py +206 -206
- package/metrics/model_roles.py +94 -84
- package/metrics/offload.py +166 -166
- package/metrics/owui.py +205 -205
- package/metrics/pacing.py +248 -0
- package/metrics/paths.py +60 -60
- package/metrics/quota.py +383 -0
- package/metrics/route.py +581 -579
- package/metrics/scheduler.py +275 -275
- package/metrics/sources.py +167 -167
- package/metrics/wrong.py +144 -144
- package/package.json +69 -65
- package/perf/README.md +89 -89
- package/perf/run.py +284 -261
- package/scripts/auto_retire.py +367 -367
- package/scripts/check.py +113 -106
- package/scripts/check_deprecations.py +378 -378
- package/scripts/check_themes.py +103 -103
- package/scripts/init.py +124 -124
- package/scripts/list_served.py +78 -78
- package/scripts/notice.py +78 -78
- package/scripts/onboard.py +410 -331
- package/scripts/provider_tags.py +129 -129
- package/scripts/reprefix.py +136 -136
- package/scripts/sync_config.py +105 -105
- package/scripts/test_auth.py +255 -255
- package/scripts/test_cli.py +189 -0
- package/scripts/test_client.py +277 -233
- package/scripts/test_connect.py +222 -222
- package/scripts/test_first_run.py +180 -180
- package/scripts/test_formats.py +549 -0
- package/scripts/test_hubs.py +328 -328
- package/scripts/test_launcher.py +67 -0
- package/scripts/test_llm_log.py +85 -81
- package/scripts/test_mcp.py +178 -178
- package/scripts/test_merged.py +215 -214
- package/scripts/test_no_owui.py +291 -291
- package/scripts/test_owui.py +90 -90
- package/scripts/test_quota.py +334 -0
- package/scripts/test_serve_edit.py +453 -438
- package/scripts/test_server_json.py +79 -0
- package/scripts/test_verdict.py +102 -98
- package/scripts/test_vision.py +231 -228
- package/serve.py +3972 -3764
- package/suite/mine.py +154 -154
- package/suite/pace.py +145 -145
- package/suite/run.py +1046 -1042
- package/suite/seed.py +421 -421
- package/suite/tasks/claim-alias-unbound/fixtures/onboard.py +286 -286
- package/suite/tasks/claim-error-text-string/fixtures/decide.py +187 -187
- package/suite/tasks/claim-list-body/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-member-nominated-twice/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-nominee-uses-slot/fixtures/gateway.py +284 -284
- package/suite/tasks/claim-silent-exit/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-sse-hang/fixtures/gateway.py +235 -235
- package/suite/tasks/claim-unknown-nominee-crash/fixtures/gateway.py +284 -284
- package/suite/tasks/rebench-matchms-809-bug/task.json +23 -23
- package/suite/tasks/rebench-matchms-809-control/task.json +27 -27
- package/suite/tasks/rebench-more-itertools-1028-bug/task.json +23 -23
- package/suite/tasks/rebench-more-itertools-1028-control/task.json +27 -27
- package/suite/tasks/rebench-narwhals-2864-bug/task.json +23 -23
- package/suite/tasks/rebench-narwhals-2864-control/task.json +27 -27
- package/suite/tasks/rebench-quantecon-py-769-bug/task.json +23 -23
- package/suite/tasks/rebench-quantecon-py-769-control/task.json +27 -27
- package/suite/tasks/rebench-scrapy-6867-bug/task.json +23 -23
- package/suite/tasks/rebench-scrapy-6867-control/task.json +27 -27
- package/ui/bench.css +430 -425
- package/ui/bench.js +1757 -1576
- package/ui/themes/dark.css +35 -35
- package/ui/themes/hardwood.css +52 -52
- package/ui/themes/rainforest.css +32 -32
package/metrics/connect.py
CHANGED
|
@@ -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()
|