chatgpt-plan-auth 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ name: Publish to PyPI
2
+
3
+ # Trusted publishing: no API token anywhere. One-time setup on PyPI:
4
+ # 1. https://pypi.org/manage/account/publishing/ -> "Add a new pending publisher"
5
+ # PyPI Project Name: chatgpt-plan-auth
6
+ # Owner: guberm
7
+ # Repository name: chatgpt-plan-auth
8
+ # Workflow name: publish.yml
9
+ # Environment name: (leave empty)
10
+ # 2. Push a tag: git tag v1.0.0 && git push origin v1.0.0
11
+ # After the first release the publisher is no longer "pending".
12
+
13
+ on:
14
+ push:
15
+ tags:
16
+ - "v*"
17
+
18
+ jobs:
19
+ publish:
20
+ runs-on: ubuntu-latest
21
+ permissions:
22
+ id-token: write # required for trusted publishing
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+
26
+ - name: Install uv
27
+ uses: astral-sh/setup-uv@v5
28
+
29
+ - name: Build
30
+ run: uv build
31
+
32
+ - name: Publish
33
+ run: uv publish
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ uv.lock
@@ -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.
@@ -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,107 @@
1
+ # chatgpt-plan-auth
2
+
3
+ Sign in with ChatGPT (ChatGPT plan usage) for locally hosted, open-source apps.
4
+
5
+ Implements the SIWC OAuth flow documented at
6
+ <https://developers.openai.com/siwc/token-sharing-open-source/>: register a
7
+ dynamic client, authorize with PKCE, exchange the code for tokens, keep them
8
+ refreshed and rotated, read the plan's model catalog, and shape Responses API
9
+ requests for the plan usage route. The access token is used as the bearer token
10
+ against `https://api.openai.com/v1` instead of an API key.
11
+
12
+ The library is a straight extraction of Home Assistant core's
13
+ `openai_conversation/chatgpt_auth.py` (the reviewer's point: deep knowledge of
14
+ an external system belongs in a library, not in core). It carries no Home
15
+ Assistant imports, no `hass`, no config-entry or storage knowledge, and no
16
+ crypto dependency.
17
+
18
+ ```bash
19
+ pip install chatgpt-plan-auth # protocol only: httpx
20
+ pip install chatgpt-plan-auth[openai] # + the OpenAI SDK client wrapper
21
+ ```
22
+
23
+ ## What lives where
24
+
25
+ | Module | Contents |
26
+ | --- | --- |
27
+ | `chatgpt_plan_auth.const` | endpoints, `dynamic_agent_client`, scopes, the `ext_agent_host_id` scheme, rejected params/tools lists, model suggestions |
28
+ | `chatgpt_plan_auth.auth` | authorization URL + PKCE/state/nonce, callback parsing, token requests, code exchange, ID token claim validation, credentials, token lifecycle |
29
+ | `chatgpt_plan_auth.catalog` | plan model catalog fetch/parse and the suggestion fallback |
30
+ | `chatgpt_plan_auth.requests` | `shape_request_for_subscription`, `subscription_error_message` |
31
+ | `chatgpt_plan_auth.client` | httpx auth flow and `openai.AsyncOpenAI` subclass (extra: `openai`) |
32
+
33
+ Everything is caller-driven: no Home Assistant, no global session, no storage.
34
+
35
+ ```python
36
+ import httpx
37
+ from chatgpt_plan_auth import (
38
+ ChatGPTCredentials, ChatGPTAuth, async_exchange_code, create_authorization_request,
39
+ create_ext_agent_host_id, parse_callback_url,
40
+ )
41
+
42
+ host_id = create_ext_agent_host_id() # generate once, persist it yourself
43
+ request = create_authorization_request(client_id=None, ext_agent_host_id=host_id)
44
+ # send the user to request.url, then read the loopback address bar (any machine):
45
+ params = parse_callback_url(callback_url)
46
+ async with httpx.AsyncClient() as client:
47
+ credentials = await async_exchange_code(
48
+ client, params["code"], request=request,
49
+ callback_client_id=params.get("client_id"), callback_state=params.get("state"),
50
+ )
51
+
52
+ # Later, per request:
53
+ auth = ChatGPTAuth(credentials, client=client, on_update=save) # `save` persists rotation
54
+ token = await auth.async_get_access_token()
55
+ ```
56
+
57
+ Errors: `ChatGPTPlanAuthFatalError` means the stored refresh token can never be
58
+ used again (start your re-authentication flow); any other
59
+ `ChatGPTPlanAuthError` is transient. With the `openai` extra,
60
+ `ChatGPTSubscriptionClient(auth, http_client=...)` attaches the token per
61
+ request and retries once on a 401.
62
+
63
+ ## Deliberate simplification: no ID token signature verification
64
+
65
+ `/.well-known/jwks.json` is never fetched, RSA keys are never parsed and no JWT
66
+ or crypto dependency is carried — the JWKS helpers and their cache from core are
67
+ gone.
68
+
69
+ Rationale: the ID token arrives directly from the provider's
70
+ TLS-authenticated token endpoint in the same response as the code exchange. That
71
+ is the trust model Home Assistant's own OAuth2 flow uses, and it is exactly the
72
+ dependency-heavy part the core reviewer objected to. A signature check adds
73
+ nothing there: anyone able to forge the token response could equally forge the
74
+ signature.
75
+
76
+ What is still verified on every code exchange:
77
+
78
+ - `iss` is `https://auth.openai.com`
79
+ - `aud` contains the client id the code was exchanged with
80
+ - `exp` is in the future
81
+ - `nonce` equals the nonce of that authorization attempt
82
+ - the callback `state` equals the state of that authorization attempt
83
+
84
+ Token endpoint errors (`invalid_grant`, `invalid_client`, `token_expired`, ...)
85
+ still surface as fatal errors and never invalidate good credentials.
86
+
87
+ If you need signature verification — or you cannot trust your network path to
88
+ the token endpoint — verify the ID token against the JWKS in your application
89
+ before calling `validate_id_token`.
90
+
91
+ ## Out of scope (stays in the consuming application)
92
+
93
+ - Home Assistant: config-entry inspection/storage, `Store` for the host id, the
94
+ HA http client, config-flow and reauth steps.
95
+ - Application branding: pass `agent_name_hint` (defaults to
96
+ `chatgpt-plan-auth`).
97
+ - Persisting credentials: the library returns plain mappings
98
+ (`ChatGPTCredentials.as_entry_data()`), nothing is written to disk.
99
+
100
+ ## Tests
101
+
102
+ ```bash
103
+ uv run --with pytest-asyncio --with openai python -m pytest tests -q
104
+ ```
105
+
106
+ All network access is a fake `httpx.MockTransport`; no test touches the network
107
+ and no test needs key material.
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "chatgpt-plan-auth"
7
+ version = "1.0.0"
8
+ description = "Sign in with ChatGPT (ChatGPT plan usage) for locally hosted apps"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Michael Guber", email = "michael@guber.dev" }]
13
+ keywords = ["chatgpt", "oauth", "openai", "pkce", "siwc"]
14
+ classifiers = [
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Typing :: Typed",
18
+ ]
19
+ dependencies = ["httpx>=0.27"]
20
+
21
+ [project.optional-dependencies]
22
+ # The OpenAI SDK wrapper (`chatgpt_plan_auth.client`). The protocol code in
23
+ # `auth`, `catalog` and `requests` never imports it.
24
+ openai = ["openai>=1.40"]
25
+ dev = ["pytest>=8", "pytest-asyncio>=0.23", "openai>=1.40"]
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["src/chatgpt_plan_auth"]
29
+
30
+ [tool.pytest.ini_options]
31
+ asyncio_mode = "auto"
32
+ pythonpath = ["tests"]
33
+ testpaths = ["tests"]
@@ -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
+ ]