Skip to content

feat(server): add --api-key authentication - #639

Merged
jason-fxz merged 9 commits into
mainfrom
feat/api-key
Oct 10, 2026
Merged

jason-fxz merged 9 commits into
mainfrom
feat/api-key

Conversation

@jason-fxz

@jason-fxz jason-fxz commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

feat(server): add --api-key authentication

Closes #152, closes #557. #305 and #558 add the server side (and, in #305, the shell); this also covers ft ctl, ft launch and the daemon.

ft serve has no way to require credentials today, so anything that can reach its port can drive the whole API. This adds an opt-in API key and teaches FreeToken's own clients to send it.

Server

  • --api-key KEY, or FREETOKEN_API_KEY when the flag is absent; the flag wins. Unset means no authentication, as today. An explicit --api-key "" is rejected at startup.
  • With a key, every route except /health needs Authorization: Bearer <key> or x-api-key: <key>, including /generate and the control routes; anything else gets 401 {"error": "Unauthorized"}.
  • Pure ASGI middleware, installed only when a key is set and inside the CORS middleware, so CORS still answers preflights and 401s carry CORS headers. Keys are compared as SHA-256 digests in constant time.
  • ServerArgs.api_key is excluded from the Parsed arguments startup log line.

Clients

Each takes --api-key, else FREETOKEN_API_KEY.

  • ft shell sends the key on chat and control requests; ft shell --model ... --api-key KEY hands the server's own key to the attached shell.
  • ft ctl sends it on every request.
  • ft launch sends it on model discovery and hands it to the agent: through the environment for Claude Code, Codex, OpenCode and dsh, through the config file for Hermes and OpenClaw. --dry-run never prints the key; where the plan shows it, it shows <api-key>.
  • A Hermes or OpenClaw config file that will hold the key is made owner-only (0600) before the write, and backups are created with the mode of the file they copy.
  • dsh reads the key from FREETOKEN_API_KEY (set as apiKeyEnv of its llm-deepseek section) and DEEPSEEK_API_KEY stays the dummy, because dsh's web search also reads DEEPSEEK_API_KEY and sends it to api.deepseek.com.

Daemon

  • POST /engine/start and /engine/switch take an optional apiKey. The daemon gives it to the serve as FREETOKEN_API_KEY in its environment (never on its command line), sends it on its own calls to the serve, and keeps it in serve.json, now written 0600, so a re-adopted serve stays reachable. The key is part of the serve config: a start with a different key is a 409, like a different model.
  • The daemon's own FREETOKEN_API_KEY is no longer passed down: a serve requires a key only when its start request carries one.
  • --api-key (or an argparse prefix of it) inside args is rejected with a 400, because the daemon would not know that key.
  • ft daemon start|switch --api-key KEY (default $FREETOKEN_API_KEY).

Compatibility

With no key configured no middleware is installed, and ft ctl and ft launch send the same requests as before; the shell's control requests now carry its placeholder bearer, which a server without a key ignores. FREETOKEN_API_KEY in the environment of ft serve now turns authentication on.

Testing

NVIDIA RTX PRO 6000 Blackwell Server Edition (sm_120, 96 GB), driver 595.91.07; Intel Xeon Platinum 8559C (16 cores), 249 GiB RAM; Qwen/Qwen3-4B (bf16, local copy at /mnt/nvme/models/Qwen3-4B).

  • pytest tests/server tests/daemon -m "not slow": 645 passed, including 23 new tests in tests/server/test_api_key.py (the --dry-run test covers all six agents) and 6 in tests/daemon/test_daemon_serve_manager.py. Each commit passes on its own.

  • FREETOKEN_API_KEY=$K ft serve --model /mnt/nvme/models/Qwen3-4B --port 19190:

    • without a key: 401 on /v1/models, /v1/chat/completions, /generate, /v1/stats, /v1/requests, /v1/cache/status, /v1/cache/rebuild and /v1/admin/prepare-stop;
    • Authorization: Bearer gets 200 on /v1/models, /v1/chat/completions and /v1/responses; x-api-key gets 200 on /v1/models and /v1/messages;
    • /health and a CORS preflight from an allowed origin stay open, a plain OPTIONS without a key gets 401, and a 401 carries the CORS headers;
    • rejected requests, OPTIONS included, do not enter /v1/requests, and the key does not appear in the server log.
  • ft shell --server ... --api-key $K (chat and /cache); without a key it exits with HTTP 401. ft shell --model /mnt/nvme/models/Qwen3-4B --api-key $K: the server answers 401 without the key while the attached shell chats.

  • ft daemon start /mnt/nvme/models/Qwen3-4B --api-key $K under a daemon whose own environment held a different FREETOKEN_API_KEY:

    • the serve's environment carries the request's key only; /engine/stats is proxied;
    • the key is absent from the serve's argv, /engine/status and the logs, and serve.json is mode 600;
    • -- --api-key x in the serve args is answered with a 400;
    • after a daemon restart the serve is re-adopted and ft daemon stop seals its accounting.

    Started without apiKey, the serve stays open.

  • FREETOKEN_API_KEY=$K ft serve --model /mnt/nvme/models/Qwen3-4B --port 19190 --memory-ratio 0.3:

    • ft ctl with the key (--api-key or FREETOKEN_API_KEY): stats, generate, requests, cache and cache --kv 32k; without a key, stats reports HTTP 401 and health answers.
    • ft launch claude|codex|opencode, Claude Code and OpenCode with the key in FREETOKEN_API_KEY, Codex with --api-key: each agent answered. ft launch dsh --api-key $K -- --profile headless (dsh 0.1.5-rc.1) answered; with DEEPSEEK_SEARCH_BASE_URL pointed at a local capture server, its web search sent freetoken and not the key. Without a key, discovery stops with a message naming --api-key. --dry-run for all six agents never prints the key; Claude Code, Codex, OpenCode and dsh show <api-key>.
  • ft serve --model /mnt/nvme/models/Qwen3-4B --port 19190 --memory-ratio 0.3 with no key: /v1/models answers without a header, the log has no API key line, ft ctl stats and cache --kv 32k and ft shell (chat and /cache) work, ft launch --dry-run prints the same output as main (377a3bc) for all six agents, and ft launch claude with an empty FREETOKEN_API_KEY answers.

The daemon reaches the serve's /v1/stats and prepare-stop itself, so the key
travels in the start request, reaches the serve through its environment and is
kept in the state file for re-adoption. The daemon's own FREETOKEN_API_KEY is no
longer passed down to the serve.
dsh's web search reads DEEPSEEK_API_KEY and sends it to api.deepseek.com, so
dsh now reads the key from FREETOKEN_API_KEY through the apiKeyEnv of its
llm-deepseek section, and DEEPSEEK_API_KEY stays the dummy.
@jason-fxz
jason-fxz merged commit 9528190 into main Oct 10, 2026
1 check passed
KarrAcaRn pushed a commit to KarrAcaRn/FreeToken-ByAI that referenced this pull request Oct 10, 2026
Upstream merged its own --api-key (FlashML-org#639), which supersedes the FlashML-org#305
adoption on next. Resolution: upstream's implementation wins everywhere
(app-scoped _ApiKeyMiddleware via install_api_key(app, key), Bearer or
x-api-key, resolve_api_key from freetoken.launch, daemon apiKey, ctl,
launch), and next's module-level _API_KEY gate is dropped. next's other
server work stays: TLS (--ssl-certfile/--ssl-keyfile), --dist-port,
--launch-nonce, loopback-pinned proxy trust (_uvicorn_config/_run_uvicorn)
and the Field-validated daemon StartBody.

tests/server/test_api_key.py is upstream's; the runtime-identity key test
now builds its own app with install_api_key instead of flipping _API_KEY.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
KarrAcaRn pushed a commit to KarrAcaRn/FreeToken-ByAI that referenced this pull request Oct 10, 2026
KarrAcaRn pushed a commit to KarrAcaRn/FreeToken-ByAI that referenced this pull request Oct 10, 2026
…L-org#639

871c600 carried only the renderer's upstream-state refresh (FlashML-org#502 and FlashML-org#134
merged, FlashML-org#133 closed); this adds the FlashML-org#305 note it was meant to.

Assisted-by: Claude Opus 5.5
trcwebdesign pushed a commit to trcwebdesign/FreeToken that referenced this pull request Oct 14, 2026
* feat(server): require an API key on every route but /health

* feat(shell): send the server's API key

* feat(ctl): send the server's API key

* feat(launch): hand the server's API key to the agent

* feat(daemon): pass the serve's API key as apiKey

The daemon reaches the serve's /v1/stats and prepare-stop itself, so the key
travels in the start request, reaches the serve through its environment and is
kept in the state file for re-adoption. The daemon's own FREETOKEN_API_KEY is no
longer passed down to the serve.

* fix(launch): keep the API key out of the variable dsh's web search reads

dsh's web search reads DEEPSEEK_API_KEY and sends it to api.deepseek.com, so
dsh now reads the key from FREETOKEN_API_KEY through the apiKeyEnv of its
llm-deepseek section, and DEEPSEEK_API_KEY stays the dummy.

* fix(server): check the API key on OPTIONS requests

* fix(launch): write agent configs that hold the API key owner-only

* chore: drop comments that restate the code
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.

Feature: --api-key flag for serve mode (token auth on the HTTP API) API access security verification

1 participant