chatgpt-plan-auth 1.0.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.
- chatgpt_plan_auth/__init__.py +77 -0
- chatgpt_plan_auth/auth.py +560 -0
- chatgpt_plan_auth/catalog.py +94 -0
- chatgpt_plan_auth/client.py +84 -0
- chatgpt_plan_auth/const.py +167 -0
- chatgpt_plan_auth/errors.py +15 -0
- chatgpt_plan_auth/py.typed +0 -0
- chatgpt_plan_auth/requests.py +101 -0
- chatgpt_plan_auth-1.0.0.dist-info/METADATA +128 -0
- chatgpt_plan_auth-1.0.0.dist-info/RECORD +12 -0
- chatgpt_plan_auth-1.0.0.dist-info/WHEEL +4 -0
- chatgpt_plan_auth-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Sign in with ChatGPT (ChatGPT plan usage) for locally hosted apps.
|
|
2
|
+
|
|
3
|
+
Implements the OAuth flow documented at
|
|
4
|
+
https://developers.openai.com/siwc/token-sharing-open-source/: register a
|
|
5
|
+
dynamic client, authorize with PKCE, exchange the code for tokens, refresh and
|
|
6
|
+
rotate them, read the plan's model catalog, and shape Responses API requests
|
|
7
|
+
for the plan usage route.
|
|
8
|
+
|
|
9
|
+
The protocol code needs only :mod:`httpx`. The OpenAI SDK wrapper
|
|
10
|
+
(:mod:`chatgpt_plan_auth.client`) needs the ``openai`` extra.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .auth import (
|
|
14
|
+
AuthorizationRequest,
|
|
15
|
+
ChatGPTCredentials,
|
|
16
|
+
ChatGPTAuth,
|
|
17
|
+
async_exchange_code,
|
|
18
|
+
async_token_request,
|
|
19
|
+
create_authorization_request,
|
|
20
|
+
create_ext_agent_host_id,
|
|
21
|
+
credentials_from_token,
|
|
22
|
+
parse_callback_url,
|
|
23
|
+
validate_id_token,
|
|
24
|
+
)
|
|
25
|
+
from .catalog import (
|
|
26
|
+
async_get_subscription_models,
|
|
27
|
+
async_get_subscription_models_for_credentials,
|
|
28
|
+
subscription_model_options,
|
|
29
|
+
)
|
|
30
|
+
from .const import (
|
|
31
|
+
AUTH_METHOD_CHATGPT,
|
|
32
|
+
AUTHORIZE_URL,
|
|
33
|
+
CLIENT_ID_PREFIX,
|
|
34
|
+
DYNAMIC_AGENT_CLIENT,
|
|
35
|
+
MODELS_URL,
|
|
36
|
+
PLAN_SCOPE,
|
|
37
|
+
REDIRECT_URI,
|
|
38
|
+
RESOURCE,
|
|
39
|
+
SCOPE,
|
|
40
|
+
SUBSCRIPTION_MODEL_SUGGESTIONS,
|
|
41
|
+
TOKEN_URL,
|
|
42
|
+
)
|
|
43
|
+
from .errors import ChatGPTPlanAuthError, ChatGPTPlanAuthFatalError
|
|
44
|
+
from .requests import shape_request_for_subscription, subscription_error_message
|
|
45
|
+
|
|
46
|
+
__version__ = "1.0.0"
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"AUTH_METHOD_CHATGPT",
|
|
50
|
+
"AUTHORIZE_URL",
|
|
51
|
+
"CLIENT_ID_PREFIX",
|
|
52
|
+
"DYNAMIC_AGENT_CLIENT",
|
|
53
|
+
"MODELS_URL",
|
|
54
|
+
"PLAN_SCOPE",
|
|
55
|
+
"REDIRECT_URI",
|
|
56
|
+
"RESOURCE",
|
|
57
|
+
"SCOPE",
|
|
58
|
+
"SUBSCRIPTION_MODEL_SUGGESTIONS",
|
|
59
|
+
"TOKEN_URL",
|
|
60
|
+
"AuthorizationRequest",
|
|
61
|
+
"ChatGPTCredentials",
|
|
62
|
+
"ChatGPTAuth",
|
|
63
|
+
"ChatGPTPlanAuthError",
|
|
64
|
+
"ChatGPTPlanAuthFatalError",
|
|
65
|
+
"async_exchange_code",
|
|
66
|
+
"async_get_subscription_models",
|
|
67
|
+
"async_get_subscription_models_for_credentials",
|
|
68
|
+
"async_token_request",
|
|
69
|
+
"create_authorization_request",
|
|
70
|
+
"create_ext_agent_host_id",
|
|
71
|
+
"credentials_from_token",
|
|
72
|
+
"parse_callback_url",
|
|
73
|
+
"shape_request_for_subscription",
|
|
74
|
+
"subscription_error_message",
|
|
75
|
+
"subscription_model_options",
|
|
76
|
+
"validate_id_token",
|
|
77
|
+
]
|
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
"""Sign in with ChatGPT (ChatGPT plan usage) authorization.
|
|
2
|
+
|
|
3
|
+
This implements the OAuth flow documented at
|
|
4
|
+
https://developers.openai.com/siwc/token-sharing-open-source/ which lets an
|
|
5
|
+
open-source, locally hosted app use an eligible ChatGPT plan for eligible
|
|
6
|
+
Responses API requests. The access token obtained here is used as the bearer
|
|
7
|
+
token against ``https://api.openai.com/v1`` instead of an API key.
|
|
8
|
+
|
|
9
|
+
HISTORY AND SCOPE
|
|
10
|
+
-----------------
|
|
11
|
+
This module was extracted from Home Assistant core, where the same code made
|
|
12
|
+
the reviewer's point for them: it is deep domain knowledge about an external
|
|
13
|
+
system and belongs in a library, not in core. Six modules are ported verbatim
|
|
14
|
+
apart from ``hass`` becoming a plain :class:`httpx.AsyncClient`:
|
|
15
|
+
|
|
16
|
+
``const`` / ``auth`` / ``catalog`` / ``requests`` (this package, ``httpx`` only)
|
|
17
|
+
and ``client`` (the OpenAI SDK wrapper, behind the ``openai`` extra).
|
|
18
|
+
|
|
19
|
+
DELIBERATE SIMPLIFICATION: NO ID TOKEN SIGNATURE VERIFICATION
|
|
20
|
+
-------------------------------------------------------------
|
|
21
|
+
``/.well-known/jwks.json`` is never fetched, RSA keys are not parsed and no
|
|
22
|
+
crypto dependency is carried. The ID token is received directly from the
|
|
23
|
+
provider's TLS-authenticated token endpoint in the same response as the code
|
|
24
|
+
exchange, which is the trust model Home Assistant's own OAuth2 flow uses, so
|
|
25
|
+
the signature adds nothing an attacker who already controls the token response
|
|
26
|
+
could not forge. Issuer, audience, expiry and the per-attempt nonce are still
|
|
27
|
+
verified in :func:`validate_id_token`, and the callback ``state`` is checked in
|
|
28
|
+
:func:`async_exchange_code`.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import asyncio
|
|
34
|
+
import base64
|
|
35
|
+
import binascii
|
|
36
|
+
from collections.abc import Callable, Mapping
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from datetime import datetime, timedelta, timezone
|
|
39
|
+
import hashlib
|
|
40
|
+
import json
|
|
41
|
+
import logging
|
|
42
|
+
import secrets
|
|
43
|
+
from typing import Any
|
|
44
|
+
import uuid
|
|
45
|
+
from urllib.parse import parse_qsl, urlencode, urlparse
|
|
46
|
+
|
|
47
|
+
import httpx
|
|
48
|
+
|
|
49
|
+
from .const import (
|
|
50
|
+
AGENT_NAME_HINT,
|
|
51
|
+
AUTH_METHOD_CHATGPT,
|
|
52
|
+
AUTHORIZE_URL,
|
|
53
|
+
CONF_ACCESS_TOKEN,
|
|
54
|
+
CONF_AUTH_METHOD,
|
|
55
|
+
CONF_CLIENT_ID,
|
|
56
|
+
CONF_EARLIEST_REFRESH_AT,
|
|
57
|
+
CONF_EMAIL,
|
|
58
|
+
CONF_EXPIRES_AT,
|
|
59
|
+
CONF_EXT_AGENT_HOST_ID,
|
|
60
|
+
CONF_ID_TOKEN,
|
|
61
|
+
CONF_REFRESH_TOKEN,
|
|
62
|
+
CONF_SCOPES,
|
|
63
|
+
CONF_SUB,
|
|
64
|
+
DYNAMIC_AGENT_CLIENT,
|
|
65
|
+
EXT_AGENT_HOST_ID_SCHEME,
|
|
66
|
+
FATAL_REFRESH_ERRORS,
|
|
67
|
+
HTTP_TIMEOUT,
|
|
68
|
+
ISSUER,
|
|
69
|
+
REDIRECT_URI,
|
|
70
|
+
RESOURCE,
|
|
71
|
+
SCOPE,
|
|
72
|
+
TOKEN_EXPIRY_MARGIN,
|
|
73
|
+
TOKEN_URL,
|
|
74
|
+
)
|
|
75
|
+
from .errors import ChatGPTPlanAuthError, ChatGPTPlanAuthFatalError
|
|
76
|
+
|
|
77
|
+
_LOGGER = logging.getLogger(__name__)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _utcnow() -> datetime:
|
|
81
|
+
"""Return the current UTC time."""
|
|
82
|
+
return datetime.now(timezone.utc)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parse_optional_datetime(value: Any) -> datetime | None:
|
|
86
|
+
"""Parse an optional ISO 8601 timestamp."""
|
|
87
|
+
if not value or not isinstance(value, str):
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
91
|
+
except ValueError:
|
|
92
|
+
return None
|
|
93
|
+
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=timezone.utc)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class ChatGPTCredentials:
|
|
98
|
+
"""Credentials of one Sign in with ChatGPT registration."""
|
|
99
|
+
|
|
100
|
+
client_id: str
|
|
101
|
+
ext_agent_host_id: str
|
|
102
|
+
sub: str
|
|
103
|
+
email: str | None
|
|
104
|
+
access_token: str
|
|
105
|
+
refresh_token: str
|
|
106
|
+
id_token: str
|
|
107
|
+
expires_at: datetime
|
|
108
|
+
scopes: list[str]
|
|
109
|
+
earliest_refresh_at: datetime | None = None
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def unique_id(self) -> str:
|
|
113
|
+
"""Return the account identity scoped to this registration.
|
|
114
|
+
|
|
115
|
+
The subject identifies the ChatGPT account, the issued client id the
|
|
116
|
+
registration (one account can have several workspaces registered).
|
|
117
|
+
"""
|
|
118
|
+
return f"{self.sub}:{self.client_id}"
|
|
119
|
+
|
|
120
|
+
def as_entry_data(self) -> dict[str, Any]:
|
|
121
|
+
"""Return the credentials as a plain mapping, ready to be persisted."""
|
|
122
|
+
data: dict[str, Any] = {
|
|
123
|
+
CONF_AUTH_METHOD: AUTH_METHOD_CHATGPT,
|
|
124
|
+
CONF_CLIENT_ID: self.client_id,
|
|
125
|
+
CONF_EXT_AGENT_HOST_ID: self.ext_agent_host_id,
|
|
126
|
+
CONF_SUB: self.sub,
|
|
127
|
+
CONF_ACCESS_TOKEN: self.access_token,
|
|
128
|
+
CONF_REFRESH_TOKEN: self.refresh_token,
|
|
129
|
+
CONF_ID_TOKEN: self.id_token,
|
|
130
|
+
CONF_EXPIRES_AT: self.expires_at.isoformat(),
|
|
131
|
+
CONF_SCOPES: self.scopes,
|
|
132
|
+
}
|
|
133
|
+
if self.email is not None:
|
|
134
|
+
data[CONF_EMAIL] = self.email
|
|
135
|
+
if self.earliest_refresh_at is not None:
|
|
136
|
+
data[CONF_EARLIEST_REFRESH_AT] = self.earliest_refresh_at.isoformat()
|
|
137
|
+
return data
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def from_entry_data(cls, data: Mapping[str, Any]) -> ChatGPTCredentials:
|
|
141
|
+
"""Build credentials from persisted data."""
|
|
142
|
+
return cls(
|
|
143
|
+
client_id=data[CONF_CLIENT_ID],
|
|
144
|
+
ext_agent_host_id=data.get(CONF_EXT_AGENT_HOST_ID, ""),
|
|
145
|
+
sub=data.get(CONF_SUB, ""),
|
|
146
|
+
email=data.get(CONF_EMAIL),
|
|
147
|
+
access_token=data[CONF_ACCESS_TOKEN],
|
|
148
|
+
refresh_token=data[CONF_REFRESH_TOKEN],
|
|
149
|
+
id_token=data.get(CONF_ID_TOKEN, ""),
|
|
150
|
+
expires_at=_parse_optional_datetime(data[CONF_EXPIRES_AT]) or _utcnow(),
|
|
151
|
+
scopes=list(data.get(CONF_SCOPES, [])),
|
|
152
|
+
earliest_refresh_at=_parse_optional_datetime(
|
|
153
|
+
data.get(CONF_EARLIEST_REFRESH_AT, "")
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def create_ext_agent_host_id() -> str:
|
|
159
|
+
"""Return a new stable ``ext_agent_host_id`` for this installation.
|
|
160
|
+
|
|
161
|
+
The host id identifies the host the app runs on, not the signed in account,
|
|
162
|
+
and every registration of one instance shares it. A UUID based host id is
|
|
163
|
+
documented as a supported alternative to a JWK thumbprint URI and needs no
|
|
164
|
+
key management; callers generate it once and store it themselves.
|
|
165
|
+
"""
|
|
166
|
+
return f"{EXT_AGENT_HOST_ID_SCHEME}{uuid.uuid4()}"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass
|
|
170
|
+
class AuthorizationRequest:
|
|
171
|
+
"""A pending SIWC authorization attempt."""
|
|
172
|
+
|
|
173
|
+
url: str
|
|
174
|
+
state: str
|
|
175
|
+
nonce: str
|
|
176
|
+
code_verifier: str
|
|
177
|
+
redirect_uri: str
|
|
178
|
+
ext_agent_host_id: str
|
|
179
|
+
# The client id to exchange the code with: `dynamic_agent_client` until the
|
|
180
|
+
# callback returns the issued client id.
|
|
181
|
+
client_id: str
|
|
182
|
+
registration: bool
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _create_pkce_pair() -> tuple[str, str]:
|
|
186
|
+
"""Create a PKCE verifier and its S256 challenge."""
|
|
187
|
+
verifier = secrets.token_urlsafe(64)
|
|
188
|
+
digest = hashlib.sha256(verifier.encode()).digest()
|
|
189
|
+
challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=")
|
|
190
|
+
return verifier, challenge
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def create_authorization_request(
|
|
194
|
+
*,
|
|
195
|
+
client_id: str | None,
|
|
196
|
+
ext_agent_host_id: str,
|
|
197
|
+
id_token_hint: str | None = None,
|
|
198
|
+
agent_name_hint: str = AGENT_NAME_HINT,
|
|
199
|
+
) -> AuthorizationRequest:
|
|
200
|
+
"""Build the authorization URL for a new or a returning ChatGPT account.
|
|
201
|
+
|
|
202
|
+
This is the first half of the flow; :func:`async_exchange_code` finishes it.
|
|
203
|
+
A fresh state, nonce and PKCE verifier are generated for every attempt.
|
|
204
|
+
"""
|
|
205
|
+
state = secrets.token_urlsafe(32)
|
|
206
|
+
nonce = secrets.token_urlsafe(32)
|
|
207
|
+
verifier, challenge = _create_pkce_pair()
|
|
208
|
+
|
|
209
|
+
registration = client_id is None
|
|
210
|
+
params = {
|
|
211
|
+
"response_type": "code",
|
|
212
|
+
"client_id": DYNAMIC_AGENT_CLIENT if registration else client_id,
|
|
213
|
+
"redirect_uri": REDIRECT_URI,
|
|
214
|
+
"scope": SCOPE,
|
|
215
|
+
"resource": RESOURCE,
|
|
216
|
+
"state": state,
|
|
217
|
+
"nonce": nonce,
|
|
218
|
+
"code_challenge": challenge,
|
|
219
|
+
"code_challenge_method": "S256",
|
|
220
|
+
"ext_agent_host_id": ext_agent_host_id,
|
|
221
|
+
}
|
|
222
|
+
if registration:
|
|
223
|
+
# agent_name_hint is display metadata and is only sent for the first
|
|
224
|
+
# (dynamic client registration) authorization request.
|
|
225
|
+
params["agent_name_hint"] = agent_name_hint
|
|
226
|
+
if id_token_hint:
|
|
227
|
+
params["id_token_hint"] = id_token_hint
|
|
228
|
+
|
|
229
|
+
return AuthorizationRequest(
|
|
230
|
+
url=f"{AUTHORIZE_URL}?{urlencode(params)}",
|
|
231
|
+
state=state,
|
|
232
|
+
nonce=nonce,
|
|
233
|
+
code_verifier=verifier,
|
|
234
|
+
redirect_uri=REDIRECT_URI,
|
|
235
|
+
ext_agent_host_id=ext_agent_host_id,
|
|
236
|
+
client_id=client_id or DYNAMIC_AGENT_CLIENT,
|
|
237
|
+
registration=registration,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def parse_callback_url(url: str) -> dict[str, str]:
|
|
242
|
+
"""Parse the query of the URL the user's browser ended up on.
|
|
243
|
+
|
|
244
|
+
The loopback callback is normally on a different machine than the app, so
|
|
245
|
+
the page itself cannot be loaded. The address bar still holds the OAuth
|
|
246
|
+
parameters, which is all that is needed.
|
|
247
|
+
"""
|
|
248
|
+
return dict(parse_qsl(urlparse(url.strip()).query))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _error_code(body: Any) -> str | None:
|
|
252
|
+
"""Return the OAuth error code of a token endpoint response body."""
|
|
253
|
+
match body:
|
|
254
|
+
case {"error": {"code": str() as code}}:
|
|
255
|
+
return code
|
|
256
|
+
case {"error": str() as code}:
|
|
257
|
+
return code
|
|
258
|
+
case {"code": str() as code}:
|
|
259
|
+
return code
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _error_message(body: Any) -> str:
|
|
264
|
+
"""Return the message of a token endpoint error response body."""
|
|
265
|
+
match body:
|
|
266
|
+
case {"error": {"message": str() as message}}:
|
|
267
|
+
return message
|
|
268
|
+
case {"error_description": str() as description}:
|
|
269
|
+
return description
|
|
270
|
+
case {"error": {"error_description": str() as description}}:
|
|
271
|
+
return description
|
|
272
|
+
return "unknown error"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
async def async_token_request(
|
|
276
|
+
client: httpx.AsyncClient,
|
|
277
|
+
data: dict[str, str],
|
|
278
|
+
*,
|
|
279
|
+
fatal_errors: bool = False,
|
|
280
|
+
) -> dict[str, Any]:
|
|
281
|
+
"""POST a form encoded grant to the SIWC token endpoint.
|
|
282
|
+
|
|
283
|
+
``fatal_errors`` maps the errors which invalidate the stored refresh token
|
|
284
|
+
to :class:`ChatGPTPlanAuthFatalError` so the caller can start reauth.
|
|
285
|
+
"""
|
|
286
|
+
try:
|
|
287
|
+
response = await client.post(TOKEN_URL, data=data, timeout=HTTP_TIMEOUT)
|
|
288
|
+
except httpx.HTTPError as err:
|
|
289
|
+
raise ChatGPTPlanAuthError(f"Error talking to OpenAI: {err}") from err
|
|
290
|
+
|
|
291
|
+
if response.status_code == 200:
|
|
292
|
+
try:
|
|
293
|
+
return dict(response.json())
|
|
294
|
+
except ValueError as err:
|
|
295
|
+
raise ChatGPTPlanAuthError("Invalid token response from OpenAI") from err
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
body = response.json()
|
|
299
|
+
except ValueError:
|
|
300
|
+
body = None
|
|
301
|
+
code = _error_code(body)
|
|
302
|
+
message = _error_message(body)
|
|
303
|
+
|
|
304
|
+
if fatal_errors and code in FATAL_REFRESH_ERRORS:
|
|
305
|
+
raise ChatGPTPlanAuthFatalError(
|
|
306
|
+
f"Sign in with ChatGPT authentication failed ({code}): {message}"
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
raise ChatGPTPlanAuthError(f"Sign in with ChatGPT failed ({code}): {message}")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _decode_jwt_claims(id_token: str) -> Mapping[str, Any]:
|
|
313
|
+
"""Return the claims of an ID token without verifying its signature.
|
|
314
|
+
|
|
315
|
+
See the module docstring: the token arrives from the TLS-authenticated
|
|
316
|
+
token endpoint, so the signature is not checked and no key material or
|
|
317
|
+
crypto dependency is carried.
|
|
318
|
+
"""
|
|
319
|
+
parts = id_token.split(".")
|
|
320
|
+
if len(parts) != 3:
|
|
321
|
+
raise ChatGPTPlanAuthError("Invalid ID token: not a JWT")
|
|
322
|
+
padding = "=" * (-len(parts[1]) % 4)
|
|
323
|
+
try:
|
|
324
|
+
claims = json.loads(base64.urlsafe_b64decode(parts[1] + padding))
|
|
325
|
+
except (binascii.Error, ValueError, UnicodeDecodeError) as err:
|
|
326
|
+
raise ChatGPTPlanAuthError(f"Invalid ID token: {err}") from err
|
|
327
|
+
if not isinstance(claims, Mapping):
|
|
328
|
+
raise ChatGPTPlanAuthError("Invalid ID token: claims are not an object")
|
|
329
|
+
return claims
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def validate_id_token(
|
|
333
|
+
id_token: str, *, client_id: str, nonce: str
|
|
334
|
+
) -> Mapping[str, Any]:
|
|
335
|
+
"""Validate an ID token and return its claims.
|
|
336
|
+
|
|
337
|
+
The issuer, the audience (the issued client id), the expiry and the nonce
|
|
338
|
+
of this authorization attempt are all verified. The signature is not; see
|
|
339
|
+
the module docstring.
|
|
340
|
+
"""
|
|
341
|
+
claims = _decode_jwt_claims(id_token)
|
|
342
|
+
|
|
343
|
+
if claims.get("iss") != ISSUER:
|
|
344
|
+
raise ChatGPTPlanAuthError(
|
|
345
|
+
f"Invalid ID token: unexpected issuer {claims.get('iss')!r}"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
audience = claims.get("aud")
|
|
349
|
+
audiences = [audience] if isinstance(audience, str) else list(audience or [])
|
|
350
|
+
if client_id not in audiences:
|
|
351
|
+
raise ChatGPTPlanAuthError("Invalid ID token: audience mismatch")
|
|
352
|
+
|
|
353
|
+
expires_at = claims.get("exp")
|
|
354
|
+
if not isinstance(expires_at, (int, float)) or expires_at <= _utcnow().timestamp():
|
|
355
|
+
raise ChatGPTPlanAuthError("Invalid ID token: expired")
|
|
356
|
+
|
|
357
|
+
if claims.get("nonce") != nonce:
|
|
358
|
+
raise ChatGPTPlanAuthError("Invalid ID token: nonce mismatch")
|
|
359
|
+
|
|
360
|
+
return claims
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _parse_optional_string(value: Any) -> str | None:
|
|
364
|
+
"""Return a string claim value, or None."""
|
|
365
|
+
return value if isinstance(value, str) else None
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
async def async_exchange_code(
|
|
369
|
+
client: httpx.AsyncClient,
|
|
370
|
+
code: str,
|
|
371
|
+
*,
|
|
372
|
+
request: AuthorizationRequest,
|
|
373
|
+
callback_client_id: str | None = None,
|
|
374
|
+
callback_state: str | None = None,
|
|
375
|
+
) -> ChatGPTCredentials:
|
|
376
|
+
"""Exchange the authorization code of a callback for credentials.
|
|
377
|
+
|
|
378
|
+
``callback_state`` is compared against the state of ``request`` (the CSRF
|
|
379
|
+
check) and the nonce of the ID token is verified by
|
|
380
|
+
:func:`validate_id_token`. A dynamic client registration returns the issued
|
|
381
|
+
client id in the callback as ``client_id``; pass it as
|
|
382
|
+
``callback_client_id`` so the code is exchanged with the registered client.
|
|
383
|
+
"""
|
|
384
|
+
if callback_state is not None and callback_state != request.state:
|
|
385
|
+
raise ChatGPTPlanAuthError("Sign in with ChatGPT failed: state mismatch")
|
|
386
|
+
|
|
387
|
+
client_id = callback_client_id or request.client_id
|
|
388
|
+
|
|
389
|
+
token = await async_token_request(
|
|
390
|
+
client,
|
|
391
|
+
{
|
|
392
|
+
"grant_type": "authorization_code",
|
|
393
|
+
"client_id": client_id,
|
|
394
|
+
"code": code,
|
|
395
|
+
"code_verifier": request.code_verifier,
|
|
396
|
+
"redirect_uri": request.redirect_uri,
|
|
397
|
+
"resource": RESOURCE,
|
|
398
|
+
},
|
|
399
|
+
fatal_errors=True,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
sub: str | None = None
|
|
403
|
+
email: str | None = None
|
|
404
|
+
if id_token := token.get("id_token"):
|
|
405
|
+
claims = validate_id_token(id_token, client_id=client_id, nonce=request.nonce)
|
|
406
|
+
sub = _parse_optional_string(claims.get("sub"))
|
|
407
|
+
email = _parse_optional_string(claims.get("email"))
|
|
408
|
+
|
|
409
|
+
return credentials_from_token(
|
|
410
|
+
token,
|
|
411
|
+
client_id=client_id,
|
|
412
|
+
ext_agent_host_id=request.ext_agent_host_id,
|
|
413
|
+
sub=sub,
|
|
414
|
+
email=email,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def credentials_from_token(
|
|
419
|
+
token: Mapping[str, Any],
|
|
420
|
+
*,
|
|
421
|
+
client_id: str,
|
|
422
|
+
ext_agent_host_id: str,
|
|
423
|
+
previous: ChatGPTCredentials | None = None,
|
|
424
|
+
sub: str | None = None,
|
|
425
|
+
email: str | None = None,
|
|
426
|
+
) -> ChatGPTCredentials:
|
|
427
|
+
"""Build credentials from a token endpoint response.
|
|
428
|
+
|
|
429
|
+
Refresh tokens rotate on every refresh, so the response always replaces the
|
|
430
|
+
stored credentials. Fields the response does not repeat are kept from
|
|
431
|
+
``previous``.
|
|
432
|
+
"""
|
|
433
|
+
scopes = str(token.get("scope", "")).split()
|
|
434
|
+
expires_in = token.get("expires_in")
|
|
435
|
+
return ChatGPTCredentials(
|
|
436
|
+
client_id=client_id,
|
|
437
|
+
ext_agent_host_id=ext_agent_host_id,
|
|
438
|
+
sub=sub if sub is not None else (previous.sub if previous else ""),
|
|
439
|
+
email=email if email is not None else (previous.email if previous else None),
|
|
440
|
+
access_token=token["access_token"],
|
|
441
|
+
refresh_token=token.get("refresh_token")
|
|
442
|
+
or (previous.refresh_token if previous else ""),
|
|
443
|
+
id_token=token.get("id_token") or (previous.id_token if previous else ""),
|
|
444
|
+
expires_at=_utcnow() + timedelta(seconds=int(expires_in) if expires_in else 3600),
|
|
445
|
+
scopes=scopes or (previous.scopes if previous else []),
|
|
446
|
+
earliest_refresh_at=_parse_optional_datetime(token.get("earliest_refresh_at")),
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
class ChatGPTAuth:
|
|
451
|
+
"""Manage the SIWC token lifecycle of one set of credentials.
|
|
452
|
+
|
|
453
|
+
The manager is storage agnostic: it refreshes through the given
|
|
454
|
+
:class:`httpx.AsyncClient` and reports every rotation through ``on_update``
|
|
455
|
+
so the caller can persist it. A dead refresh token raises
|
|
456
|
+
:class:`ChatGPTPlanAuthFatalError`; starting the reauth flow is the
|
|
457
|
+
caller's job.
|
|
458
|
+
"""
|
|
459
|
+
|
|
460
|
+
def __init__(
|
|
461
|
+
self,
|
|
462
|
+
credentials: ChatGPTCredentials,
|
|
463
|
+
*,
|
|
464
|
+
client: httpx.AsyncClient,
|
|
465
|
+
on_update: Callable[[ChatGPTCredentials], None] | None = None,
|
|
466
|
+
) -> None:
|
|
467
|
+
"""Initialize the token manager."""
|
|
468
|
+
self.credentials = credentials
|
|
469
|
+
self.client = client
|
|
470
|
+
self.on_update = on_update
|
|
471
|
+
self._lock = asyncio.Lock()
|
|
472
|
+
|
|
473
|
+
@classmethod
|
|
474
|
+
def from_entry_data(
|
|
475
|
+
cls,
|
|
476
|
+
data: Mapping[str, Any],
|
|
477
|
+
*,
|
|
478
|
+
client: httpx.AsyncClient,
|
|
479
|
+
on_update: Callable[[ChatGPTCredentials], None] | None = None,
|
|
480
|
+
) -> ChatGPTAuth:
|
|
481
|
+
"""Build the token manager from persisted credentials."""
|
|
482
|
+
return cls(
|
|
483
|
+
ChatGPTCredentials.from_entry_data(data), client=client, on_update=on_update
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
async def async_get_access_token(self) -> str:
|
|
487
|
+
"""Return a valid access token, refreshing it when it is about to expire.
|
|
488
|
+
|
|
489
|
+
This is used as the async `api_key` provider of the OpenAI client, so
|
|
490
|
+
the token is checked (and refreshed) before every request.
|
|
491
|
+
"""
|
|
492
|
+
async with self._lock:
|
|
493
|
+
if self._needs_refresh():
|
|
494
|
+
await self._async_refresh()
|
|
495
|
+
return self.credentials.access_token
|
|
496
|
+
|
|
497
|
+
def _needs_refresh(self) -> bool:
|
|
498
|
+
"""Return if the access token should be refreshed."""
|
|
499
|
+
now = _utcnow()
|
|
500
|
+
if (
|
|
501
|
+
self.credentials.earliest_refresh_at is not None
|
|
502
|
+
and now < self.credentials.earliest_refresh_at
|
|
503
|
+
):
|
|
504
|
+
# The token endpoint rejects refresh attempts before this time.
|
|
505
|
+
return False
|
|
506
|
+
return now >= self.credentials.expires_at - TOKEN_EXPIRY_MARGIN
|
|
507
|
+
|
|
508
|
+
async def async_handle_authentication_error(self) -> None:
|
|
509
|
+
"""Refresh the token after the API rejected the access token."""
|
|
510
|
+
async with self._lock:
|
|
511
|
+
if (
|
|
512
|
+
self.credentials.earliest_refresh_at is not None
|
|
513
|
+
and _utcnow() < self.credentials.earliest_refresh_at
|
|
514
|
+
):
|
|
515
|
+
# The token endpoint refuses refreshes before this time, so the
|
|
516
|
+
# rejected request was not a token expiry problem.
|
|
517
|
+
return
|
|
518
|
+
await self._async_refresh()
|
|
519
|
+
|
|
520
|
+
async def _async_refresh(self) -> None:
|
|
521
|
+
"""Exchange the refresh token for a new set of tokens."""
|
|
522
|
+
token = await async_token_request(
|
|
523
|
+
self.client,
|
|
524
|
+
{
|
|
525
|
+
"grant_type": "refresh_token",
|
|
526
|
+
"client_id": self.credentials.client_id,
|
|
527
|
+
"refresh_token": self.credentials.refresh_token,
|
|
528
|
+
"resource": RESOURCE,
|
|
529
|
+
},
|
|
530
|
+
fatal_errors=True,
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
self._store(
|
|
534
|
+
credentials_from_token(
|
|
535
|
+
token,
|
|
536
|
+
client_id=self.credentials.client_id,
|
|
537
|
+
ext_agent_host_id=self.credentials.ext_agent_host_id,
|
|
538
|
+
previous=self.credentials,
|
|
539
|
+
)
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
def _store(self, credentials: ChatGPTCredentials) -> None:
|
|
543
|
+
"""Replace the (rotated) credentials and hand them to the caller."""
|
|
544
|
+
self.credentials = credentials
|
|
545
|
+
if self.on_update is not None:
|
|
546
|
+
self.on_update(credentials)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
__all__ = [
|
|
550
|
+
"AuthorizationRequest",
|
|
551
|
+
"ChatGPTCredentials",
|
|
552
|
+
"ChatGPTAuth",
|
|
553
|
+
"async_exchange_code",
|
|
554
|
+
"async_token_request",
|
|
555
|
+
"create_authorization_request",
|
|
556
|
+
"create_ext_agent_host_id",
|
|
557
|
+
"credentials_from_token",
|
|
558
|
+
"parse_callback_url",
|
|
559
|
+
"validate_id_token",
|
|
560
|
+
]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Fetch the ChatGPT plan model catalog.
|
|
2
|
+
|
|
3
|
+
The plan usage route serves a non-standard catalog body (a ``models`` list of
|
|
4
|
+
objects carrying a ``slug``) which the OpenAI SDK cannot parse, so the request
|
|
5
|
+
is made directly with :mod:`httpx`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .auth import ChatGPTCredentials, ChatGPTAuth
|
|
13
|
+
from .const import (
|
|
14
|
+
HTTP_TIMEOUT,
|
|
15
|
+
MODELS_URL,
|
|
16
|
+
SUBSCRIPTION_MODEL_SUGGESTIONS,
|
|
17
|
+
)
|
|
18
|
+
from .errors import ChatGPTPlanAuthError, ChatGPTPlanAuthFatalError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def subscription_model_options(models: list[str]) -> list[str]:
|
|
22
|
+
"""Return the models to offer, falling back to the known suggestions."""
|
|
23
|
+
return models or SUBSCRIPTION_MODEL_SUGGESTIONS
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def async_get_subscription_models(
|
|
27
|
+
client: httpx.AsyncClient, auth: ChatGPTAuth
|
|
28
|
+
) -> list[str]:
|
|
29
|
+
"""Return the model slugs offered to a ChatGPT plan account."""
|
|
30
|
+
return await _async_fetch_subscription_models(
|
|
31
|
+
client, await auth.async_get_access_token()
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def async_get_subscription_models_for_credentials(
|
|
36
|
+
client: httpx.AsyncClient, credentials: ChatGPTCredentials
|
|
37
|
+
) -> list[str]:
|
|
38
|
+
"""Return the model slugs of an account which is not stored yet.
|
|
39
|
+
|
|
40
|
+
Used while creating or converting an entry, when there is no
|
|
41
|
+
:class:`ChatGPTAuth` (and therefore no stored token) to supply the access
|
|
42
|
+
token.
|
|
43
|
+
"""
|
|
44
|
+
return await _async_fetch_subscription_models(client, credentials.access_token)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def _async_fetch_subscription_models(
|
|
48
|
+
client: httpx.AsyncClient, access_token: str
|
|
49
|
+
) -> list[str]:
|
|
50
|
+
"""Return the model slugs offered to a ChatGPT plan account."""
|
|
51
|
+
try:
|
|
52
|
+
response = await client.get(
|
|
53
|
+
MODELS_URL,
|
|
54
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
55
|
+
timeout=HTTP_TIMEOUT,
|
|
56
|
+
)
|
|
57
|
+
except httpx.HTTPError as err:
|
|
58
|
+
raise ChatGPTPlanAuthError(f"Error talking to OpenAI: {err}") from err
|
|
59
|
+
|
|
60
|
+
if response.status_code in (401, 403):
|
|
61
|
+
raise ChatGPTPlanAuthFatalError(
|
|
62
|
+
"Sign in with ChatGPT authentication failed while listing models"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
response.raise_for_status()
|
|
67
|
+
except httpx.HTTPError as err:
|
|
68
|
+
raise ChatGPTPlanAuthError(f"Error listing OpenAI models: {err}") from err
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
payload = response.json()
|
|
72
|
+
except ValueError as err:
|
|
73
|
+
raise ChatGPTPlanAuthError(
|
|
74
|
+
"Invalid model catalog response from OpenAI"
|
|
75
|
+
) from err
|
|
76
|
+
|
|
77
|
+
models = payload.get("models") if isinstance(payload, dict) else None
|
|
78
|
+
if not isinstance(models, list):
|
|
79
|
+
raise ChatGPTPlanAuthError(
|
|
80
|
+
"The model catalog response from OpenAI did not contain a models list"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return [
|
|
84
|
+
item["slug"]
|
|
85
|
+
for item in models
|
|
86
|
+
if isinstance(item, dict) and isinstance(item.get("slug"), str)
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
__all__ = [
|
|
91
|
+
"async_get_subscription_models",
|
|
92
|
+
"async_get_subscription_models_for_credentials",
|
|
93
|
+
"subscription_model_options",
|
|
94
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""OpenAI SDK client which authenticates with a ChatGPT plan.
|
|
2
|
+
|
|
3
|
+
Requires the ``openai`` extra: ``pip install chatgpt-plan-auth[openai]``. Nothing
|
|
4
|
+
else in this package imports the OpenAI SDK.
|
|
5
|
+
|
|
6
|
+
The ported overrides carry no ``typing.override`` decorator: that is Python
|
|
7
|
+
3.12+ and this package supports 3.10.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import AsyncGenerator
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from openai import AsyncOpenAI
|
|
17
|
+
|
|
18
|
+
from .auth import ChatGPTAuth
|
|
19
|
+
from .const import PLACEHOLDER_API_KEY
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ChatGPTSubscriptionAuth(httpx.Auth):
|
|
23
|
+
"""Attach the SIWC access token and refresh it once on a 401."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, auth: ChatGPTAuth) -> None:
|
|
26
|
+
"""Initialize the auth flow."""
|
|
27
|
+
self._auth = auth
|
|
28
|
+
|
|
29
|
+
async def async_auth_flow(
|
|
30
|
+
self, request: httpx.Request
|
|
31
|
+
) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
|
32
|
+
"""Attach the access token, refreshing it when it was rejected."""
|
|
33
|
+
request.headers["Authorization"] = (
|
|
34
|
+
f"Bearer {await self._auth.async_get_access_token()}"
|
|
35
|
+
)
|
|
36
|
+
response = yield request
|
|
37
|
+
if response.status_code == 401:
|
|
38
|
+
await self._auth.async_handle_authentication_error()
|
|
39
|
+
request.headers["Authorization"] = (
|
|
40
|
+
f"Bearer {self._auth.credentials.access_token}"
|
|
41
|
+
)
|
|
42
|
+
yield request
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ChatGPTSubscriptionClient(AsyncOpenAI):
|
|
46
|
+
"""OpenAI client authenticating with a ChatGPT plan subscription."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
auth: ChatGPTAuth,
|
|
51
|
+
http_client: httpx.AsyncClient,
|
|
52
|
+
**options: Any,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Initialize the client."""
|
|
55
|
+
self.auth = auth
|
|
56
|
+
# A ChatGPT plan has no API key: the bearer token is attached per request
|
|
57
|
+
# by `custom_auth`, which also refreshes it. The SDK still requires a
|
|
58
|
+
# non-empty `api_key` to build a client, and it does not support a
|
|
59
|
+
# callable one (that raises inside the request).
|
|
60
|
+
options.pop("api_key", None)
|
|
61
|
+
# The plan usage route answers a degraded provider edge with a 5xx and a
|
|
62
|
+
# `retry_after` (60 seconds when this was written), which the SDK honors
|
|
63
|
+
# while backing off. The default of two retries therefore leaves an
|
|
64
|
+
# interactive request waiting minutes before the caller sees the error,
|
|
65
|
+
# so bound it to a single retry and let the caller report the status.
|
|
66
|
+
options.setdefault("max_retries", 1)
|
|
67
|
+
super().__init__(api_key=PLACEHOLDER_API_KEY, http_client=http_client, **options)
|
|
68
|
+
|
|
69
|
+
def with_options(self, **options: Any) -> ChatGPTSubscriptionClient:
|
|
70
|
+
"""Return a copy of this client which keeps the ChatGPT plan sign in.
|
|
71
|
+
|
|
72
|
+
The base implementation copies a client with `type(self)(**options)`,
|
|
73
|
+
which would not carry the sign in of this instance.
|
|
74
|
+
"""
|
|
75
|
+
options.setdefault("http_client", self._client)
|
|
76
|
+
return ChatGPTSubscriptionClient(self.auth, **options)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def custom_auth(self) -> httpx.Auth | None:
|
|
80
|
+
"""Return the token refreshing auth flow."""
|
|
81
|
+
return ChatGPTSubscriptionAuth(self.auth)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
__all__ = ["ChatGPTSubscriptionAuth", "ChatGPTSubscriptionClient"]
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Constants of the Sign in with ChatGPT (ChatGPT plan usage) protocol.
|
|
2
|
+
|
|
3
|
+
Ports the protocol constants out of Home Assistant core's
|
|
4
|
+
``openai_conversation/chatgpt_auth.py``. Endpoint URLs, the dynamic client
|
|
5
|
+
registration id, scopes, the ``ext_agent_host_id`` scheme, the parameters and
|
|
6
|
+
tools the plan usage route rejects, and the model suggestions live here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from datetime import timedelta
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
AUTHORIZE_URL = "https://auth.openai.com/api/accounts/authorize"
|
|
13
|
+
TOKEN_URL = "https://auth.openai.com/api/accounts/oauth/token"
|
|
14
|
+
ISSUER = "https://auth.openai.com"
|
|
15
|
+
RESOURCE = "https://api.openai.com/v1"
|
|
16
|
+
MODELS_URL = "https://api.openai.com/v1/models"
|
|
17
|
+
|
|
18
|
+
# The callback URI must be an HTTP loopback address on 127.0.0.1; only the port
|
|
19
|
+
# may vary. A fixed port keeps the URI stable between authorization attempts.
|
|
20
|
+
REDIRECT_URI = "http://127.0.0.1:1455/auth/callback"
|
|
21
|
+
|
|
22
|
+
# `dynamic_agent_client` is the first-time registration entrypoint only. It must
|
|
23
|
+
# never be stored: the callback returns the issued client id (oaiapp_...) which
|
|
24
|
+
# is used for the code exchange and all later authorizations.
|
|
25
|
+
DYNAMIC_AGENT_CLIENT = "dynamic_agent_client"
|
|
26
|
+
CLIENT_ID_PREFIX = "oaiapp_"
|
|
27
|
+
|
|
28
|
+
# Display metadata of the first (dynamic client registration) authorization
|
|
29
|
+
# request. Callers pass their own product name.
|
|
30
|
+
AGENT_NAME_HINT = "chatgpt-plan-auth"
|
|
31
|
+
|
|
32
|
+
# The `ext_agent_host_id` scheme: a UUID based host id is documented as a
|
|
33
|
+
# supported alternative to a JWK thumbprint URI and needs no key management.
|
|
34
|
+
EXT_AGENT_HOST_ID_SCHEME = "urn:uuid:"
|
|
35
|
+
|
|
36
|
+
SCOPE = "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct"
|
|
37
|
+
# Granted scope required before the ChatGPT plan may be used.
|
|
38
|
+
PLAN_SCOPE = "chatgpt.tokens.use.direct"
|
|
39
|
+
|
|
40
|
+
# Refresh the access token this long before it expires. Access tokens live one
|
|
41
|
+
# hour and refresh tokens 30 days.
|
|
42
|
+
TOKEN_EXPIRY_MARGIN = timedelta(minutes=5)
|
|
43
|
+
HTTP_TIMEOUT = 30
|
|
44
|
+
|
|
45
|
+
# Keys of the credential mapping (`ChatGPTCredentials.as_entry_data`). They are
|
|
46
|
+
# plain strings so a caller can persist them wherever it wants; Home Assistant
|
|
47
|
+
# stores them as config entry data.
|
|
48
|
+
CONF_AUTH_METHOD = "auth_method"
|
|
49
|
+
CONF_ACCESS_TOKEN = "access_token"
|
|
50
|
+
CONF_CLIENT_ID = "client_id"
|
|
51
|
+
CONF_EMAIL = "email"
|
|
52
|
+
CONF_EARLIEST_REFRESH_AT = "earliest_refresh_at"
|
|
53
|
+
CONF_EXPIRES_AT = "expires_at"
|
|
54
|
+
CONF_EXT_AGENT_HOST_ID = "ext_agent_host_id"
|
|
55
|
+
CONF_ID_TOKEN = "id_token"
|
|
56
|
+
CONF_REFRESH_TOKEN = "refresh_token"
|
|
57
|
+
CONF_SCOPES = "scopes"
|
|
58
|
+
CONF_SUB = "sub"
|
|
59
|
+
|
|
60
|
+
AUTH_METHOD_API_KEY = "api_key"
|
|
61
|
+
AUTH_METHOD_CHATGPT = "chatgpt"
|
|
62
|
+
|
|
63
|
+
# Token endpoint errors which mean the refresh token can never be used again.
|
|
64
|
+
FATAL_REFRESH_ERRORS = frozenset(
|
|
65
|
+
{
|
|
66
|
+
"invalid_grant",
|
|
67
|
+
"invalid_client",
|
|
68
|
+
"invalid_refresh_token",
|
|
69
|
+
"refresh_token_expired",
|
|
70
|
+
"refresh_token_invalidated",
|
|
71
|
+
"refresh_token_reused",
|
|
72
|
+
"token_expired",
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Errors returned by the subscription route itself (inference requests).
|
|
77
|
+
SUBSCRIPTION_INFERENCE_ERROR_HINTS = {
|
|
78
|
+
"subscription_sharing_user_not_eligible": (
|
|
79
|
+
"This ChatGPT account is not eligible to use the ChatGPT plan with "
|
|
80
|
+
"this app. Check your plan in ChatGPT settings."
|
|
81
|
+
),
|
|
82
|
+
"subscription_sharing_usage_limit_exceeded": (
|
|
83
|
+
"The ChatGPT plan usage limit was reached. Check ChatGPT settings -> "
|
|
84
|
+
"Usage to see the remaining plan usage."
|
|
85
|
+
),
|
|
86
|
+
"subscription_sharing_usage_unavailable": (
|
|
87
|
+
"ChatGPT plan usage is currently unavailable, please retry later."
|
|
88
|
+
),
|
|
89
|
+
"subscription_sharing_route_not_supported": (
|
|
90
|
+
"This request is not supported by ChatGPT plan usage."
|
|
91
|
+
),
|
|
92
|
+
"subscription_sharing_unsupported_capability": (
|
|
93
|
+
"This request uses a capability which ChatGPT plan usage does not support."
|
|
94
|
+
),
|
|
95
|
+
"subscription_sharing_invalid_user": (
|
|
96
|
+
"The ChatGPT sign-in is no longer valid. Please sign in with ChatGPT again."
|
|
97
|
+
),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# Parameters the ChatGPT plan usage route rejects. Everything else in the
|
|
101
|
+
# request builder is either required (model/input) or accepted.
|
|
102
|
+
SUBSCRIPTION_UNSUPPORTED_PARAMS = (
|
|
103
|
+
"background",
|
|
104
|
+
"conversation",
|
|
105
|
+
"max_output_tokens",
|
|
106
|
+
"max_tool_calls",
|
|
107
|
+
"metadata",
|
|
108
|
+
"moderation",
|
|
109
|
+
"multi_agent",
|
|
110
|
+
"prompt",
|
|
111
|
+
"prompt_cache_key",
|
|
112
|
+
"prompt_cache_options",
|
|
113
|
+
"prompt_cache_retention",
|
|
114
|
+
"safety_identifier",
|
|
115
|
+
"service_tier",
|
|
116
|
+
"temperature",
|
|
117
|
+
"top_logprobs",
|
|
118
|
+
"top_p",
|
|
119
|
+
"truncation",
|
|
120
|
+
"user",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Hosted tools which the subscription route does not provide. Web search is
|
|
124
|
+
# still offered by the route, subject to the account's plan and policy.
|
|
125
|
+
SUBSCRIPTION_UNSUPPORTED_TOOL_TYPES = (
|
|
126
|
+
"code_interpreter",
|
|
127
|
+
"file_search",
|
|
128
|
+
"image_generation",
|
|
129
|
+
"mcp",
|
|
130
|
+
"tool_search",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# The subscription route requires unsaved, streamed responses.
|
|
134
|
+
SUBSCRIPTION_STORE = False
|
|
135
|
+
SUBSCRIPTION_STREAM = True
|
|
136
|
+
|
|
137
|
+
# Placeholder value the OpenAI SDK insists on; the real credential is the OAuth
|
|
138
|
+
# access token attached by `ChatGPTSubscriptionClient.custom_auth`.
|
|
139
|
+
PLACEHOLDER_API_KEY = "chatgpt-plan"
|
|
140
|
+
|
|
141
|
+
# Models which were observed in the catalog of a ChatGPT plan account. The
|
|
142
|
+
# catalog is authoritative and is used whenever it can be read, but it can be
|
|
143
|
+
# unavailable (OpenAI answered a transient HTTP 520 while this was written) and
|
|
144
|
+
# the plan usage route rejects the regular API default model, so these are
|
|
145
|
+
# offered as suggestions in that case. The model field stays editable, so a
|
|
146
|
+
# model outside this list can still be used.
|
|
147
|
+
SUBSCRIPTION_MODEL_SUGGESTIONS: list[str] = [
|
|
148
|
+
"gpt-6.1-sol",
|
|
149
|
+
"gpt-6-astra",
|
|
150
|
+
"gpt-6-sol",
|
|
151
|
+
"gpt-6-luna",
|
|
152
|
+
"gpt-reserve",
|
|
153
|
+
"gpt-5.6-sol",
|
|
154
|
+
"gpt-5.6-terra",
|
|
155
|
+
"gpt-5.6-luna",
|
|
156
|
+
"gpt-daybreak-blue-latest",
|
|
157
|
+
"gpt-5.5",
|
|
158
|
+
"codex-auto-review",
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def is_chatgpt_data(data: dict[str, Any]) -> bool:
|
|
163
|
+
"""Return if stored data authenticates with a ChatGPT plan.
|
|
164
|
+
|
|
165
|
+
API key data keeps the historical layout and has no auth method key.
|
|
166
|
+
"""
|
|
167
|
+
return data.get(CONF_AUTH_METHOD) == AUTH_METHOD_CHATGPT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Errors of the Sign in with ChatGPT flow.
|
|
2
|
+
|
|
3
|
+
Callers map these onto their own error types: :class:`ChatGPTPlanAuthFatalError`
|
|
4
|
+
means the stored refresh token can never be used again and the user has to sign
|
|
5
|
+
in again (Home Assistant starts its reauth flow), every other
|
|
6
|
+
:class:`ChatGPTPlanAuthError` is a transient failure.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ChatGPTPlanAuthError(Exception):
|
|
11
|
+
"""Error of the Sign in with ChatGPT flow."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ChatGPTPlanAuthFatalError(ChatGPTPlanAuthError):
|
|
15
|
+
"""The stored credentials are dead and the user has to sign in again."""
|
|
File without changes
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Adapt Responses API requests for the ChatGPT plan usage route.
|
|
2
|
+
|
|
3
|
+
The plan usage route accepts a subset of the Responses API: ``store: false``
|
|
4
|
+
with ``stream: true``, no system input items, no hosted tools beyond web
|
|
5
|
+
search, and none of the parameters listed in ``SUBSCRIPTION_UNSUPPORTED_PARAMS``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .const import (
|
|
13
|
+
SUBSCRIPTION_INFERENCE_ERROR_HINTS,
|
|
14
|
+
SUBSCRIPTION_STORE,
|
|
15
|
+
SUBSCRIPTION_STREAM,
|
|
16
|
+
SUBSCRIPTION_UNSUPPORTED_PARAMS,
|
|
17
|
+
SUBSCRIPTION_UNSUPPORTED_TOOL_TYPES,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def shape_request_for_subscription(
|
|
22
|
+
model_args: dict[str, Any],
|
|
23
|
+
messages: list[Any],
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Adapt a Responses API request for the ChatGPT plan usage route.
|
|
26
|
+
|
|
27
|
+
The subscription route only accepts `store: false` together with
|
|
28
|
+
`stream: true` and rejects a documented list of parameters. It also does
|
|
29
|
+
not accept system input items: the system prompt is sent as `instructions`
|
|
30
|
+
instead. Function tools are accepted flat.
|
|
31
|
+
"""
|
|
32
|
+
for param in SUBSCRIPTION_UNSUPPORTED_PARAMS:
|
|
33
|
+
model_args.pop(param, None)
|
|
34
|
+
|
|
35
|
+
model_args["store"] = SUBSCRIPTION_STORE
|
|
36
|
+
model_args["stream"] = SUBSCRIPTION_STREAM
|
|
37
|
+
|
|
38
|
+
# System/developer messages are not accepted as input items.
|
|
39
|
+
instructions = model_args.get("instructions")
|
|
40
|
+
while messages and (
|
|
41
|
+
isinstance(messages[0], dict)
|
|
42
|
+
and messages[0].get("type") == "message"
|
|
43
|
+
and messages[0].get("role") in ("system", "developer")
|
|
44
|
+
):
|
|
45
|
+
content = messages.pop(0).get("content")
|
|
46
|
+
if isinstance(content, str):
|
|
47
|
+
instructions = f"{instructions}\n\n{content}" if instructions else content
|
|
48
|
+
if instructions:
|
|
49
|
+
model_args["instructions"] = instructions
|
|
50
|
+
|
|
51
|
+
# The route rejects explicit `system` input items.
|
|
52
|
+
for item in messages:
|
|
53
|
+
if (
|
|
54
|
+
isinstance(item, dict)
|
|
55
|
+
and item.get("type") == "message"
|
|
56
|
+
and item.get("role") == "system"
|
|
57
|
+
):
|
|
58
|
+
item["role"] = "developer"
|
|
59
|
+
|
|
60
|
+
# Hosted tools which the subscription route does not provide are removed;
|
|
61
|
+
# function tools are sent flat and `web_search` is kept.
|
|
62
|
+
if tools := model_args.get("tools"):
|
|
63
|
+
kept = [
|
|
64
|
+
tool
|
|
65
|
+
for tool in tools
|
|
66
|
+
if tool.get("type") == "function"
|
|
67
|
+
or tool.get("type") not in SUBSCRIPTION_UNSUPPORTED_TOOL_TYPES
|
|
68
|
+
]
|
|
69
|
+
if kept:
|
|
70
|
+
model_args["tools"] = kept
|
|
71
|
+
else:
|
|
72
|
+
model_args.pop("tools")
|
|
73
|
+
|
|
74
|
+
# Naming a single function as `tool_choice` is not supported.
|
|
75
|
+
if isinstance(model_args.get("tool_choice"), dict):
|
|
76
|
+
model_args.pop("tool_choice")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def subscription_error_message(err: Exception) -> str | None:
|
|
80
|
+
"""Return a helpful message for a subscription route error.
|
|
81
|
+
|
|
82
|
+
Duck types on ``code`` and only imports the OpenAI SDK when the error is not
|
|
83
|
+
recognised by code, so this module works without the ``openai`` extra.
|
|
84
|
+
"""
|
|
85
|
+
code: Any = getattr(err, "code", None)
|
|
86
|
+
if isinstance(code, str) and (
|
|
87
|
+
hint := SUBSCRIPTION_INFERENCE_ERROR_HINTS.get(code)
|
|
88
|
+
):
|
|
89
|
+
return hint
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
import openai
|
|
93
|
+
except ImportError:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
if isinstance(err, openai.AuthenticationError):
|
|
97
|
+
return SUBSCRIPTION_INFERENCE_ERROR_HINTS["subscription_sharing_invalid_user"]
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
__all__ = ["shape_request_for_subscription", "subscription_error_message"]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: chatgpt-plan-auth
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Sign in with ChatGPT (ChatGPT plan usage) for locally hosted apps
|
|
5
|
+
Author-email: Michael Guber <michael@guber.dev>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: chatgpt,oauth,openai,pkce,siwc
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Typing :: Typed
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Requires-Dist: httpx>=0.27
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: openai>=1.40; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
18
|
+
Provides-Extra: openai
|
|
19
|
+
Requires-Dist: openai>=1.40; extra == 'openai'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# chatgpt-plan-auth
|
|
23
|
+
|
|
24
|
+
Sign in with ChatGPT (ChatGPT plan usage) for locally hosted, open-source apps.
|
|
25
|
+
|
|
26
|
+
Implements the SIWC OAuth flow documented at
|
|
27
|
+
<https://developers.openai.com/siwc/token-sharing-open-source/>: register a
|
|
28
|
+
dynamic client, authorize with PKCE, exchange the code for tokens, keep them
|
|
29
|
+
refreshed and rotated, read the plan's model catalog, and shape Responses API
|
|
30
|
+
requests for the plan usage route. The access token is used as the bearer token
|
|
31
|
+
against `https://api.openai.com/v1` instead of an API key.
|
|
32
|
+
|
|
33
|
+
The library is a straight extraction of Home Assistant core's
|
|
34
|
+
`openai_conversation/chatgpt_auth.py` (the reviewer's point: deep knowledge of
|
|
35
|
+
an external system belongs in a library, not in core). It carries no Home
|
|
36
|
+
Assistant imports, no `hass`, no config-entry or storage knowledge, and no
|
|
37
|
+
crypto dependency.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install chatgpt-plan-auth # protocol only: httpx
|
|
41
|
+
pip install chatgpt-plan-auth[openai] # + the OpenAI SDK client wrapper
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## What lives where
|
|
45
|
+
|
|
46
|
+
| Module | Contents |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `chatgpt_plan_auth.const` | endpoints, `dynamic_agent_client`, scopes, the `ext_agent_host_id` scheme, rejected params/tools lists, model suggestions |
|
|
49
|
+
| `chatgpt_plan_auth.auth` | authorization URL + PKCE/state/nonce, callback parsing, token requests, code exchange, ID token claim validation, credentials, token lifecycle |
|
|
50
|
+
| `chatgpt_plan_auth.catalog` | plan model catalog fetch/parse and the suggestion fallback |
|
|
51
|
+
| `chatgpt_plan_auth.requests` | `shape_request_for_subscription`, `subscription_error_message` |
|
|
52
|
+
| `chatgpt_plan_auth.client` | httpx auth flow and `openai.AsyncOpenAI` subclass (extra: `openai`) |
|
|
53
|
+
|
|
54
|
+
Everything is caller-driven: no Home Assistant, no global session, no storage.
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import httpx
|
|
58
|
+
from chatgpt_plan_auth import (
|
|
59
|
+
ChatGPTCredentials, ChatGPTAuth, async_exchange_code, create_authorization_request,
|
|
60
|
+
create_ext_agent_host_id, parse_callback_url,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
host_id = create_ext_agent_host_id() # generate once, persist it yourself
|
|
64
|
+
request = create_authorization_request(client_id=None, ext_agent_host_id=host_id)
|
|
65
|
+
# send the user to request.url, then read the loopback address bar (any machine):
|
|
66
|
+
params = parse_callback_url(callback_url)
|
|
67
|
+
async with httpx.AsyncClient() as client:
|
|
68
|
+
credentials = await async_exchange_code(
|
|
69
|
+
client, params["code"], request=request,
|
|
70
|
+
callback_client_id=params.get("client_id"), callback_state=params.get("state"),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Later, per request:
|
|
74
|
+
auth = ChatGPTAuth(credentials, client=client, on_update=save) # `save` persists rotation
|
|
75
|
+
token = await auth.async_get_access_token()
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Errors: `ChatGPTPlanAuthFatalError` means the stored refresh token can never be
|
|
79
|
+
used again (start your re-authentication flow); any other
|
|
80
|
+
`ChatGPTPlanAuthError` is transient. With the `openai` extra,
|
|
81
|
+
`ChatGPTSubscriptionClient(auth, http_client=...)` attaches the token per
|
|
82
|
+
request and retries once on a 401.
|
|
83
|
+
|
|
84
|
+
## Deliberate simplification: no ID token signature verification
|
|
85
|
+
|
|
86
|
+
`/.well-known/jwks.json` is never fetched, RSA keys are never parsed and no JWT
|
|
87
|
+
or crypto dependency is carried — the JWKS helpers and their cache from core are
|
|
88
|
+
gone.
|
|
89
|
+
|
|
90
|
+
Rationale: the ID token arrives directly from the provider's
|
|
91
|
+
TLS-authenticated token endpoint in the same response as the code exchange. That
|
|
92
|
+
is the trust model Home Assistant's own OAuth2 flow uses, and it is exactly the
|
|
93
|
+
dependency-heavy part the core reviewer objected to. A signature check adds
|
|
94
|
+
nothing there: anyone able to forge the token response could equally forge the
|
|
95
|
+
signature.
|
|
96
|
+
|
|
97
|
+
What is still verified on every code exchange:
|
|
98
|
+
|
|
99
|
+
- `iss` is `https://auth.openai.com`
|
|
100
|
+
- `aud` contains the client id the code was exchanged with
|
|
101
|
+
- `exp` is in the future
|
|
102
|
+
- `nonce` equals the nonce of that authorization attempt
|
|
103
|
+
- the callback `state` equals the state of that authorization attempt
|
|
104
|
+
|
|
105
|
+
Token endpoint errors (`invalid_grant`, `invalid_client`, `token_expired`, ...)
|
|
106
|
+
still surface as fatal errors and never invalidate good credentials.
|
|
107
|
+
|
|
108
|
+
If you need signature verification — or you cannot trust your network path to
|
|
109
|
+
the token endpoint — verify the ID token against the JWKS in your application
|
|
110
|
+
before calling `validate_id_token`.
|
|
111
|
+
|
|
112
|
+
## Out of scope (stays in the consuming application)
|
|
113
|
+
|
|
114
|
+
- Home Assistant: config-entry inspection/storage, `Store` for the host id, the
|
|
115
|
+
HA http client, config-flow and reauth steps.
|
|
116
|
+
- Application branding: pass `agent_name_hint` (defaults to
|
|
117
|
+
`chatgpt-plan-auth`).
|
|
118
|
+
- Persisting credentials: the library returns plain mappings
|
|
119
|
+
(`ChatGPTCredentials.as_entry_data()`), nothing is written to disk.
|
|
120
|
+
|
|
121
|
+
## Tests
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
uv run --with pytest-asyncio --with openai python -m pytest tests -q
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
All network access is a fake `httpx.MockTransport`; no test touches the network
|
|
128
|
+
and no test needs key material.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
chatgpt_plan_auth/__init__.py,sha256=Uqhl6MCVksCpiUBGb6TdIx3fiTInDH6Ma4IyaMxOC5Q,2123
|
|
2
|
+
chatgpt_plan_auth/auth.py,sha256=bJKUHK5rVCpNo-PR0VFjYuz4PAZMWEVrwhQUttkJaWQ,19528
|
|
3
|
+
chatgpt_plan_auth/catalog.py,sha256=V6FewEdhguoDgqcq0hEo2iv_D6wapbMGDYokLVyU4M8,2951
|
|
4
|
+
chatgpt_plan_auth/client.py,sha256=LvE7eGZWd5Cy-9olHvq6vrYI-sjpMF-P7r2FxOpcFJE,3210
|
|
5
|
+
chatgpt_plan_auth/const.py,sha256=BP9Ejl6Fef3tuSv8MwBr8Qtni-4dNV_JMOp7L5X4vrI,5957
|
|
6
|
+
chatgpt_plan_auth/errors.py,sha256=5IXUq5XoBl_TCT8moAYWuPfZrmAVjy1SUkt1y1UXptY,551
|
|
7
|
+
chatgpt_plan_auth/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
chatgpt_plan_auth/requests.py,sha256=UXDEY9IW34nyUF7BvWIs8MzmIe4CNlrTl_PlJCbgXYA,3415
|
|
9
|
+
chatgpt_plan_auth-1.0.0.dist-info/METADATA,sha256=-5WIdGTLiacBEuzetMj7neXUkx5zdTJebxC_hAAYNQw,5510
|
|
10
|
+
chatgpt_plan_auth-1.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
chatgpt_plan_auth-1.0.0.dist-info/licenses/LICENSE,sha256=gTWpUtJRJGTvKKxNlJJ0rKdlUe11k2FkmT93JGljSFI,1070
|
|
12
|
+
chatgpt_plan_auth-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael Guber
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|