fastapi-chatgpt-plan
fastapi-chatgpt-plan: a standalone FastAPI backend that uses your ChatGPT
plan through Sign in with ChatGPT OAuth.
No OpenAI API key is needed. The server signs in with your ChatGPT account,
keeps the OAuth tokens in memory, and proxies model listing and Responses API
calls to the same origin. It is meant to run on your own machine and serve a
frontend on the same origin (http://127.0.0.1:<port>).
Supported platforms: macOS and Linux (the process lock uses fcntl). One
ChatGPT connection at a time, one process, one worker.
Install from PyPI
Requires Python 3.11 or later. Install the command with uv:
uv tool install fastapi-chatgpt-plan
Start the server with ChatGPT plan inference enabled:
CHATGPT_PLAN_ENABLED=true fastapi-chatgpt-plan
Open http://127.0.0.1:8000/ and select Continue with ChatGPT to sign in
and approve plan access.
Plan inference is disabled by default. A fresh installation started without
CHATGPT_PLAN_ENABLED=true requires an issued public identity client ID;
see Running for identity-only setup.
Setup from source
cd fastapi-chatgpt-plan
uv sync
Running
Identity-only sign-in (no plan inference), using an issued public identity
client with http://127.0.0.1:8000/auth/callback registered:
CHATGPT_IDENTITY_CLIENT_ID=oaiapp_your_identity_client uv run fastapi-chatgpt-plan
ChatGPT plan inference enabled:
CHATGPT_PLAN_ENABLED=true uv run fastapi-chatgpt-plan
An unregistered installation in the default identity-only mode displays setup
instructions and returns 409 identity_registration_required from login,
without redirecting to a dynamic registration that fails with invalid_client.
It never silently requests plan scopes. The
identity public-client flow
requires a provisioned client; this package cannot issue one. It accepts an
ID-token-only response and sends no API resource in that flow. Confidential
identity clients are not supported.
Alternatively, explicitly enable plan inference to register an OSS client and
approve plan permissions. After that registration, restarting with the default
CHATGPT_PLAN_ENABLED=false reuses the verified client for identity-only login,
as confirmed with a real account. A provisioned identity client and an OSS plan
client must use separate state directories; the app rejects attempts to switch
client identities within one registration.
The CLI binds 127.0.0.1 only and runs a single uvicorn worker. A second
instance using the same state directory is refused via a process lock, so
registration data stays single-writer.
Open http://127.0.0.1:8000/ to check sign-in status, sign in, sign out, and
list models — this minimal page exists only to verify authentication until a
real frontend arrives.
Configuration
Environment variables are read once at startup; invalid values abort startup with a clear error.
| Variable | Default | Purpose |
|---|---|---|
CHATGPT_PLAN_ENABLED |
false |
Enable inference and model listing; selects the OAuth scopes |
CHATGPT_APP_PORT |
8000 |
Loopback port (1024–65535) |
CHATGPT_STATE_DIR |
~/.local/share/fastapi-chatgpt-plan |
Where registration data is stored (~ expanded) |
CHATGPT_IDENTITY_CLIENT_ID |
unset | Provisioned public identity client for a new identity-only installation |
With CHATGPT_PLAN_ENABLED=false the server requests only
openid profile email and never contacts the models/responses endpoints or
refresh flow. With true it additionally requests
offline_access resource.invoke chatgpt.tokens.use.direct. A successful
sign-in without the plan scope leaves you signed in but inference-disabled —
re-sign in and approve plan access.
API
All state-changing POSTs require the session cookie plus an
x-csrf-token header carrying the csrf value from GET /api/session
(or a csrf_token form field). The Origin header must match the server
origin; same-origin browser fetch calls satisfy this automatically.
The Host header is restricted to 127.0.0.1:<port>.
| Method | Path | Behavior |
|---|---|---|
| GET | / |
Minimal sign-in check page |
| GET | /health |
Liveness only: {"status": "ok"} |
| GET | /api/session |
Status, public user info, plan flags, CSRF token |
| POST | /auth/login |
Start browser-bound sign-in; 303 redirect to OpenAI |
| GET | /auth/callback |
OAuth callback (redirect target; not for direct use) |
| POST | /auth/logout |
End the local session; best-effort remote revocation |
| GET | /api/models |
List available models |
| POST | /api/responses |
Stream a Responses call as SSE |
GET /api/session bootstraps an anonymous session cookie on first contact
and returns:
{
"status": "anonymous",
"user": null,
"plan": {"enabled": true, "permitted": false},
"csrf": "<opaque-csrf-token>"
}
status is anonymous, authenticated, or reauthorization_required.
plan.enabled is the server configuration; plan.permitted is whether the
current connection actually carries the plan scope. Neither the session ID
nor any OAuth token is ever included in responses.
GET /api/models returns (filtered to upstream visibility == "list",
order preserved):
{"models": [{"slug": "<model-slug>", "display_name": "<display name>"}]}
POST /api/responses
{
"model": "<model-slug>",
"input": [
{"role": "user", "content": "最初の質問"},
{"role": "assistant", "content": "前の回答"},
{"role": "user", "content": "続きの質問"}
],
"instructions": "簡潔に日本語で回答する",
"reasoning": {"effort": "low"}
}
modeland a non-emptyinputare required.inputis limited touser/assistanttext messages; the full conversation history is sent every time (nothing is stored server-side).instructionsandreasoning.effortare optional and omitted upstream when absent. Accepted efforts:none,minimal,low,medium,high,xhigh,max— which values a model accepts depends on the model.streammay only betrue,storeonlyfalse(both are optional). The upstream call always usesstream: true,store: false.- Unknown fields are rejected with 422 rather than dropped. Attachments,
tools, structured output,
previous_response_id, andconversationare outside this contract. - Limits: 1 MiB request body (413), 200 messages and 200,000 total text
characters including
instructions(422). These are transport limits, not a model context guarantee; nothing is truncated automatically. modelmust appear in the upstream model list, which is fetched on demand and cached in memory per connection for up to 10 minutes. A model missing from the cache triggers one refetch beforeunknown_modelis returned.
SSE responses
POST /api/responses answers with Content-Type: text/event-stream and
Cache-Control: no-store. Upstream Responses API events are serialized and
forwarded incrementally — the answer is never buffered:
event: response.output_text.delta
data: {"type":"response.output_text.delta", ..., "delta":"回答の一部"}
event: response.completed
data: {"type":"response.completed", ...}
Only response.completed counts as success. response.failed,
response.incomplete, and upstream error retain their event names and JSON
structure, with credential strings redacted. Locally detected EOF or transport
failures use event: error with stream_incomplete or stream_interrupted.
For an upstream failure, inspect response.error or response.incomplete_details;
plan usage codes subscription_sharing_usage_limit_exceeded and
subscription_sharing_usage_unavailable point to
ChatGPT usage settings.
Errors before the stream starts are normal HTTP errors (see below); once streaming has begun, failures arrive as SSE terminal events even though the HTTP status is 200. Logout or browser cancellation may end the connection without a terminal event; clients must not report such an EOF as success.
JavaScript example
EventSource cannot POST, so use fetch() and consume the body. SSE chunks
can split anywhere — including inside multi-byte UTF-8 characters and across
event boundaries — so decode incrementally and split on blank lines only:
const session = await (await fetch("/api/session")).json();
const response = await fetch("/api/responses", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-csrf-token": session.csrf,
},
body: JSON.stringify({
model: "<model-slug>",
input: [{ role: "user", content: "Say hello" }],
}),
});
if (!response.ok) {
const { error } = await response.json();
throw new Error(`${error.code}: ${error.message}`);
}
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader();
let buffer = "";
let answer = "";
let completed = false;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += value;
let index;
while ((index = buffer.indexOf("\n\n")) !== -1) {
const block = buffer.slice(0, index);
buffer = buffer.slice(index + 2);
let event = "message";
let data = "";
for (const line of block.split("\n")) {
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ")) data += line.slice(6);
}
if (event === "error") {
const { code, message } = JSON.parse(data);
throw new Error(`${code}: ${message}`);
}
if (event === "response.failed" || event === "response.incomplete") {
const payload = JSON.parse(data).response;
throw new Error(JSON.stringify(payload.error || payload.incomplete_details));
}
if (event === "response.output_text.delta") {
answer += JSON.parse(data).delta; // stream text incrementally
}
if (event === "response.completed") {
completed = true;
// final usage/metadata in JSON.parse(data)
}
}
}
if (!completed) throw new Error("Stream ended before response.completed");
} finally {
await reader.cancel(); // also cancels upstream if parsing/rendering fails
reader.releaseLock();
}
Errors
Errors before SSE starts use a stable JSON shape:
{"error": {"code": "inference_disabled", "message": "ChatGPT plan inference is disabled."}}
| Condition | HTTP | Code examples |
|---|---|---|
| No/expired session, not signed in, re-auth needed | 401 | session_required, sign_in_required, reauthorization_required |
| Inference disabled or plan scope not granted | 403 | inference_disabled, plan_permission_required |
| CSRF, Origin, or Host check failed | 403 | invalid_csrf_token, invalid_origin, invalid_host |
| Identity registration missing or incompatible | 409 | identity_registration_required, registration_mismatch, plan_registration_required |
| Unsupported input/parameters, unknown model | 422 | invalid_request, invalid_json, unknown_model |
| Body over the size limit | 413 | request_too_large |
| Plan usage unavailable/exhausted (pre-stream) | 429 | usage_limit |
| Upstream rejection or connection failure | 502 | upstream_error |
Usage-limit errors point to
ChatGPT usage settings. Mid-stream
failures are reported inside SSE, never as HTTP errors and never as fabricated
success events. Upstream failure events keep their original names and payloads;
locally detected failures use event: error.
What is stored
Two separate places:
- Memory only — access/refresh/ID tokens, scopes and expiry, app sessions, CSRF state, OAuth transactions, the model cache, and in-flight streams. All of it vanishes on restart: you sign in again after every server restart. Stopping the server discards the tokens; it does not revoke the registration with OpenAI.
CHATGPT_STATE_DIR—registration.json(schema version, persistent host ID, issued client ID, client kind, verified issuer/subject/email/name) andruntime.lock(the single-process lock). Directory mode0700, file mode0600, atomic writes, symlink-safe. No tokens, cookies, PKCE verifiers, codes, or conversation data are written anywhere.
Because the registration survives restarts, re-login reuses the same host ID and issued client ID instead of registering a new client each time.
The session cookie holds an opaque random ID (HttpOnly, SameSite=Lax,
Path=/; Secure stays off on loopback HTTP). It rotates at sign-in
completion and expires 24 hours after the last authenticated access.
Access tokens refresh automatically before model/responses calls; concurrent
refreshes are serialized, a rotated refresh token is applied immediately (the
current one is kept when the server does not rotate it),
and a refresh racing a logout or re-login cannot revive a stale connection.
If refresh fails the session becomes reauthorization_required — sign in
again. There is no API-key fallback and no automatic request retry.
POST /auth/logout stops new calls, ends in-flight streams, discards the
session and tokens, and attempts remote refresh-token revocation
(remote_revocation in the response is confirmed, not_confirmed, or
not_attempted). The registration file is kept.
Only a session authenticated for the current connection can log it out.
Local disconnection precedes remote revocation; a later sign-in is protected
from delayed logout/refresh results and cookie deletion. Pending callbacks from
an older connection generation cannot restore it. Browser disconnection also
cancels an outstanding upstream read and closes the stream.
Development
cd fastapi-chatgpt-plan # from the repository root
uv run pytest # mock-HTTP tests, no real credentials needed
uv run ruff check .
uv build
Tests use httpx.MockTransport with a fake authorization server and a test
RSA key for JWKS/JWT verification; they never touch real credentials or a
real account.
Verified with fastapi 0.142, uvicorn 0.54, httpx 0.28, openai 3.24, PyJWT 2.15, pydantic 2.13 on Python 3.14.
Releases
This package is released independently using tags named
fastapi-chatgpt-plan-<version>; the version must match project.version
in pyproject.toml. The first release tag is fastapi-chatgpt-plan-0.1.0.
Release
Publish a GitHub Release tagged fastapi-chatgpt-plan-<version> matching the
version in pyproject.toml. publish-fastapi.yml tests Python 3.11–3.14, builds
the wheel and sdist, and publishes them to PyPI using Trusted Publishing.
Before the first release, configure the PyPI project (or pending publisher)
with owner ftnext, repository sign-in-with-chatgpt-py, workflow
publish-fastapi.yml, and environment release. No API token is required.
Authenticated responses renew the browser cookie and server session together. Expired sessions and OAuth transactions are purged when a new session is created. The model cache used by inference expires after ten minutes.
Metadata
Release files for fastapi-chatgpt-plan 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| fastapi_chatgpt_plan-0.1.0.tar.gz | 48.3 kB | With provenance | Details for fastapi_chatgpt_plan-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| fastapi_chatgpt_plan-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for fastapi_chatgpt_plan-0.1.0-py3-none-any.whl |
Total release size: 83.2 kB
Release files / fastapi_chatgpt_plan-0.1.0.tar.gz
| Download URL | fastapi_chatgpt_plan-0.1.0.tar.gz |
|---|---|
| Size | 48.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
36742d00d5ae0ea0f819c62577a9227e45975a58216f0492177c40ae4542337b
|
|
BLAKE2b-256 checksum How to use checksums |
9bc5d8a897a44f6da15e567be64e6863ff45b698ad11182801fc59abdd19a0c5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 4, 2026.
Transparency logRelease files / fastapi_chatgpt_plan-0.1.0-py3-none-any.whl
| Download URL | fastapi_chatgpt_plan-0.1.0-py3-none-any.whl |
|---|---|
| Size | 35.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b683819e2762132f5335406f4aca3ff1dc615f069466fc64fe42146cb3d25452
|
|
BLAKE2b-256 checksum How to use checksums |
a54f6f230b1a01af75f7a7803ec386fd79ff4d5f4876d56a6b4adfa472fe8a48
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 4, 2026.
Transparency logSecurity for fastapi-chatgpt-plan 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for fastapi_chatgpt_plan-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 4, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.