Skip to content

feat: chatgpt_plan component — users fund LLM calls with their own ChatGPT plan (draft, flag off) - #118

Draft
calmmage wants to merge 1 commit into
mainfrom
feat/chatgpt-plan
Draft

calmmage wants to merge 1 commit into
mainfrom
feat/chatgpt-plan

Conversation

@calmmage

@calmmage calmmage commented Oct 1, 2026

Copy link
Copy Markdown
Owner

Request

Petr, 01 Oct 2026 (verbatim):

Let's draft a PR for this tech to be build in into our whisper bot and botspot.
Allow everyone to wire in their own chatgpt subscription and pay less for my service / no limit usage on chat / tasks
https://help.openai.com/en/articles/20001542-using-your-chatgpt-plan-in-other-apps-and-sites

Scope answer: "Design PRs now": a design doc plus testable scaffolding behind an off flag. Real OAuth gets wired once OpenAI issues a client ID. This is S1, the botspot half. S2 (whisper bot wiring) depends on it.

What's built

New package botspot/components/new/chatgpt_plan/. It is off by default. With BOTSPOT_CHATGPT_PLAN_ENABLED=false, every call raises PlanDisabled before any I/O.

  • settings.py: ChatgptPlanSettings (env prefix BOTSPOT_CHATGPT_PLAN_). It is registered in BotspotSettings, DependencyManager (deps.chatgpt_plan, Optional), BotManager (initialized only when enabled) and utils.deps_getters.get_chatgpt_plan.
  • oauth.py: "Sign in with ChatGPT".
    • Uses OAuth 2 auth-code with PKCE S256 and a random state + nonce.
    • Pending records go in Mongo chatgpt_oauth_pending, with a TTL index and single-use find_one_and_delete.
    • Code exchange checks that the granted scope contains chatgpt.tokens.use.direct.
    • id_token claim checks: iss, aud, nonce, sub.
    • JWKS signature verification is a marked TODO. See below.
  • store.py: Mongo chatgpt_links, keyed by Telegram user id.
    • Fields: sub, email, client_id, Fernet-encrypted refresh and access tokens, expires_at, scopes, status (active / needs_relink / capped + capped_until), linked_at, last_used_at.
    • The token is refreshed when it is within the margin. Refresh is rolling, and a per-user lock prevents two concurrent refreshes.
    • A failed refresh sets the status to needs_relink. Tokens are never logged.
  • client.py: Responses API only.
    • Sends store=false and stream=true, and accumulates the output text. A call succeeds only after response.completed.
    • Strips the disallowed params (temperature, top_p, max_output_tokens, metadata, user, previous_response_id, background, tools).
    • On 503 it backs off a bounded number of times. On unsupported_capability it drops error.param and retries.
    • list_models keeps only models with visibility == "list".
  • errors.py: the exception classes and an error-code / HTTP-status mapping table:
OpenAI Exception Component action
429 / subscription_sharing_usage_limit_exceeded PlanUsageCapped(reset_hint) status capped until the hint or CAP_PAUSE_SECONDS
401 / invalid_grant / invalid_refresh_token / token_expired PlanNeedsRelink tokens cleared, status needs_relink
503 / subscription_sharing_usage_unavailable PlanUnavailable bounded backoff, then raise
403 (policy/region) PlanUnavailable(retryable=False) raise, no retry
subscription_sharing_unsupported_capability PlanUnsupported(param) drop the param and retry
  • web.py: an aiohttp handler factory for GET <redirect_uri path>. It calls complete_link and renders a tiny "Linked. Go back to Telegram" page. An on_linked(result) hook lets the host bot message the user.
  • README.md in the package covers the blocker, config, error table and what is untested. There is also a demo in examples/components_examples/chatgpt_plan_demo/ and one line in docs/index.md.
  • Dependency: cryptography>=42 is added to the extras group, the same group that holds pymongo. Fernet needs it. It was already in uv.lock as a transitive package, so the lock only gains the direct reference. HTTP uses aiohttp, which botspot already uses.

llm_provider.py and subscription_manager/ are not touched.

Untested until OpenAI issues a client ID

All HTTP in the tests is faked: a fake transport, plus one local aiohttp test server on loopback for the real transport. None of the following has ever talked to OpenAI:

  • the real authorize screen and the partner redirect rules;
  • whether resource (sent on authorize, code exchange and refresh) and agent_name_hint are accepted;
  • the token endpoint's exact response shape (scope, id_token, refresh rolling);
  • id_token signature verification against the issuer JWKS. It is NOT implemented.
    • The TODO is in oauth.verify_id_token_signature, which logs a warning.
    • For now we rely on the id_token arriving straight from the token endpoint over TLS (OIDC Core 3.1.3.7), plus the iss/aud/nonce checks.
    • Tests inject a verifier through ChatgptPlan(..., id_token_verifier=...).
  • real Responses stream events, error bodies and the format of the 429 reset hint;
  • revocation: unlink only deletes our stored tokens; it does not revoke them at OpenAI.

Eligibility blocker

Self-serve SIWC client IDs are only for open-source, locally hosted apps, which may only use a loopback redirect (http://127.0.0.1:{port}/callback). Paid or remotely hosted apps, which includes us, have to apply through the interest form: https://openai.com/form/sign-in-with-chatgpt-interest/ ("select group of commercial partners"). The client ID and the partner redirect rules come with approval. So the flag stays off, and every endpoint, scope and redirect is configurable. Petr has to submit the form himself.

Config (env)

Var Default
BOTSPOT_CHATGPT_PLAN_ENABLED false
BOTSPOT_CHATGPT_PLAN_CLIENT_ID (required when enabled; issued by OpenAI)
BOTSPOT_CHATGPT_PLAN_REDIRECT_URI (required when enabled; public HTTPS callback, its path is the route)
BOTSPOT_CHATGPT_PLAN_TOKEN_ENCRYPTION_KEY (required when enabled; Fernet key)
BOTSPOT_CHATGPT_PLAN_ISSUER https://auth.openai.com
BOTSPOT_CHATGPT_PLAN_AUTHORIZE_PATH /api/accounts/authorize
BOTSPOT_CHATGPT_PLAN_TOKEN_PATH /api/accounts/oauth/token
BOTSPOT_CHATGPT_PLAN_RESOURCE https://api.openai.com/v1
BOTSPOT_CHATGPT_PLAN_API_BASE https://api.openai.com/v1
BOTSPOT_CHATGPT_PLAN_SCOPES openid profile email offline_access resource.invoke chatgpt.tokens.use.direct
BOTSPOT_CHATGPT_PLAN_AGENT_NAME_HINT ""
BOTSPOT_CHATGPT_PLAN_PENDING_TTL_SECONDS 600
BOTSPOT_CHATGPT_PLAN_REFRESH_MARGIN_SECONDS 300
BOTSPOT_CHATGPT_PLAN_UNAVAILABLE_RETRIES 2
BOTSPOT_CHATGPT_PLAN_UNAVAILABLE_BACKOFF_SECONDS 2.0
BOTSPOT_CHATGPT_PLAN_CAP_PAUSE_SECONDS 3600
BOTSPOT_CHATGPT_PLAN_REQUEST_TIMEOUT_SECONDS 120
BOTSPOT_CHATGPT_PLAN_RETURN_URL ""

The component also needs BOTSPOT_MONGO_DATABASE_ENABLED=true.

Public interface (for the whisper bot)

from botspot.components.new.chatgpt_plan import (
    # module-level async API; each raises PlanDisabled when the flag is off / component not initialized
    start_link,        # async def start_link(user_id: int) -> str  (authorize URL)
    complete_link,     # async def complete_link(state: str, code: str) -> LinkResult
    get_access_token,  # async def get_access_token(user_id: int) -> str
    get_link,          # async def get_link(user_id: int) -> PlanLink | None   (status for /settings, no tokens)
    unlink,            # async def unlink(user_id: int) -> bool
    respond,           # async def respond(user_id: int, model: str, input: str | list[dict[str, Any]], *,
                       #                   instructions: str | None = None, **params: Any) -> PlanResponse
    list_models,       # async def list_models(user_id: int) -> list[str]
    get_chatgpt_plan,  # def get_chatgpt_plan() -> ChatgptPlan  (raises PlanDisabled)
    # web
    aiohttp_routes,         # def aiohttp_routes(plan: ChatgptPlan, on_linked: Callable[[LinkResult], Awaitable[None]] | None = None) -> list[RouteDef]
    make_callback_handler,  # def make_callback_handler(plan: ChatgptPlan, on_linked=None) -> Callable[[web.Request], Awaitable[web.Response]]
    # errors (all subclass PlanError(BotspotError); .code holds the OpenAI code)
    PlanError,
    PlanDisabled,      # flag off, no I/O
    PlanNotLinked,
    PlanNeedsRelink,
    PlanUsageCapped,   # .reset_hint: str | None
    PlanUnavailable,   # .retryable: bool
    PlanUnsupported,   # .param: str | None
    PlanLinkError,     # bad/expired state, missing scope, id_token claim mismatch, failed code exchange
    # types
    ChatgptPlan, ChatgptPlanSettings, LinkResult, PlanLink, PlanResponse,
)
  • PlanResponse(text: str, model: str, usage: dict)
  • LinkResult(user_id: int, sub: str, email: str, scopes: str)
  • PlanLink(user_id, status, sub, email, scopes, linked_at, last_used_at, capped_until)

The host falls back to its normal paid path on PlanUsageCapped, PlanNeedsRelink and PlanUnavailable. The plan path cannot set max_tokens.

Checks (local)

  • pytest tests/: 335 passed, 56 of them new in tests/components/new/test_chatgpt_plan.py. Coverage is 73.7% total; the new package is 92–100% per file.
  • ruff check botspot and ruff format --check botspot: clean.
  • vulture --min-confidence 80 botspot: clean.
  • lizard -l python --CCN 20 -w botspot: clean. The highest CCN in the new code is 11 (parse_stream).
  • pyright botspot/components/new/chatgpt_plan: 0 errors.

Draft. Do not merge until Petr reviews it.

🤖 Generated with Claude Code

New botspot/components/new/chatgpt_plan: users link their own ChatGPT plan via
OAuth 2 + PKCE and LLM calls run on the Responses API with their token.

- settings (BOTSPOT_CHATGPT_PLAN_*, enabled=false), registered in BotspotSettings,
  DependencyManager, BotManager and deps getters
- oauth: PKCE S256, single-use state + nonce in a TTL collection, code exchange,
  scope check, id_token claim checks; JWKS signature verification is a TODO hook
- store: Fernet-encrypted tokens, refresh within margin (rolling), needs_relink/capped
- client: respond (store=false, stream=true, success only on response.completed,
  disallowed params stripped, bounded 503 backoff, unsupported-param drop), list_models
- errors: OpenAI error code / status mapping table
- web: aiohttp OAuth callback route factory with an on_linked hook
- cryptography added to the extras group (Fernet)

Flag off: every call raises PlanDisabled with no I/O. Untested against OpenAI until a
partner client ID is issued; all HTTP in tests is faked.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant