# GPTPortal puts a dozen provider APIs behind one Node.js chat page

> A local-first Express app that streams from OpenAI, Anthropic, Google, xAI, DeepSeek, Groq, Moonshot, Mistral, OpenRouter and your own Ollama or vLLM endpoint, with a running cost figure next to every answer. There is no build step and no database, and the tagged releases stop in February 2024 even though the code reads 2.0.0.

**Zaki-1052/GPTPortal** — A feature-rich portal to chat with GPT-4, Claude, Gemini, Mistral, & OpenAI Assistant APIs via a lightweight Node.js web app; supports customizable multimodality for voice, images, & files.

- Repository: https://github.com/Zaki-1052/GPTPortal
- Website: http://localhost:3000/portal
- Stars: 397 · Forks: 64
- Language: JavaScript
- License: MIT
- Published: 2026-09-10 · Updated: 2026-09-10 · Language: en
- Canonical page: https://hysenlabs.com/projects/zaki-1052-gptportal

## Basic Auth, a browser session, and the keys in .env

Authentication is one gate and one password. The middleware layer runs Basic Auth, sessions, CORS, rate limiting, CSP, body parsing and static files ahead of the routes, and `.env` supplies `USER_USERNAME` and `USER_PASSWORD`. That is the entire identity system, which tells you the intended deployment: a laptop, or a box in a house, for one person. The README is blunt about it and describes the project as local-first rather than a hosted multi-tenant service.

State follows the same logic. Conversations sit in the browser session and, if you want them on disk, in files under `public/uploads/`. There is no database to migrate or back up, because `package.json` lists eighteen dependencies and not one of them stores your rows. `express`, `axios`, `openai`, `@google/genai`, `marked`, `multer`, `tiktoken`, `js-tiktoken`, `dotenv`, the auth and rate-limit packages, and a handful of small helpers.

What that leaves out matters just as much. `express-rate-limit` and a CSP header cover the browser side of the boundary. TLS is not in these files: the Dockerfile exposes the app on port 3000 and the compose file maps `3000:3000` straight through. Anything other than localhost needs a reverse proxy with certificates that you add yourself.

## No build step, and a core Application.js holding three managers

The 2.0 rewrite trades breadth for a request path you can read end to end. Start at `server.js`, which `package.json` names as `main`. It hands off to `src/server/core/Application.js`, which the architecture diagram credits with wiring everything together and emitting lifecycle events. Three managers hang off it: `MiddlewareManager` owns the request-level concerns, `ServiceManager` instantiates and holds the services, and `RouteManager` sits beside them.

The map stops there. The copy of the diagram that ships with the repository ends mid-graph, so the services underneath `ServiceManager` are named but not described. The same truncation reaches the sections the table of contents promises after Architecture, including the `.env` reference, the streaming walkthrough, the HTTP API reference, deployment, security and cost management. Those are titles without readable text here, and anyone who needs them is reading `src/`.

Two scripts cover the operational surface:

```json
  "scripts": {
    "test": "node scripts/smoke-test.js",
    "start": "node server.js"
  },
```

No build script exists, no bundler sits in the dependency list, and nothing compiles. A change to the frontend is a file edit and a reload.

## Twelve key slots in .env.example, with the tuning values commented out

`.env.example` is the real configuration reference, and it is worth reading line by line because every key maps to a provider the catalog knows about. Eleven provider slots arrive pre-filled with placeholders: `OPENAI_API_KEY`, `GOOGLE_API_KEY`, `MISTRAL_API_KEY`, `CLAUDE_API_KEY`, `GROQ_API_KEY`, `OPENROUTER_API_KEY`, `CODESTRAL_API_KEY`, `DEEPSEEK_API_KEY`, `GROK_API_KEY` for xAI, and `KIMI_API_KEY` for Moonshot.

The tuning keys ship commented out, so nothing reaches the request until you uncomment it. `DEFAULT_MODEL` is set to `'gpt-5.5'`. `TEMPERATURE` carries an example value of `1`, and `MAX_TOKENS` an example of `8000`. `REASONING_EFFORT` names four levels, `minimal`, `low`, `medium`, `high`, while `VERBOSITY` names three, `low`, `medium`, `high`. `ASSISTANT_ID` and `THREAD_ID` exist so you can resume an existing OpenAI assistant rather than starting a new thread every session.

The final block extends the app without touching its code. Custom OpenAI-compatible runtimes take a `{base_url, api_key}` pair, and the models they expose are addressed with a prefixed name that the comment truncates mid-pattern. That is the seam worth watching: local endpoints arrive entirely through environment variables and are auto-discovered into the model picker, so a mistake in the base URL shows up as a missing entry rather than an error.

## Compare view and cost tracking put a price on every answer

Cost is a first-class display rather than a setting. The interface carries a live indicator of how full the context window is and what the conversation has cost so far. The compare view sends one prompt to several models at once and lays the answers side by side, each carrying its own cost. Two dependencies make that arithmetic possible without a hosted tokenizer service: `js-tiktoken` in the browser and `tiktoken` on the Node side.

The same thinking drives Claude prompt caching, which the README presents as a way to cut the bill on long system prompts that stay stable across a session. That pairing is deliberate: the system prompt is editable from inside the browser, which encourages exactly the long preamble caching is meant for.

Presets bundle the knobs into one saved unit, covering system prompt, temperature, max tokens, reasoning effort and verbosity together. Export writes the conversation to a self-contained HTML file, so a thread is not trapped in a session that a cache clear will end. The trade-off is the flip side of the no-database choice. A conversation you forget to export is gone, and `.env` remains the only record of which keys were configured.

## Generate: is a prefix, and an upload is text appended to the prompt

Multimodality here is a handful of separate mechanisms rather than one pipeline. Vision means attaching an image and asking a model that reads images. File uploads take text-based files, and their name and contents get added to your prompt. Voice arrives in two halves, dictation into the composer and replies read back to you. Image generation is a prefix: start a message with `Generate:` and the image comes back inline. For OpenAI there is an assistants mode, a stateful tool-using assistant with a persistent thread and code execution.

Rendering happens in the browser with `marked`, `marked-katex-extension` for math, and syntax-highlighted code blocks carrying per-block copy buttons. The README notes that output is sanitized before it reaches the DOM, which is the right order of operations for something that turns model text into HTML.

One consequence deserves stating plainly. Because uploads are text based, a document whose value lives in its structure cannot be read as structure. The name and the contents go into the prompt as text, and whatever your provider makes of that text is what you get. If your workflow turns on reading a table out of a spreadsheet, the attachment path will not carry it.

## Docker runs it as the node user behind dumb-init

The container path is the shortest route to a running portal, and both files are short enough to read in full. The `Dockerfile` is two stages. A `node:lts` build stage installs `dumb-init`, copies the manifests and runs `npm ci --only=production --legacy-peer-deps`. A `node:lts-slim` final stage copies the built modules across, sets `ENV NODE_ENV production`, switches to `USER node` and starts the app under `dumb-init`.

`docker-compose.yml` pulls a published image rather than building one, which skips the build stage:

```yaml
    image: ghcr.io/zaki-1052/gptportal:latest
    volumes:
      - ./.env:/app/.env #env variables for the app
    ports:
      - 3000:3000
```

Two commented lines show what else you can mount: `public/instructions.md` for the system prompt and `public/claudeInstructions.xml` for the Claude side of it. Only `.env` is mounted by default, so your keys stay outside the image and outside git. One detail to expect: the service is named `chagpt-ui`, a slip from an earlier name that survives in the shipped file and that you inherit the moment you copy it.

## Local runtimes are endpoints here, not competitors

Ollama, LM Studio, vLLM and LocalAI appear in GPTPortal in a specific role. They serve the models. GPTPortal is only the browser shell in front of them, and it reaches each one through the same `{base_url, api_key}` pair, addressing models with a prefix so a local Llama and a hosted one can sit in the same dropdown. It stores nothing about them and manages no weights.

That is the difference in approach against the alternative people usually mean, the per-provider web UI. A provider's own interface knows its own features first: OpenAI's assistants and threads, Anthropic's caching controls, each vendor's file handling. GPTPortal trades that for one consistent surface, a cost figure on every message, and a compare view that sends a single prompt to several providers at once. You keep your keys in one file and pay each vendor directly, with no subscription and no middleman.

The cost of that trade is real and worth pricing before you commit. Anything vendor-specific that GPTPortal does not expose cannot be reached from this UI at all, and the OpenRouter catalog is loaded live and cached rather than curated, so the curated core list in a single JSON file is the part you maintain.

## Tags stop at v1.1.2 while package.json reads 2.0.0

The release history and the version number disagree, and that gap is the first thing to check. The repository is not archived and its last push was on 2026-07-06. The tags tell a different story: v1.1.2 shipped on 2024-02-27, v1.1.1 on 2024-02-09, and v1.1.0-voice on 2024-02-05. Meanwhile `package.json` reads `"version": "2.0.0"` and the README opens the architecture section by calling the 2.0 rewrite a legibility pass. No 2.0 tag is published, so nobody can install a named release of the code you see on the default branch.

What sits in the repository root explains the shape of the project better than any changelog would. There is `MODEL_UPDATE.md`, a planning document, a `docs/` directory, and an `oldDocs.md` that suggests earlier documentation was kept rather than replaced. `vercel.json` sits next to them, so a serverless deployment path was at least considered. A `server.log` is also tracked, and `node_modules/` appears among the top-level entries of the default branch, which means a clone carries a dependency tree with it.

The operational consequence is small and specific. You are tracking `main`, not a release, and the model catalog breaks before the portal code does.

## Conclusion

Adopt GPTPortal if you already pay two or three providers and want one local page with a cost readout instead of a folder of browser tabs. Do not adopt it for shared or public access, because a single Basic Auth password with no TLS in these files is the whole perimeter. Before anything else, open the model catalog JSON named in the README and confirm the identifiers still match what your providers serve, since the last tagged release is v1.1.2 from 2024-02-27 and the catalog is the part that rots first.

## FAQ

### What does GPTPortal need before it will start?

Node.js, a copy of `.env.example` saved as `.env`, and at least one provider key, since every model call leaves your machine. Set `USER_USERNAME` and `USER_PASSWORD` for the login gate, fill in one key such as `OPENAI_API_KEY` or `CLAUDE_API_KEY`, then run `node server.js` and open http://localhost:3000/portal.

### Can GPTPortal talk to a model running on my own machine?

Yes, as long as that runtime exposes an OpenAI-compatible endpoint. Ollama, LM Studio, vLLM and LocalAI are the ones named, and you register them through environment variables holding a base URL and an API key. Their models are then discovered into the model picker without any code change.

### Does GPTPortal store my conversations anywhere?

Not unless you ask it to. Conversation state lives in the browser session, and files under `public/uploads/` hold uploads when you keep them. There is no database among the eighteen dependencies in `package.json`, so exporting to a self-contained HTML file is the only durable copy.

### Is there a hosted version of GPTPortal?

No. The project ships one Basic Auth gate and per-browser session state, sized for a single person or a household, and the container image is meant to be run by you rather than signed up for. You are billed by each provider for what you use, through keys you supply.

### How do I run GPTPortal with Docker?

The compose file pulls `ghcr.io/zaki-1052/gptportal:latest`, mounts `./.env` to `/app/.env` and maps port 3000. The image is built in two stages and starts as the `node` user under `dumb-init`. Two further mounts for `public/instructions.md` and `public/claudeInstructions.xml` are present but commented out.

## Sources

- [License: MIT](https://github.com/Zaki-1052/GPTPortal/blob/main/LICENSE)
- [Project website](http://localhost:3000/portal)
- [README](https://github.com/Zaki-1052/GPTPortal/blob/main/README.md)
- [Releases](https://github.com/Zaki-1052/GPTPortal/releases)
- [Zaki-1052/GPTPortal on GitHub](https://github.com/Zaki-1052/GPTPortal)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/zaki-1052-gptportal
