useoutlet 0.1.0__py3-none-any.whl
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.
- useoutlet/__init__.py +79 -0
- useoutlet/_version.py +1 -0
- useoutlet/client.py +304 -0
- useoutlet/direct.py +164 -0
- useoutlet/ended.py +59 -0
- useoutlet/grants.py +107 -0
- useoutlet/http.py +96 -0
- useoutlet/pkce.py +117 -0
- useoutlet/providers.json +548 -0
- useoutlet/providers.py +130 -0
- useoutlet/py.typed +0 -0
- useoutlet/types.py +162 -0
- useoutlet-0.1.0.dist-info/METADATA +238 -0
- useoutlet-0.1.0.dist-info/RECORD +16 -0
- useoutlet-0.1.0.dist-info/WHEEL +4 -0
- useoutlet-0.1.0.dist-info/licenses/LICENSE +21 -0
useoutlet/__init__.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""useoutlet: connect your users' AI accounts to your app.
|
|
2
|
+
|
|
3
|
+
Status: direct mode is live today. Vault mode (connect / refresh / status /
|
|
4
|
+
revoke) is open. Register your app at useoutlet.dev/register.
|
|
5
|
+
|
|
6
|
+
from useoutlet import Outlet
|
|
7
|
+
|
|
8
|
+
outlet = Outlet(app_id="app_yourapp", app_secret=os.environ["OUTLET_APP_SECRET"])
|
|
9
|
+
request = outlet.connect(providers=["openai"], requested_cap_usd=10)
|
|
10
|
+
# send the user to request.grant_url, then
|
|
11
|
+
session = outlet.wait(request.id)
|
|
12
|
+
|
|
13
|
+
# use the official provider SDK: Outlet is not in the data path
|
|
14
|
+
ai = OpenAI(api_key=session.keys["openai"])
|
|
15
|
+
|
|
16
|
+
A connection that ends (capped, revoked or expired) is a ConnectionEndedError
|
|
17
|
+
from status(), refresh() and wait(). The Connect your AI button, the CLI and
|
|
18
|
+
the MCP docs server live in the npm package, @useoutlet/sdk.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from ._version import __version__
|
|
22
|
+
from .client import AsyncOutlet, Outlet
|
|
23
|
+
from .direct import direct
|
|
24
|
+
from .grants import refresh, revoke, status
|
|
25
|
+
from .pkce import PkceChallenge, create_grant, exchange_code, pkce_challenge
|
|
26
|
+
from .providers import (
|
|
27
|
+
KeyShape,
|
|
28
|
+
ProviderCheck,
|
|
29
|
+
ProviderEntry,
|
|
30
|
+
ProviderKind,
|
|
31
|
+
ProviderMode,
|
|
32
|
+
ProviderModes,
|
|
33
|
+
get_provider,
|
|
34
|
+
provider_ids,
|
|
35
|
+
providers,
|
|
36
|
+
)
|
|
37
|
+
from .types import (
|
|
38
|
+
CapReason,
|
|
39
|
+
ConnectionEndedError,
|
|
40
|
+
EndReason,
|
|
41
|
+
GrantInfo,
|
|
42
|
+
GrantRequest,
|
|
43
|
+
GrantStatus,
|
|
44
|
+
Mode,
|
|
45
|
+
OutletError,
|
|
46
|
+
OutletSession,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"AsyncOutlet",
|
|
51
|
+
"CapReason",
|
|
52
|
+
"ConnectionEndedError",
|
|
53
|
+
"EndReason",
|
|
54
|
+
"GrantInfo",
|
|
55
|
+
"GrantRequest",
|
|
56
|
+
"GrantStatus",
|
|
57
|
+
"KeyShape",
|
|
58
|
+
"Mode",
|
|
59
|
+
"Outlet",
|
|
60
|
+
"OutletError",
|
|
61
|
+
"OutletSession",
|
|
62
|
+
"PkceChallenge",
|
|
63
|
+
"ProviderCheck",
|
|
64
|
+
"ProviderEntry",
|
|
65
|
+
"ProviderKind",
|
|
66
|
+
"ProviderMode",
|
|
67
|
+
"ProviderModes",
|
|
68
|
+
"__version__",
|
|
69
|
+
"create_grant",
|
|
70
|
+
"direct",
|
|
71
|
+
"exchange_code",
|
|
72
|
+
"get_provider",
|
|
73
|
+
"pkce_challenge",
|
|
74
|
+
"provider_ids",
|
|
75
|
+
"providers",
|
|
76
|
+
"refresh",
|
|
77
|
+
"revoke",
|
|
78
|
+
"status",
|
|
79
|
+
]
|
useoutlet/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
useoutlet/client.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""The Outlet client: the grant flow for a server, with the credential held
|
|
2
|
+
once. A confidential client (app secret) starts a connection request, sends
|
|
3
|
+
the user to its grant URL and waits for the approval. A public client (no
|
|
4
|
+
secret) starts it with PKCE and finishes on the return address with
|
|
5
|
+
exchange(). refresh(), status() and revoke() then use the secret or the
|
|
6
|
+
grant's refresh token, whichever the client holds; a rotated token replaces
|
|
7
|
+
the old one in the client."""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from . import grants as _grants
|
|
17
|
+
from . import http as _http
|
|
18
|
+
from .direct import direct
|
|
19
|
+
from .ended import ended
|
|
20
|
+
from .http import DEFAULT_TIMEOUT, Auth, api
|
|
21
|
+
from .pkce import create_grant, exchange_code
|
|
22
|
+
from .types import GrantInfo, GrantRequest, OutletError, OutletSession
|
|
23
|
+
|
|
24
|
+
# A connection request the user never finished is forgotten after this long.
|
|
25
|
+
_PENDING_TTL = 3600.0
|
|
26
|
+
|
|
27
|
+
_WAIT_NEEDS_SECRET = (
|
|
28
|
+
"wait() needs the app secret. A public client finishes the connection with "
|
|
29
|
+
"exchange(code, state) on the return address."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class _Pending:
|
|
35
|
+
grant_request_id: str
|
|
36
|
+
verifier: str
|
|
37
|
+
started: float
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Outlet:
|
|
41
|
+
"""The Outlet client.
|
|
42
|
+
|
|
43
|
+
app_id: your app's registered Outlet id (app_..., public).
|
|
44
|
+
app_secret: your confidential app secret (apps_...), server-side only.
|
|
45
|
+
Leave it out for a public client (PKCE, SPEC section 7.1).
|
|
46
|
+
base_url: the vault to talk to. Defaults to https://api.useoutlet.dev/v0.
|
|
47
|
+
timeout: seconds one vault call may take.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
app_id: str,
|
|
53
|
+
app_secret: str | None = None,
|
|
54
|
+
*,
|
|
55
|
+
base_url: str | None = None,
|
|
56
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
57
|
+
) -> None:
|
|
58
|
+
self.app_id = app_id
|
|
59
|
+
self.app_secret = app_secret or None
|
|
60
|
+
self._base_url = base_url
|
|
61
|
+
self.timeout = timeout
|
|
62
|
+
# grant id -> the refresh token the vault last issued (public clients)
|
|
63
|
+
self._tokens: dict[str, str] = {}
|
|
64
|
+
# state -> the PKCE values of a connection request not yet exchanged
|
|
65
|
+
self._pending: dict[str, _Pending] = {}
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def base_url(self) -> str:
|
|
69
|
+
return self._base_url or _http.DEFAULT_BASE_URL
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def public(self) -> bool:
|
|
73
|
+
"""True for a public client: no app secret, PKCE instead."""
|
|
74
|
+
return self.app_secret is None
|
|
75
|
+
|
|
76
|
+
# The grant flow
|
|
77
|
+
|
|
78
|
+
def connect(
|
|
79
|
+
self,
|
|
80
|
+
providers: Sequence[str],
|
|
81
|
+
requested_cap_usd: float | None = None,
|
|
82
|
+
redirect_uri: str | None = None,
|
|
83
|
+
) -> GrantRequest:
|
|
84
|
+
"""Start a connection request. Send the user to the returned grant_url.
|
|
85
|
+
|
|
86
|
+
providers: the provider the user connects. The vault takes one provider
|
|
87
|
+
per connection request; use a separate request for each provider.
|
|
88
|
+
requested_cap_usd: your proposed monthly Vault cap in USD. The user
|
|
89
|
+
sets the cap on the approval card.
|
|
90
|
+
redirect_uri: the return address (public clients: required, and it
|
|
91
|
+
must be registered for the app).
|
|
92
|
+
|
|
93
|
+
A confidential client then calls wait(); a public client calls
|
|
94
|
+
exchange() with the code and state from the return address.
|
|
95
|
+
"""
|
|
96
|
+
if isinstance(providers, str):
|
|
97
|
+
providers = [providers]
|
|
98
|
+
if self.public:
|
|
99
|
+
started = create_grant(
|
|
100
|
+
self.app_id,
|
|
101
|
+
providers,
|
|
102
|
+
redirect_uri or "",
|
|
103
|
+
requested_cap_usd,
|
|
104
|
+
base_url=self.base_url,
|
|
105
|
+
timeout=self.timeout,
|
|
106
|
+
)
|
|
107
|
+
self._remember(started)
|
|
108
|
+
return started
|
|
109
|
+
r = api(
|
|
110
|
+
self.base_url,
|
|
111
|
+
"/grants",
|
|
112
|
+
method="POST",
|
|
113
|
+
body={
|
|
114
|
+
"app_id": self.app_id,
|
|
115
|
+
"providers": list(providers),
|
|
116
|
+
"requested_cap_usd": requested_cap_usd,
|
|
117
|
+
"redirect_uri": redirect_uri,
|
|
118
|
+
},
|
|
119
|
+
auth=Auth(app_secret=self.app_secret),
|
|
120
|
+
timeout=self.timeout,
|
|
121
|
+
)
|
|
122
|
+
# The vault answers this path in camelCase and the PKCE path in
|
|
123
|
+
# snake_case (SPEC section 7.1). Both spellings are read.
|
|
124
|
+
return GrantRequest(
|
|
125
|
+
id=str(r.get("grantRequestId") or r.get("grant_request_id") or ""),
|
|
126
|
+
grant_url=str(r.get("grantUrl") or r.get("grant_url") or ""),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def wait(
|
|
130
|
+
self,
|
|
131
|
+
grant_request_id: str,
|
|
132
|
+
*,
|
|
133
|
+
interval: float = 1.5,
|
|
134
|
+
timeout: float | None = None,
|
|
135
|
+
) -> OutletSession:
|
|
136
|
+
"""Confidential clients: wait for the user to approve. Polls the grant
|
|
137
|
+
request every `interval` seconds, as the npm package's connect() does,
|
|
138
|
+
and returns the session once the vault delivers the key. A request that
|
|
139
|
+
ends before approval (revoked, capped) raises ConnectionEndedError.
|
|
140
|
+
timeout: seconds to wait in all; None waits until the vault answers."""
|
|
141
|
+
if self.public:
|
|
142
|
+
raise OutletError(_WAIT_NEEDS_SECRET, "app_secret_required")
|
|
143
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
144
|
+
while True:
|
|
145
|
+
r = api(
|
|
146
|
+
self.base_url,
|
|
147
|
+
f"/grants/{grant_request_id}",
|
|
148
|
+
auth=Auth(app_secret=self.app_secret),
|
|
149
|
+
timeout=self.timeout,
|
|
150
|
+
)
|
|
151
|
+
state = r.get("status") if isinstance(r, dict) else None
|
|
152
|
+
if state == "complete":
|
|
153
|
+
return OutletSession.from_wire(r)
|
|
154
|
+
if state in ("capped", "revoked"):
|
|
155
|
+
raise ended(state, str(r.get("grantId") or grant_request_id), status=409)
|
|
156
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
157
|
+
raise OutletError(
|
|
158
|
+
f"The connection request was not approved within {timeout:g} seconds.",
|
|
159
|
+
"approval_timeout",
|
|
160
|
+
)
|
|
161
|
+
time.sleep(interval)
|
|
162
|
+
|
|
163
|
+
def exchange(self, code: str, state: str, request: GrantRequest | None = None) -> OutletSession:
|
|
164
|
+
"""Public clients: finish the connection on the return address. Verifies
|
|
165
|
+
state against the request this client started, exchanges the code and
|
|
166
|
+
verifier over the back channel, and holds the session's refresh token
|
|
167
|
+
for refresh(), status() and revoke(). Pass `request` when your app
|
|
168
|
+
kept the GrantRequest itself (another process, say)."""
|
|
169
|
+
self._forget_stale()
|
|
170
|
+
if request is not None:
|
|
171
|
+
if not request.verifier:
|
|
172
|
+
raise OutletError("No PKCE transaction in progress.", "no_pkce_txn")
|
|
173
|
+
pending: _Pending | None = _Pending(request.id, request.verifier, time.monotonic())
|
|
174
|
+
expected = request.state
|
|
175
|
+
else:
|
|
176
|
+
if not self._pending:
|
|
177
|
+
raise OutletError("No PKCE transaction in progress.", "no_pkce_txn")
|
|
178
|
+
pending = self._pending.get(state) if state else None
|
|
179
|
+
expected = state if pending else None
|
|
180
|
+
if not code:
|
|
181
|
+
raise OutletError("No authorization code in the redirect URL.", "no_code")
|
|
182
|
+
if not state or pending is None or state != expected:
|
|
183
|
+
raise OutletError("State mismatch. Possible CSRF; aborting.", "state_mismatch")
|
|
184
|
+
session = exchange_code(
|
|
185
|
+
pending.grant_request_id,
|
|
186
|
+
code,
|
|
187
|
+
pending.verifier,
|
|
188
|
+
base_url=self.base_url,
|
|
189
|
+
timeout=self.timeout,
|
|
190
|
+
)
|
|
191
|
+
self._pending.pop(state, None)
|
|
192
|
+
if session.refresh_token:
|
|
193
|
+
self._tokens[session.grant_id] = session.refresh_token
|
|
194
|
+
return session
|
|
195
|
+
|
|
196
|
+
# After connect
|
|
197
|
+
|
|
198
|
+
def refresh(self, grant_id: str, refresh_token: str | None = None) -> OutletSession:
|
|
199
|
+
"""Re-fetch (and possibly rotate) the keys for a grant. A public client's
|
|
200
|
+
token rotates: the new one replaces the old in this client, and the
|
|
201
|
+
session carries it for your own store. Pass `refresh_token` to seed a
|
|
202
|
+
token this client has not seen (one you stored earlier). Raises
|
|
203
|
+
ConnectionEndedError when the connection is capped, revoked or expired."""
|
|
204
|
+
token = refresh_token or self._tokens.get(grant_id)
|
|
205
|
+
session = _grants.refresh(
|
|
206
|
+
grant_id,
|
|
207
|
+
app_secret=self.app_secret,
|
|
208
|
+
refresh_token=token,
|
|
209
|
+
base_url=self.base_url,
|
|
210
|
+
timeout=self.timeout,
|
|
211
|
+
)
|
|
212
|
+
if session.refresh_token:
|
|
213
|
+
self._tokens[grant_id] = session.refresh_token
|
|
214
|
+
return session
|
|
215
|
+
|
|
216
|
+
def status(self, grant_id: str, refresh_token: str | None = None) -> GrantInfo:
|
|
217
|
+
"""Current status and spend for a grant, never key material. Raises
|
|
218
|
+
ConnectionEndedError for a capped or revoked connection."""
|
|
219
|
+
return _grants.status(
|
|
220
|
+
grant_id,
|
|
221
|
+
app_secret=self.app_secret,
|
|
222
|
+
refresh_token=refresh_token or self._tokens.get(grant_id),
|
|
223
|
+
base_url=self.base_url,
|
|
224
|
+
timeout=self.timeout,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def revoke(self, grant_id: str, refresh_token: str | None = None) -> None:
|
|
228
|
+
"""Revoke a grant from the app's side. The client forgets its token."""
|
|
229
|
+
_grants.revoke(
|
|
230
|
+
grant_id,
|
|
231
|
+
app_secret=self.app_secret,
|
|
232
|
+
refresh_token=refresh_token or self._tokens.get(grant_id),
|
|
233
|
+
base_url=self.base_url,
|
|
234
|
+
timeout=self.timeout,
|
|
235
|
+
)
|
|
236
|
+
self._tokens.pop(grant_id, None)
|
|
237
|
+
|
|
238
|
+
# Direct mode, for symmetry with the npm package's Outlet.direct()
|
|
239
|
+
direct = staticmethod(direct)
|
|
240
|
+
|
|
241
|
+
# Pending PKCE requests
|
|
242
|
+
|
|
243
|
+
def _remember(self, started: GrantRequest) -> None:
|
|
244
|
+
self._forget_stale()
|
|
245
|
+
if started.state and started.verifier:
|
|
246
|
+
self._pending[started.state] = _Pending(started.id, started.verifier, time.monotonic())
|
|
247
|
+
|
|
248
|
+
def _forget_stale(self) -> None:
|
|
249
|
+
cutoff = time.monotonic() - _PENDING_TTL
|
|
250
|
+
for state in [s for s, p in self._pending.items() if p.started < cutoff]:
|
|
251
|
+
del self._pending[state]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class AsyncOutlet:
|
|
255
|
+
"""The same client for asyncio apps. Each call runs the sync client in a
|
|
256
|
+
worker thread (asyncio.to_thread), so the event loop is never blocked. The
|
|
257
|
+
standard library has no async HTTP client, and this package adds none."""
|
|
258
|
+
|
|
259
|
+
def __init__(
|
|
260
|
+
self,
|
|
261
|
+
app_id: str,
|
|
262
|
+
app_secret: str | None = None,
|
|
263
|
+
*,
|
|
264
|
+
base_url: str | None = None,
|
|
265
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
266
|
+
) -> None:
|
|
267
|
+
self.sync = Outlet(app_id, app_secret, base_url=base_url, timeout=timeout)
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def public(self) -> bool:
|
|
271
|
+
return self.sync.public
|
|
272
|
+
|
|
273
|
+
async def connect(
|
|
274
|
+
self,
|
|
275
|
+
providers: Sequence[str],
|
|
276
|
+
requested_cap_usd: float | None = None,
|
|
277
|
+
redirect_uri: str | None = None,
|
|
278
|
+
) -> GrantRequest:
|
|
279
|
+
return await asyncio.to_thread(
|
|
280
|
+
self.sync.connect, providers, requested_cap_usd, redirect_uri
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
async def wait(
|
|
284
|
+
self, grant_request_id: str, *, interval: float = 1.5, timeout: float | None = None
|
|
285
|
+
) -> OutletSession:
|
|
286
|
+
return await asyncio.to_thread(
|
|
287
|
+
self.sync.wait, grant_request_id, interval=interval, timeout=timeout
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
async def exchange(
|
|
291
|
+
self, code: str, state: str, request: GrantRequest | None = None
|
|
292
|
+
) -> OutletSession:
|
|
293
|
+
return await asyncio.to_thread(self.sync.exchange, code, state, request)
|
|
294
|
+
|
|
295
|
+
async def refresh(self, grant_id: str, refresh_token: str | None = None) -> OutletSession:
|
|
296
|
+
return await asyncio.to_thread(self.sync.refresh, grant_id, refresh_token)
|
|
297
|
+
|
|
298
|
+
async def status(self, grant_id: str, refresh_token: str | None = None) -> GrantInfo:
|
|
299
|
+
return await asyncio.to_thread(self.sync.status, grant_id, refresh_token)
|
|
300
|
+
|
|
301
|
+
async def revoke(self, grant_id: str, refresh_token: str | None = None) -> None:
|
|
302
|
+
await asyncio.to_thread(self.sync.revoke, grant_id, refresh_token)
|
|
303
|
+
|
|
304
|
+
direct = staticmethod(direct)
|
useoutlet/direct.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Direct (paste) mode. Vault mode is open: register your app at useoutlet.dev/register.
|
|
2
|
+
|
|
3
|
+
The user pastes their own provider API key; direct() checks it locally and
|
|
4
|
+
returns the same OutletSession shape that the vault flow returns, so upgrading
|
|
5
|
+
later is a one-line change. No network: the key never leaves the process and
|
|
6
|
+
is never sent to Outlet.
|
|
7
|
+
|
|
8
|
+
session = direct({"openai": user_pasted_key})
|
|
9
|
+
ai = OpenAI(api_key=session.keys["openai"])
|
|
10
|
+
|
|
11
|
+
Works with any provider: pass its key under that provider's id. For an
|
|
12
|
+
OpenAI-compatible one (for example {"groq": key}), point the OpenAI SDK at the
|
|
13
|
+
provider's base URL. The provider registry (providers.py) names the nineteen
|
|
14
|
+
with a named Direct screen. Its key shapes and format hints are for the person
|
|
15
|
+
pasting a key. direct() refuses no key over them.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import secrets
|
|
21
|
+
import time
|
|
22
|
+
from collections.abc import Mapping
|
|
23
|
+
from typing import NoReturn
|
|
24
|
+
|
|
25
|
+
from .providers import get_provider
|
|
26
|
+
from .types import OutletError, OutletSession
|
|
27
|
+
|
|
28
|
+
# First-class providers with well-defined key shapes get strict checks.
|
|
29
|
+
# Order within openai matters: the generic "sk-" entry must come last.
|
|
30
|
+
_ACCEPTED: dict[str, list[str]] = {
|
|
31
|
+
"openai": ["sk-proj-", "sk-svcacct-", "sk-"],
|
|
32
|
+
"anthropic": ["sk-ant-"],
|
|
33
|
+
"google": ["AIza"],
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_BASE36 = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _display_name(provider: str) -> str:
|
|
40
|
+
"""The registry's display name for nicer error messages. A provider outside
|
|
41
|
+
the registry falls back to the raw id the developer passed."""
|
|
42
|
+
entry = get_provider(provider)
|
|
43
|
+
return entry.display_name if entry else provider
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _fail(message: str, code: str) -> NoReturn:
|
|
47
|
+
raise OutletError(message, code)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _clean(raw: str) -> str:
|
|
51
|
+
"""Pasted keys arrive with the user's clipboard noise: surrounding whitespace,
|
|
52
|
+
sometimes quotes from a config file. Clean before judging, so honest pastes
|
|
53
|
+
do not fail format checks."""
|
|
54
|
+
return raw.strip().strip("\"'")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _validate_key(provider: str, raw: str) -> str:
|
|
58
|
+
key = _clean(raw)
|
|
59
|
+
name = _display_name(provider)
|
|
60
|
+
|
|
61
|
+
if not key:
|
|
62
|
+
_fail(f"Empty {name} key.", "invalid_key_format")
|
|
63
|
+
|
|
64
|
+
# Admin keys are the user's root org credential. Direct mode exists
|
|
65
|
+
# precisely so apps never hold one. Refuse loudly for ANY provider, not
|
|
66
|
+
# just the named.
|
|
67
|
+
if key.startswith("sk-ant-admin"):
|
|
68
|
+
_fail(
|
|
69
|
+
"This is an Anthropic ADMIN key. It controls the whole organization. "
|
|
70
|
+
"Never paste an admin key into an app. Use a regular API key "
|
|
71
|
+
"(starts with sk-ant-api).",
|
|
72
|
+
"admin_key_rejected",
|
|
73
|
+
)
|
|
74
|
+
if key.startswith("sk-admin-"):
|
|
75
|
+
_fail(
|
|
76
|
+
"This is an OpenAI ADMIN key. It controls the whole organization. "
|
|
77
|
+
"Never paste an admin key into an app. Use a project API key "
|
|
78
|
+
"(starts with sk-proj-).",
|
|
79
|
+
"admin_key_rejected",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Provider-exclusive prefixes pasted into the wrong field are a mistake no
|
|
83
|
+
# matter which provider you named: "sk-ant-" is Anthropic-only, "AIza" is
|
|
84
|
+
# Google-only. The generic "sk-" is NOT exclusive (DeepSeek, Qwen, Moonshot
|
|
85
|
+
# and others use it), so it is only judged among the first-class three.
|
|
86
|
+
if provider != "anthropic" and key.startswith("sk-ant-"):
|
|
87
|
+
_fail(
|
|
88
|
+
f"That looks like an ANTHROPIC key, but it was pasted into the {name} field.",
|
|
89
|
+
"wrong_provider_key",
|
|
90
|
+
)
|
|
91
|
+
if provider != "google" and key.startswith("AIza"):
|
|
92
|
+
_fail(
|
|
93
|
+
f"That looks like a GOOGLE key, but it was pasted into the {name} field.",
|
|
94
|
+
"wrong_provider_key",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# First-class providers (OpenAI, Anthropic, Google): strict format and mix-up.
|
|
98
|
+
if provider in _ACCEPTED:
|
|
99
|
+
if provider != "openai" and key.startswith("sk-") and not key.startswith("sk-ant-"):
|
|
100
|
+
_fail(
|
|
101
|
+
f"That looks like an OPENAI key, but it was pasted into the {name} field.",
|
|
102
|
+
"wrong_provider_key",
|
|
103
|
+
)
|
|
104
|
+
prefixes = _ACCEPTED[provider]
|
|
105
|
+
if not any(key.startswith(p) for p in prefixes):
|
|
106
|
+
_fail(
|
|
107
|
+
f"This doesn't look like a {name} API key (expected it to start with "
|
|
108
|
+
f"{' or '.join(prefixes)}).",
|
|
109
|
+
"invalid_key_format",
|
|
110
|
+
)
|
|
111
|
+
return key
|
|
112
|
+
|
|
113
|
+
# Any other provider's key is an opaque value: a token, a JWT, a generic
|
|
114
|
+
# "sk-", or two parts around a colon or a dot (fal, Higgsfield, Z.ai). We
|
|
115
|
+
# accept any non-empty, non-admin key whole rather than reject a valid key
|
|
116
|
+
# we cannot model. The registry's key shapes are never enforced here.
|
|
117
|
+
return key
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _base36(n: int) -> str:
|
|
121
|
+
digits = ""
|
|
122
|
+
while n:
|
|
123
|
+
n, r = divmod(n, 36)
|
|
124
|
+
digits = _BASE36[r] + digits
|
|
125
|
+
return digits or "0"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _local_id() -> str:
|
|
129
|
+
"""Not a secret: a local handle apps can log and store safely."""
|
|
130
|
+
tail = "".join(secrets.choice(_BASE36) for _ in range(6))
|
|
131
|
+
return f"direct_{_base36(int(time.time() * 1000))}{tail}"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def direct(keys: Mapping[str, str]) -> OutletSession:
|
|
135
|
+
"""Check pasted provider keys and return a session, entirely locally.
|
|
136
|
+
|
|
137
|
+
keys: the provider API keys the user pasted, by provider id. Checked
|
|
138
|
+
locally (format and provider mix-ups for OpenAI, Anthropic and Google; a
|
|
139
|
+
safety check that refuses admin keys for everyone), never transmitted
|
|
140
|
+
anywhere by the SDK.
|
|
141
|
+
"""
|
|
142
|
+
if not keys:
|
|
143
|
+
_fail("direct() needs at least one provider key.", "no_keys")
|
|
144
|
+
checked = {provider: _validate_key(provider, raw) for provider, raw in keys.items()}
|
|
145
|
+
return OutletSession(
|
|
146
|
+
grant_id=_local_id(),
|
|
147
|
+
keys=checked,
|
|
148
|
+
# No Outlet meter in direct mode; real caps arrive with vault mode.
|
|
149
|
+
cap_usd=float("inf"),
|
|
150
|
+
# Direct keys live until the user revokes them in their provider console.
|
|
151
|
+
expires_at="9999-12-31T23:59:59Z",
|
|
152
|
+
mode="direct",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def assert_vault_grant(grant_id: str) -> None:
|
|
157
|
+
"""Vault operations (refresh, status, revoke) have nothing to act on for a
|
|
158
|
+
direct-mode session. Fail with directions rather than a confusing 404."""
|
|
159
|
+
if grant_id.startswith("direct_"):
|
|
160
|
+
_fail(
|
|
161
|
+
"This is a direct-mode session: there is no vault grant behind it. "
|
|
162
|
+
"To revoke, delete the key on the provider's website.",
|
|
163
|
+
"direct_mode_session",
|
|
164
|
+
)
|
useoutlet/ended.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Connection ends: the vault's refusals and status answers turned into
|
|
2
|
+
ConnectionEndedError. Every end passes through ended(). The npm package also
|
|
3
|
+
tells the page's Connect your AI button; a server has no button to tell."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from .providers import get_provider
|
|
8
|
+
from .types import ConnectionEndedError, EndReason, GrantInfo, OutletError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def ended(
|
|
12
|
+
reason: EndReason,
|
|
13
|
+
grant_id: str,
|
|
14
|
+
*,
|
|
15
|
+
info: GrantInfo | None = None,
|
|
16
|
+
provider: str | None = None,
|
|
17
|
+
status: int | None = None,
|
|
18
|
+
) -> ConnectionEndedError:
|
|
19
|
+
"""The typed error for a connection that ended."""
|
|
20
|
+
message = None
|
|
21
|
+
if reason == "refused" and provider:
|
|
22
|
+
entry = get_provider(provider)
|
|
23
|
+
name = entry.display_name if entry else provider
|
|
24
|
+
message = f"{name} refused this Direct API key. Ask the user for a new one."
|
|
25
|
+
return ConnectionEndedError(
|
|
26
|
+
reason, grant_id, info=info, provider=provider, status=status, message=message
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def ended_from_vault(e: OutletError, grant_id: str) -> ConnectionEndedError | None:
|
|
31
|
+
"""The connection end behind a vault refusal, or None when the code is not
|
|
32
|
+
one: 409 grant_capped, 409 grant_revoked, and 401 unauthorized for a refresh
|
|
33
|
+
token the vault no longer knows (rotated away or gone)."""
|
|
34
|
+
if e.code == "grant_capped":
|
|
35
|
+
return ended("capped", grant_id, status=e.status)
|
|
36
|
+
if e.code == "grant_revoked":
|
|
37
|
+
return ended("revoked", grant_id, status=e.status)
|
|
38
|
+
if e.code == "unauthorized":
|
|
39
|
+
return ended("expired", grant_id, status=e.status)
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def ended_from_info(info: GrantInfo) -> ConnectionEndedError | None:
|
|
44
|
+
"""The connection end a status answer reports, or None while it is open."""
|
|
45
|
+
if info.status not in ("capped", "revoked"):
|
|
46
|
+
return None
|
|
47
|
+
provider = info.providers[0] if info.providers else None
|
|
48
|
+
return ended(info.status, info.grant_id, info=info, provider=provider, status=409)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_connection_ended(e: object) -> bool:
|
|
52
|
+
"""True for a ConnectionEndedError, from this package or another copy of it."""
|
|
53
|
+
if isinstance(e, ConnectionEndedError):
|
|
54
|
+
return True
|
|
55
|
+
return (
|
|
56
|
+
getattr(e, "code", None) == "connection_ended"
|
|
57
|
+
and isinstance(getattr(e, "reason", None), str)
|
|
58
|
+
and isinstance(getattr(e, "grant_id", None), str)
|
|
59
|
+
)
|