From 857d5635665c5c3adc8ae022ee409db42a86d3a7 Mon Sep 17 00:00:00 2001 From: Xiaoze Fan Date: Thu, 8 Oct 2026 06:51:47 +0000 Subject: [PATCH 1/9] feat(server): require an API key on every route but /health --- docs/cli.md | 1 + python/freetoken/launch.py | 10 ++- python/freetoken/server/api_server.py | 41 +++++++++++++ python/freetoken/server/args.py | 20 +++++- tests/server/test_api_key.py | 88 +++++++++++++++++++++++++++ 5 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 tests/server/test_api_key.py diff --git a/docs/cli.md b/docs/cli.md index b9be878ce7..c226c371e2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -41,6 +41,7 @@ parsers all resolve automatically from the checkpoint and the GPU. |---|---|---| | `--host` | 127.0.0.1 | Bind address | | `--port` | 1919 | Bind port | +| `--api-key` | `$FREETOKEN_API_KEY`, else off | Require the key on every route except `/health`, as `Authorization: Bearer ` or `x-api-key: `; other requests get a 401 | | `--gpu` | GPU 0 | GPU to run on: a UUID from `nvidia-smi -L` or an `nvidia-smi` index; see [below](#choosing-a-gpu) | | `--max-running-requests` | 4 | Max concurrently running requests | | `--max-output-tokens` | 32768 | Default output budget for requests that omit one | diff --git a/python/freetoken/launch.py b/python/freetoken/launch.py index 5aac9389fe..a67cc676f2 100644 --- a/python/freetoken/launch.py +++ b/python/freetoken/launch.py @@ -20,7 +20,7 @@ CODEX_PROFILE = "freetoken-launch" CODEX_PROVIDER_NAME = "FreeToken" CODEX_CATALOG_NAME = "freetoken-model.json" -CODEX_PROVIDER_API_KEY_ENV = "FREETOKEN_API_KEY" +API_KEY_ENV = "FREETOKEN_API_KEY" # Used when the server reports no context length. Guessing low only costs earlier compaction. FALLBACK_CONTEXT_WINDOW = 128_000 MAX_OUTPUT_TOKENS_CAP = 32_768 @@ -167,6 +167,10 @@ def resolve_server_url(server: str | None) -> ServerURL: return ServerURL(origin=origin, openai_base_url=f"{origin}/v1") +def resolve_api_key(api_key: str | None) -> str | None: + return api_key or os.environ.get(API_KEY_ENV) or None + + def _get_json(url: str) -> object: request = Request(url, headers={"Accept": "application/json"}) with urlopen(request, timeout=5) as response: @@ -371,7 +375,7 @@ def _codex_profile_text(ctx: LaunchContext, catalog_path: Path) -> str: + f"name = {_toml_string(CODEX_PROVIDER_NAME)}\n" + f"base_url = {_toml_string(ctx.server.openai_base_url)}\n" + 'wire_api = "responses"\n' - + f"env_key = {_toml_string(CODEX_PROVIDER_API_KEY_ENV)}\n" + + f"env_key = {_toml_string(API_KEY_ENV)}\n" ) @@ -408,7 +412,7 @@ def prepare_codex(ctx: LaunchContext) -> CommandSpec: ] return CommandSpec( argv=argv, - env={CODEX_PROVIDER_API_KEY_ENV: "freetoken"}, + env={API_KEY_ENV: "freetoken"}, unset_env=CODEX_CLEAR_ENV, ) diff --git a/python/freetoken/server/api_server.py b/python/freetoken/server/api_server.py index 34221cfc70..64a35159ca 100644 --- a/python/freetoken/server/api_server.py +++ b/python/freetoken/server/api_server.py @@ -2,6 +2,8 @@ import asyncio import contextlib +import hashlib +import hmac import json import os import signal @@ -34,6 +36,7 @@ load_generation_sampling, ) from pydantic import BaseModel +from starlette.datastructures import Headers from .args import ServerArgs from .anthropic_api import register_anthropic_routes @@ -417,6 +420,43 @@ def install_cors(app: FastAPI, origins_csv: str) -> None: ) +class _ApiKeyMiddleware: + """Answer 401 unless the request carries the key, except /health and OPTIONS requests.""" + + def __init__(self, app, api_key: str) -> None: + self.app = app + self._digest = hashlib.sha256(api_key.encode()).digest() + + def _matches(self, value: str | None) -> bool: + # compare digests so the time taken does not depend on the key length + return value is not None and hmac.compare_digest( + hashlib.sha256(value.encode()).digest(), self._digest + ) + + async def __call__(self, scope, receive, send) -> None: + if ( + scope["type"] not in ("http", "websocket") + or scope.get("method") == "OPTIONS" + or scope["path"] == "/health" + ): + return await self.app(scope, receive, send) + headers = Headers(scope=scope) + scheme, _, token = headers.get("authorization", "").partition(" ") + if (scheme.lower() == "bearer" and self._matches(token)) or self._matches( + headers.get("x-api-key") + ): + return await self.app(scope, receive, send) + await JSONResponse({"error": "Unauthorized"}, status_code=401)(scope, receive, send) + + +def install_api_key(app: FastAPI, api_key: str | None) -> None: + """Require ``api_key`` on every route but /health; run before install_cors so 401s get CORS headers.""" + if api_key is None: + return + app.add_middleware(_ApiKeyMiddleware, api_key=api_key) + logger.info("API key required on every route except /health") + + app = FastAPI(title="FreeToken API Server", version=__version__, lifespan=lifespan) register_openai_routes(app, get_global_state, lambda: _MODEL_SAMPLING) register_anthropic_routes(app, get_global_state, lambda: _MODEL_SAMPLING) @@ -958,6 +998,7 @@ def run_api_server(config: ServerArgs, start_backend: Callable[[], "Any"], run_s host = config.server_host port = config.server_port + install_api_key(app, config.api_key) # Create/validate FREETOKEN_API_LOG_DIR and start the writer thread up front, so a # bad path is reported at boot rather than silently on the first request. install_cors(app, config.cors_origins) diff --git a/python/freetoken/server/args.py b/python/freetoken/server/args.py index b8d06018d8..5779345462 100644 --- a/python/freetoken/server/args.py +++ b/python/freetoken/server/args.py @@ -3,7 +3,7 @@ import argparse import json import os -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import List, Tuple import torch @@ -38,6 +38,8 @@ def _nvfp4_entry(value: str) -> str: class ServerArgs(SchedulerConfig): server_host: str = "127.0.0.1" server_port: int = 1919 + # None serves without authentication; kept out of repr so a logged config never shows it + api_key: str | None = field(default=None, repr=False) num_tokenizer: int = 0 silent_output: bool = False # The terminal shell is attached to this server (ft shell --model / ft serve --shell-mode). @@ -368,6 +370,15 @@ def _infer_reasoning_parser(model_path: str) -> str | None: help="The port number for the server to listen on.", ) + parser.add_argument( + "--api-key", + type=str, + default=ServerArgs.api_key, + help="Require this key on every route except /health, sent as " + "'Authorization: Bearer ' or 'x-api-key: '. Defaults to $FREETOKEN_API_KEY; " + "unset serves without authentication.", + ) + parser.add_argument( "--cuda-graph-max-bs", "--graph", @@ -842,6 +853,13 @@ def _infer_reasoning_parser(model_path: str) -> str | None: if entry: kwargs["quant_backend"] = entry + # an empty flag usually means an unset shell variable; refuse it rather than serve open + if kwargs["api_key"] == "": + parser.error("--api-key is empty; omit it to serve without authentication") + from freetoken.launch import resolve_api_key + + kwargs["api_key"] = resolve_api_key(kwargs["api_key"]) + if kwargs["model_path"].startswith("~"): kwargs["model_path"] = os.path.expanduser(kwargs["model_path"]) diff --git a/tests/server/test_api_key.py b/tests/server/test_api_key.py new file mode 100644 index 0000000000..11ed99bd71 --- /dev/null +++ b/tests/server/test_api_key.py @@ -0,0 +1,88 @@ +"""--api-key: the gate in front of every route but /health, and where the key comes from.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from freetoken.server.api_server import install_api_key, install_cors +from freetoken.server.args import parse_args + +KEY = "sk-test" +ORIGIN = "http://localhost:1420" + + +def _client(api_key: str | None) -> TestClient: + app = FastAPI() + app.get("/health")(lambda: {"status": "ok"}) + app.get("/v1/models")(lambda: {"data": []}) + install_api_key(app, api_key) + install_cors(app, ORIGIN) + return TestClient(app) + + +def _parse(*argv: str): + hf = SimpleNamespace(to_dict=lambda: {"architectures": ["Qwen3ForCausalLM"], "torch_dtype": "bfloat16"}) + with patch("freetoken.utils.cached_load_hf_config", lambda _path: hf): + return parse_args(["--model", "/models/anon", *argv])[0] + + +@pytest.mark.parametrize( + "headers, status", + [ + ({}, 401), + ({"Authorization": "Bearer wrong"}, 401), + ({"Authorization": f"Basic {KEY}"}, 401), + ({"Authorization": KEY}, 401), + ({"Authorization": b"Bearer caf\xe9"}, 401), + ({"x-api-key": "wrong"}, 401), + ({"Authorization": f"Bearer {KEY}"}, 200), + ({"Authorization": f"bearer {KEY}"}, 200), + ({"x-api-key": KEY}, 200), + ], +) +def test_key_gates_every_route_but_health(headers, status): + client = _client(KEY) + response = client.get("/v1/models", headers=headers) + assert response.status_code == status + if status == 401: + assert response.json() == {"error": "Unauthorized"} + assert client.get("/health").status_code == 200 + + +def test_no_key_serves_open(): + assert _client(None).get("/v1/models").status_code == 200 + + +def test_cors_preflight_passes_and_401_carries_cors_headers(): + client = _client(KEY) + preflight = client.options( + "/v1/models", + headers={"Origin": ORIGIN, "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "authorization"}, + ) + assert preflight.status_code == 200 + denied = client.get("/v1/models", headers={"Origin": ORIGIN}) + assert denied.status_code == 401 + assert denied.headers["access-control-allow-origin"] == ORIGIN + + +def test_flag_wins_over_env_and_empty_values_do_not_enable_auth(monkeypatch): + monkeypatch.delenv("FREETOKEN_API_KEY", raising=False) + assert _parse().api_key is None + monkeypatch.setenv("FREETOKEN_API_KEY", "from-env") + assert _parse().api_key == "from-env" + assert _parse("--api-key", KEY).api_key == KEY + monkeypatch.setenv("FREETOKEN_API_KEY", "") + assert _parse().api_key is None + with pytest.raises(SystemExit): + _parse("--api-key", "") + + +def test_key_stays_out_of_the_logged_config(monkeypatch): + monkeypatch.delenv("FREETOKEN_API_KEY", raising=False) + assert KEY not in str(_parse("--api-key", KEY)) + From 225d73414c1a8d5be12a22b9a4ddd5026ebf69b1 Mon Sep 17 00:00:00 2001 From: Xiaoze Fan Date: Thu, 8 Oct 2026 06:51:47 +0000 Subject: [PATCH 2/9] feat(shell): send the server's API key --- docs/cli.md | 1 + python/freetoken/server/api_server.py | 6 +++--- python/freetoken/shell/__init__.py | 8 ++++++-- python/freetoken/shell/client.py | 9 +++++---- python/freetoken/shell/tui.py | 6 ++++-- 5 files changed, 19 insertions(+), 11 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index c226c371e2..c3bd6686cf 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -132,6 +132,7 @@ ft shell --model ~/models/Qwen3.6-35B-A3B # serve + chat in one process ``` - Attach mode talks to `--server URL` (default `http://127.0.0.1:1919`) +- `--api-key KEY` (default `$FREETOKEN_API_KEY`) attaches to a server started with `--api-key`; with `--model` the shell uses that server's own key - `/help` inside the shell lists the commands (`/think`, `/cache`, `/reset`). ## ft ctl diff --git a/python/freetoken/server/api_server.py b/python/freetoken/server/api_server.py index 64a35159ca..0e6dd1c499 100644 --- a/python/freetoken/server/api_server.py +++ b/python/freetoken/server/api_server.py @@ -923,7 +923,7 @@ def _flag_shutdown(signum, frame) -> None: signal.signal(sig, _flag_shutdown) -def _serve_and_run_shell(host: str, port: int) -> None: +def _serve_and_run_shell(host: str, port: int, api_key: str | None) -> None: """Shell mode: serve the API here, and attach the terminal client to it over the loopback. The shell is an ordinary API client (see ``freetoken.shell``), so shell mode is just @@ -956,7 +956,7 @@ def _serve_and_run_shell(host: str, port: int) -> None: # /health and echoes the same load progress the desktop app polls for. A ^C during that # wait is a stop, not a crash -- exit through the teardown below, not a traceback. with contextlib.suppress(KeyboardInterrupt): - asyncio.run(run_shell(origin, connect_grace=30.0)) + asyncio.run(run_shell(origin, connect_grace=30.0, api_key=api_key)) finally: server.should_exit = True thread.join(timeout=15) @@ -1085,7 +1085,7 @@ def _on_meta(meta: dict) -> None: ).start() if run_shell: - _serve_and_run_shell(host, port) + _serve_and_run_shell(host, port, config.api_key) return # uvicorn stays on the main thread (signal handling unchanged); ^C reaches the worker group. uvicorn.run(app, host=host, port=port) diff --git a/python/freetoken/shell/__init__.py b/python/freetoken/shell/__init__.py index e429f487a0..d31eed4cd5 100644 --- a/python/freetoken/shell/__init__.py +++ b/python/freetoken/shell/__init__.py @@ -45,6 +45,10 @@ def _build_parser(prog: str) -> argparse.ArgumentParser: default=None, help="FreeToken server URL (default: $FREETOKEN_HOST, else http://127.0.0.1:1919)", ) + parser.add_argument( + "--api-key", + help="API key of a server started with --api-key (default: $FREETOKEN_API_KEY)", + ) return parser @@ -63,7 +67,7 @@ def main(argv: Sequence[str] | None = None, *, prog: str = "ft shell") -> int: except SystemExit as exc: return int(exc.code) if isinstance(exc.code, int) else 2 - from freetoken.launch import resolve_server_url + from freetoken.launch import resolve_api_key, resolve_server_url try: server = resolve_server_url(parsed.server) @@ -74,7 +78,7 @@ def main(argv: Sequence[str] | None = None, *, prog: str = "ft shell") -> int: from .tui import run_shell try: - return asyncio.run(run_shell(server.origin)) + return asyncio.run(run_shell(server.origin, api_key=resolve_api_key(parsed.api_key))) except KeyboardInterrupt: return 130 diff --git a/python/freetoken/shell/client.py b/python/freetoken/shell/client.py index 183fa4dff2..d111536294 100644 --- a/python/freetoken/shell/client.py +++ b/python/freetoken/shell/client.py @@ -27,7 +27,7 @@ import openai from openai import AsyncOpenAI -# Any string is accepted by the server; sent so the SDK doesn't refuse to build a request. +# Sent when no key is given: a server without --api-key accepts any string, and the SDK needs one. LOCAL_API_KEY = "freetoken-local" CONTROL_TIMEOUT = 10.0 # Per-read timeout on the chat stream. Generous: it has to cover the gap before the first @@ -97,14 +97,15 @@ def __init__( self, origin: str, *, - api_key: str = LOCAL_API_KEY, + api_key: str | None = None, timeout: float = CONTROL_TIMEOUT, ) -> None: self.origin = origin.rstrip("/") self.timeout = timeout + self._api_key = api_key or LOCAL_API_KEY self._openai = AsyncOpenAI( base_url=f"{self.origin}/v1", - api_key=api_key, + api_key=self._api_key, max_retries=0, # a retried chat request would generate the turn twice timeout=openai.Timeout(CHAT_READ_TIMEOUT, connect=10.0), ) @@ -118,7 +119,7 @@ def _request_json_blocking( self, method: str, path: str, body: dict[str, Any] | None, timeout: float ) -> dict[str, Any]: data = None - headers = {"Accept": "application/json"} + headers = {"Accept": "application/json", "Authorization": f"Bearer {self._api_key}"} if body is not None: data = json.dumps(body).encode("utf-8") headers["Content-Type"] = "application/json" diff --git a/python/freetoken/shell/tui.py b/python/freetoken/shell/tui.py index 93bd18cc33..72d7ebe0e0 100644 --- a/python/freetoken/shell/tui.py +++ b/python/freetoken/shell/tui.py @@ -515,13 +515,15 @@ def _format_load_progress(doc: dict) -> str: return f"loading ({phase})..." -async def run_shell(origin: str, *, connect_grace: float = 0.0) -> int: +async def run_shell( + origin: str, *, connect_grace: float = 0.0, api_key: str | None = None +) -> int: """Attach to the FreeToken server at ``origin`` and run the terminal chat. ``connect_grace`` is how long to keep retrying a refused connection before giving up -- left at 0 when attaching to a server the user says is already running, raised when the caller just started one in this process (see ``server/api_server.py``).""" - client = ShellClient(origin) + client = ShellClient(origin, api_key=api_key) try: return await _run_shell(client, origin, connect_grace=connect_grace) finally: From 6b751e1033998bc7ce7664081fd937db471591a6 Mon Sep 17 00:00:00 2001 From: Xiaoze Fan Date: Thu, 8 Oct 2026 06:51:47 +0000 Subject: [PATCH 3/9] feat(ctl): send the server's API key --- docs/cli.md | 4 +++- python/freetoken/control_cli.py | 37 ++++++++++++++++++++++++++++----- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index c3bd6686cf..dc92032854 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -138,9 +138,11 @@ ft shell --model ~/models/Qwen3.6-35B-A3B # serve + chat in one process ## ft ctl ```bash -ft ctl [--base-url http://127.0.0.1:1919] [--timeout 10] [--json] +ft ctl [--base-url http://127.0.0.1:1919] [--api-key KEY] [--timeout 10] [--json] ``` +`--api-key` defaults to `$FREETOKEN_API_KEY`. + | Subcommand | Endpoint | Purpose | |---|---|---| | `health` | `GET /health` | Server status, model, load progress | diff --git a/python/freetoken/control_cli.py b/python/freetoken/control_cli.py index 50a32aae6e..e33eb4acb8 100644 --- a/python/freetoken/control_cli.py +++ b/python/freetoken/control_cli.py @@ -10,6 +10,8 @@ from collections.abc import Sequence from typing import Any +from freetoken.launch import resolve_api_key + DEFAULT_BASE_URL = "http://127.0.0.1:1919" # (CLI dest, pool, rebuild body key). moe/mamba are slot counts and pass straight through; @@ -74,6 +76,7 @@ def _request_json( body: dict[str, Any] | None = None, query: dict[str, Any] | None = None, timeout: float = 10.0, + api_key: str | None, ) -> dict[str, Any]: with _open_request( method, @@ -83,6 +86,7 @@ def _request_json( query=query, accept="application/json", timeout=timeout, + api_key=api_key, ) as response: raw = response.read() @@ -104,6 +108,7 @@ def _request_sse_text( *, body: dict[str, Any] | None = None, timeout: float = 10.0, + api_key: str | None, ) -> str: with _open_request( method, @@ -112,6 +117,7 @@ def _request_sse_text( body=body, accept="text/event-stream", timeout=timeout, + api_key=api_key, ) as response: return _read_sse_text(response) @@ -125,9 +131,12 @@ def _open_request( query: dict[str, Any] | None = None, accept: str, timeout: float, + api_key: str | None, ) -> Any: data = None headers = {"Accept": accept} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" if body is not None: data = json.dumps(body).encode("utf-8") headers["Content-Type"] = "application/json" @@ -284,6 +293,10 @@ def _build_parser(prog: str) -> argparse.ArgumentParser: default=DEFAULT_BASE_URL, help=f"FreeToken server URL (default: {DEFAULT_BASE_URL})", ) + parser.add_argument( + "--api-key", + help="API key of a server started with --api-key (default: $FREETOKEN_API_KEY)", + ) parser.add_argument("--timeout", type=float, default=10.0, help="HTTP timeout in seconds") parser.add_argument("--json", action="store_true", help="Print raw JSON response") @@ -355,14 +368,19 @@ def main(argv: Sequence[str] | None = None, *, prog: str = "ft ctl") -> int: args = parser.parse_args(list(argv) if argv is not None else None) except SystemExit as exc: return int(exc.code) if isinstance(exc.code, int) else 2 + args.api_key = resolve_api_key(args.api_key) try: if args.command == "health": - doc = _request_json("GET", args.base_url, "/health", timeout=args.timeout) + doc = _request_json( + "GET", args.base_url, "/health", timeout=args.timeout, api_key=args.api_key + ) _print_doc(doc, _format_health, raw_json=args.json) return 0 if args.command == "stats": - doc = _request_json("GET", args.base_url, "/v1/stats", timeout=args.timeout) + doc = _request_json( + "GET", args.base_url, "/v1/stats", timeout=args.timeout, api_key=args.api_key + ) _print_doc(doc, _format_stats, raw_json=args.json) return 0 if args.command == "generate": @@ -376,6 +394,7 @@ def main(argv: Sequence[str] | None = None, *, prog: str = "ft ctl") -> int: "ignore_eos": args.ignore_eos, }, timeout=args.timeout, + api_key=args.api_key, ) if args.json: print(json.dumps({"text": text}, ensure_ascii=False, indent=2, sort_keys=True)) @@ -390,13 +409,16 @@ def main(argv: Sequence[str] | None = None, *, prog: str = "ft ctl") -> int: "/v1/requests", query=query, timeout=args.timeout, + api_key=args.api_key, ) _print_doc(doc, _format_requests, raw_json=args.json) return 0 if args.command == "cache" and args.cache_command in (None, "status"): if args.cache_command is None and _cache_targets(args): return _run_cache_rebuild(args) - doc = _request_json("GET", args.base_url, "/v1/cache/status", timeout=args.timeout) + doc = _request_json( + "GET", args.base_url, "/v1/cache/status", timeout=args.timeout, api_key=args.api_key + ) _print_doc(doc, _format_cache_status, raw_json=args.json) return 0 if args.command == "cache" and args.cache_command == "rebuild": @@ -444,7 +466,9 @@ def _run_cache_rebuild(args: argparse.Namespace) -> int: print("error: cache rebuild requires at least one cache target", file=sys.stderr) return 2 # The token targets can only be converted against the live geometry, so read it first. - status = _request_json("GET", args.base_url, "/v1/cache/status", timeout=args.timeout) + status = _request_json( + "GET", args.base_url, "/v1/cache/status", timeout=args.timeout, api_key=args.api_key + ) body = _rebuild_body(targets, status.get("geometry") or {}) body["timeout"] = args.wait doc = _request_json( @@ -453,6 +477,7 @@ def _run_cache_rebuild(args: argparse.Namespace) -> int: "/v1/cache/rebuild", body=body, timeout=args.wait + args.timeout, + api_key=args.api_key, ) if args.json or doc.get("status") != "ok": _print_doc(doc, _format_rebuild, raw_json=args.json) @@ -462,7 +487,9 @@ def _run_cache_rebuild(args: argparse.Namespace) -> int: # /cache prints. Best-effort -- the rebuild already succeeded. print("status=ok") try: - print(_format_cache_status(_request_json("GET", args.base_url, "/v1/cache/status"))) + print(_format_cache_status( + _request_json("GET", args.base_url, "/v1/cache/status", api_key=args.api_key) + )) except ControlCliError: pass return 0 From 522d9ac68d4fd042bac9161aedbeb3553b2d920b Mon Sep 17 00:00:00 2001 From: Xiaoze Fan Date: Thu, 8 Oct 2026 06:51:47 +0000 Subject: [PATCH 4/9] feat(launch): hand the server's API key to the agent --- docs/cli.md | 1 + python/freetoken/launch.py | 59 ++++++++++++++++++++++++------------ tests/server/test_api_key.py | 13 ++++++++ 3 files changed, 53 insertions(+), 20 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index dc92032854..a15a34b71c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -170,6 +170,7 @@ Code and Hermes need no declaration. | Flag | Meaning | |---|---| | `--server URL` | Server to point the agent at (default `http://127.0.0.1:1919`) | +| `--api-key KEY` | Key of a server started with `--api-key` (default `$FREETOKEN_API_KEY`). The agent gets it in its environment; Hermes and OpenClaw keep it in their config files | | `--dry-run` | Print the planned config changes and command, touch nothing | | `-y`, `--yes` | Approve install/config prompts | | `--config` | Configure without launching | diff --git a/python/freetoken/launch.py b/python/freetoken/launch.py index a67cc676f2..fc7dcedfb4 100644 --- a/python/freetoken/launch.py +++ b/python/freetoken/launch.py @@ -71,6 +71,7 @@ class LaunchContext: extra_args: list[str] dry_run: bool assume_yes: bool = False + api_key: str | None = None @dataclass(frozen=True) @@ -171,9 +172,11 @@ def resolve_api_key(api_key: str | None) -> str | None: return api_key or os.environ.get(API_KEY_ENV) or None -def _get_json(url: str) -> object: - request = Request(url, headers={"Accept": "application/json"}) - with urlopen(request, timeout=5) as response: +def _get_json(url: str, api_key: str | None) -> object: + headers = {"Accept": "application/json"} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + with urlopen(Request(url, headers=headers), timeout=5) as response: return json.loads(response.read().decode("utf-8")) @@ -181,10 +184,10 @@ def _positive_int(value: object) -> int | None: return value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else None -def _stats_model(server: ServerURL) -> dict[str, object]: +def _stats_model(server: ServerURL, api_key: str | None) -> dict[str, object]: """The /v1/stats model card; empty when the server predates it.""" try: - payload = _get_json(f"{server.origin}/v1/stats") + payload = _get_json(f"{server.origin}/v1/stats", api_key) except (HTTPError, URLError, HTTPException, OSError, TimeoutError, ValueError): return {} model = payload.get("model") if isinstance(payload, dict) else None @@ -197,11 +200,16 @@ def _input_modalities(stats_model: dict[str, object]) -> tuple[str, ...]: return ("text", *extra) -def discover_server_model(server: ServerURL) -> ServedModel: +def discover_server_model(server: ServerURL, api_key: str | None) -> ServedModel: try: - _get_json(server.openai_base_url) - payload = _get_json(f"{server.openai_base_url}/models") + _get_json(server.openai_base_url, api_key) + payload = _get_json(f"{server.openai_base_url}/models", api_key) except (HTTPError, URLError, HTTPException, OSError, TimeoutError) as exc: + if isinstance(exc, HTTPError) and exc.code == 401: + raise RuntimeError( + f"FreeToken server at {server.origin} needs a valid API key: " + f"pass --api-key or set {API_KEY_ENV}" + ) from exc raise RuntimeError(f"Cannot connect to FreeToken server at {server.origin}: {exc}") from exc if not isinstance(payload, dict): @@ -224,7 +232,7 @@ def discover_server_model(server: ServerURL) -> ServedModel: if not models: raise RuntimeError(f"FreeToken server at {server.origin} reported no models") - stats_model = _stats_model(server) + stats_model = _stats_model(server, api_key) if context_length is None: context_length = _positive_int(stats_model.get("ctx")) @@ -412,7 +420,7 @@ def prepare_codex(ctx: LaunchContext) -> CommandSpec: ] return CommandSpec( argv=argv, - env={API_KEY_ENV: "freetoken"}, + env={API_KEY_ENV: ctx.api_key or "freetoken"}, unset_env=CODEX_CLEAR_ENV, ) @@ -427,7 +435,7 @@ def prepare_claude(ctx: LaunchContext) -> CommandSpec: "CLAUDE_CODE_MAX_OUTPUT_TOKENS": str(_max_output_tokens(ctx)), "ANTHROPIC_BASE_URL": ctx.server.origin, "ANTHROPIC_API_KEY": "", - "ANTHROPIC_AUTH_TOKEN": "freetoken", + "ANTHROPIC_AUTH_TOKEN": ctx.api_key or "freetoken", "ANTHROPIC_MODEL": model_id, "ANTHROPIC_DEFAULT_OPUS_MODEL": model_id, "ANTHROPIC_DEFAULT_SONNET_MODEL": model_id, @@ -460,13 +468,16 @@ def _opencode_model_entries( def _opencode_config(ctx: LaunchContext) -> str: model_ids = _ordered_model_ids(ctx) + options = {"baseURL": ctx.server.openai_base_url} + if ctx.api_key: + options["apiKey"] = ctx.api_key config = { "$schema": "https://opencode.ai/config.json", "provider": { OPENCODE_PROVIDER: { "npm": "@ai-sdk/openai-compatible", "name": OPENCODE_PROVIDER_NAME, - "options": {"baseURL": ctx.server.openai_base_url}, + "options": options, "models": _opencode_model_entries( model_ids, _context_window(ctx), _max_output_tokens(ctx), _accepts_images(ctx) ), @@ -618,7 +629,7 @@ def _patch_openclaw_config( new_models.append(entry) provider["baseUrl"] = ctx.server.openai_base_url - provider["apiKey"] = "freetoken-local" + provider["apiKey"] = ctx.api_key or "freetoken-local" provider["api"] = "openai-completions" provider["models"] = new_models providers[OPENCLAW_PROVIDER] = provider @@ -701,8 +712,8 @@ def prepare_hermes(ctx: LaunchContext) -> CommandSpec: model_section["default"] = ctx.model.model_id model_section["provider"] = "custom" model_section["base_url"] = ctx.server.openai_base_url - # A non-empty dummy: FreeToken is unauthenticated, but some clients reject an empty key. - model_section["api_key"] = HERMES_API_KEY + # Dummy for a server without --api-key; some clients reject an empty key. + model_section["api_key"] = ctx.api_key or HERMES_API_KEY # Hermes' name for the window (prompt + generation). Left unset it auto-detects, and a # miss lands on a 32k default that trips the floor below. window = _context_window(ctx) @@ -756,9 +767,9 @@ def prepare_dsh(ctx: LaunchContext) -> CommandSpec: replays tool-call arguments byte-verbatim and reasoning as ``reasoning_content``, while llm-pi-ai's JSON round-trip can change argument values and key order — drift that survives render canonicalization, cuts - the prefix cache, and shows the model a rewrite of its own output. The - dummy DEEPSEEK_API_KEY satisfies dsh's non-empty key requirement; FreeToken - itself is unauthenticated.""" + the prefix cache, and shows the model a rewrite of its own output. Without + an API key, the dummy DEEPSEEK_API_KEY satisfies dsh's non-empty key + requirement.""" settings_path = _dsh_home() / DSH_LAUNCH_SETTINGS_NAME patch_path = _dsh_home() / DSH_LAUNCH_PATCH_NAME if not ctx.dry_run: @@ -812,7 +823,7 @@ def prepare_dsh(ctx: LaunchContext) -> CommandSpec: argv=argv, env={ "DEEPSEEK_BASE_URL": ctx.server.openai_base_url, - "DEEPSEEK_API_KEY": DSH_API_KEY, + "DEEPSEEK_API_KEY": ctx.api_key or DSH_API_KEY, "DSH_TELEMETRY_DISABLED": "1", }, unset_env=CLOUD_PROVIDER_API_KEY_ENV, @@ -844,6 +855,10 @@ def parse_argv(argv: list[str], prog: str = "python -m freetoken.launch") -> arg ) parser.add_argument("agent", choices=sorted(PREPARERS)) parser.add_argument("--server", help="FreeToken server origin or /v1 URL") + parser.add_argument( + "--api-key", + help=f"API key of a server started with --api-key (default: ${API_KEY_ENV})", + ) parser.add_argument( "--dry-run", action="store_true", @@ -1032,13 +1047,16 @@ def main(argv: list[str] | None = None, prog: str = "python -m freetoken.launch" return 0 server = resolve_server_url(args.server) - model = discover_server_model(server) + api_key = resolve_api_key(args.api_key) + model = discover_server_model(server, api_key) plan_ctx = LaunchContext( server=server, model=model, extra_args=args.extra_args, dry_run=True, assume_yes=args.yes, + # the plan is printed by --dry-run, so it never holds the real key + api_key="" if api_key else None, ) spec = PREPARERS[args.agent](plan_ctx) if args.dry_run: @@ -1056,6 +1074,7 @@ def main(argv: list[str] | None = None, prog: str = "python -m freetoken.launch" extra_args=args.extra_args, dry_run=False, assume_yes=args.yes, + api_key=api_key, ) spec = PREPARERS[args.agent](ctx) if binary_path != spec.argv[0]: diff --git a/tests/server/test_api_key.py b/tests/server/test_api_key.py index 11ed99bd71..627dba9138 100644 --- a/tests/server/test_api_key.py +++ b/tests/server/test_api_key.py @@ -2,12 +2,14 @@ from __future__ import annotations +import json from types import SimpleNamespace from unittest.mock import patch import pytest from fastapi import FastAPI from fastapi.testclient import TestClient +from freetoken import launch from freetoken.server.api_server import install_api_key, install_cors from freetoken.server.args import parse_args @@ -86,3 +88,14 @@ def test_key_stays_out_of_the_logged_config(monkeypatch): monkeypatch.delenv("FREETOKEN_API_KEY", raising=False) assert KEY not in str(_parse("--api-key", KEY)) + +@pytest.mark.parametrize("agent", sorted(launch.PREPARERS)) +def test_dry_run_prints_a_placeholder_instead_of_the_key(agent, monkeypatch, capsys): + key = 'p"w\\d\xe9' + model = launch.ServedModel(model_id="m", models=["m"], context_length=8192) + monkeypatch.setattr(launch, "discover_server_model", lambda _server, _key: model) + assert launch.main([agent, "--dry-run", "--api-key", key]) == 0 + out = capsys.readouterr().out + assert key not in out and json.dumps(key)[1:-1] not in out + if agent in ("claude", "codex", "dsh", "opencode"): + assert "" in out From f404f6caa30b889fc7ef964962570493d0e5cacc Mon Sep 17 00:00:00 2001 From: Xiaoze Fan Date: Thu, 8 Oct 2026 06:51:47 +0000 Subject: [PATCH 5/9] 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. --- python/freetoken/daemon/README.md | 11 +- python/freetoken/daemon/app.py | 23 +++- python/freetoken/daemon/client.py | 8 ++ python/freetoken/daemon/pidfile.py | 18 ++- python/freetoken/daemon/proxy.py | 18 ++- python/freetoken/daemon/serve_manager.py | 69 ++++++++--- python/freetoken/daemon/server.py | 3 +- tests/daemon/test_daemon_serve_manager.py | 138 +++++++++++++++++++++- 8 files changed, 255 insertions(+), 33 deletions(-) diff --git a/python/freetoken/daemon/README.md b/python/freetoken/daemon/README.md index ea3b016eaf..ebe4a17008 100644 --- a/python/freetoken/daemon/README.md +++ b/python/freetoken/daemon/README.md @@ -60,9 +60,9 @@ Target a non-default daemon with `--url http://host:1900` (or `$FREETOKEN_DAEMON | Method / path | Notes | | --- | --- | | `GET /health` | Daemon self-health; always answers, never gated by `--token`. | -| `POST /engine/start` `{model,port,args[]}` | Idempotent on the full `(model,port,args)`; a differing config on the same port → `409`. | +| `POST /engine/start` `{model,port,args[],apiKey?}` | Idempotent on the full `(model,port,args,apiKey)`; a differing config on the same port → `409`. | | `POST /engine/stop` `{force?:false}` | Close admission, drain/abort, durably enqueue the final-accounting receipt, then `SIGTERM`→grace→`SIGKILL`. A prepare/outbox failure preserves the engine. | -| `POST /engine/switch` `{model,port,args[],force?:false}` | One serialized stop-accounting-start transaction. | +| `POST /engine/switch` `{model,port,args[],apiKey?,force?:false}` | One serialized stop-accounting-start transaction. | | `GET /engine/status` | `{running,pid,model,port,uptimeS,lastExitCode,…}`; outlives any single serve. | | `GET /engine/logs?since=` | SSE, ANSI-stripped, tqdm-`\r` collapsed, ring replay, `id:`, `Last-Event-ID` resume. | | `GET /engine/metrics` | `{ramBytes,vramBytes}` — the serve tree's own footprint only. | @@ -75,6 +75,13 @@ Target a non-default daemon with `--url http://host:1900` (or `$FREETOKEN_DAEMON Set `--token` (or `$FREETOKEN_DAEMON_TOKEN`) to require an `X-FT-Token` header on everything except `/health`. +`apiKey` is the serve's own `--api-key`. The daemon hands 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 +the owner-only state file so a re-adopted serve stays reachable. The daemon's own +`FREETOKEN_API_KEY` is never passed down: a serve requires a key only when its start request carries +one, and an `--api-key` inside `args` is rejected with a 400 because the daemon would not know it. +`ft daemon start|switch --api-key KEY` (default `$FREETOKEN_API_KEY`) sets it. + The daemon reaches the serve's destructive `POST /v1/admin/prepare-stop` endpoint only over loopback; the serve rejects non-loopback callers even when its inference API is bound to `0.0.0.0`. `force` is never implicit: it is an explicit recovery choice that may record null diff --git a/python/freetoken/daemon/app.py b/python/freetoken/daemon/app.py index d7a0a53c60..ce7802f448 100644 --- a/python/freetoken/daemon/app.py +++ b/python/freetoken/daemon/app.py @@ -30,6 +30,8 @@ class StartBody(BaseModel): model: str port: int | None = None args: list[str] = [] + # Handed to the serve as FREETOKEN_API_KEY, never on its command line; absent = no auth. + apiKey: str | None = None class StopBody(BaseModel): @@ -161,6 +163,15 @@ def resolve_port(explicit: int | None) -> int: st = manager.status() return st.get("port") or default_serve_port + def reject_api_key_arg(args: list[str]) -> None: + # the daemon must know the key to reach the serve, so the key only travels as apiKey + # argparse also accepts any unambiguous prefix, and no other serve flag starts with --ap + flags = (a.split("=", 1)[0] for a in args) + if any(len(f) >= 4 and "--api-key".startswith(f) for f in flags): + raise HTTPException( + status_code=400, detail="pass the serve's API key as apiKey, not in args" + ) + def accounting_error(exc: Exception) -> JSONResponse: code = ( "accounting_outbox_failed" @@ -192,9 +203,17 @@ async def health(): @app.post("/engine/start", dependencies=auth) async def engine_start(body: StartBody): + reject_api_key_arg(body.args) port = resolve_port(body.port) try: - return await run(lifecycle_pool, manager.start, body.model, port, list(body.args)) + return await run( + lifecycle_pool, + manager.start, + body.model, + port, + list(body.args), + body.apiKey or None, + ) except Conflict as exc: st = manager.status() return JSONResponse( @@ -236,6 +255,7 @@ async def shutdown_daemon(request: Request, body: StopBody | None = None): @app.post("/engine/switch", dependencies=auth) async def engine_switch(body: SwitchBody): + reject_api_key_arg(body.args) port = resolve_port(body.port) try: return await run( @@ -245,6 +265,7 @@ async def engine_switch(body: SwitchBody): port, list(body.args), body.force, + body.apiKey or None, ) except (AccountingPrepareError, AccountingOutboxError) as exc: return accounting_error(exc) diff --git a/python/freetoken/daemon/client.py b/python/freetoken/daemon/client.py index 6616778e72..9ded96276e 100644 --- a/python/freetoken/daemon/client.py +++ b/python/freetoken/daemon/client.py @@ -16,6 +16,8 @@ from collections.abc import Sequence from typing import Any +from freetoken.launch import resolve_api_key + DEFAULT_URL = "http://127.0.0.1:1900" DEFAULT_TIMEOUT = 10.0 # prepare-stop (15s transport budget) + default SIGTERM grace (10s) + reap wait (10s), @@ -144,6 +146,10 @@ def _build_parser(prog: str) -> argparse.ArgumentParser: sp = sub.add_parser(name, parents=[common], help=f"POST /engine/{name}") sp.add_argument("model", help="Model path/id") sp.add_argument("--port", type=int, default=None, help="Serve port") + sp.add_argument( + "--api-key", + help="API key the serve requires, set in its environment (default: $FREETOKEN_API_KEY)", + ) if name == "switch": sp.add_argument( "--force", @@ -181,6 +187,8 @@ def main(argv: Sequence[str] | None = None, *, prog: str = "ft daemon") -> int: body: dict[str, Any] = {"model": args.model, "args": list(args.serve_args)} if args.port is not None: body["port"] = args.port + if api_key := resolve_api_key(args.api_key): + body["apiKey"] = api_key if args.verb == "switch" and args.force: body["force"] = True method, path = "POST", f"/engine/{args.verb}" diff --git a/python/freetoken/daemon/pidfile.py b/python/freetoken/daemon/pidfile.py index 89ffc2b4df..c015ebf773 100644 --- a/python/freetoken/daemon/pidfile.py +++ b/python/freetoken/daemon/pidfile.py @@ -4,15 +4,16 @@ open for the daemon's whole life. flock (not a bare port-bind) avoids TIME_WAIT races and is released automatically if the daemon dies, so a crashed daemon never wedges its own restart. -``ServeStateStore`` persists ``{model, port, pid, args, starttime, logPath}`` as JSON on every -lifecycle change, so a restarted daemon can re-adopt a still-running serve. ``starttime`` + -``args`` are what make re-adoption PID-reuse-safe and config-exact.""" +``ServeStateStore`` persists ``{model, port, pid, args, starttime, log_path, api_key}`` as JSON on +every lifecycle change, so a restarted daemon can re-adopt a still-running serve. ``starttime`` + +``args`` are what make re-adoption PID-reuse-safe and config-exact. The file is owner-only +because it holds the serve's API key.""" from __future__ import annotations import json import os -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, field class AlreadyRunning(RuntimeError): @@ -63,6 +64,7 @@ class ServeState: args: list[str] starttime: int | None = None log_path: str | None = None + api_key: str | None = field(default=None, repr=False) class ServeStateStore: @@ -72,7 +74,12 @@ def __init__(self, path: str) -> None: def save(self, state: ServeState) -> None: os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True) tmp = f"{self.path}.tmp" - with open(tmp, "w") as fh: + # unlink first: O_CREAT applies the 0o600 mode only to a file it creates + try: + os.remove(tmp) + except FileNotFoundError: + pass + with os.fdopen(os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w") as fh: json.dump(asdict(state), fh) fh.flush() os.fsync(fh.fileno()) @@ -102,6 +109,7 @@ def load(self) -> ServeState | None: args=list(doc.get("args") or []), starttime=doc.get("starttime"), log_path=doc.get("log_path"), + api_key=doc.get("api_key"), ) except (KeyError, TypeError, ValueError): return None diff --git a/python/freetoken/daemon/proxy.py b/python/freetoken/daemon/proxy.py index 9e382a4d72..c4f0ba2864 100644 --- a/python/freetoken/daemon/proxy.py +++ b/python/freetoken/daemon/proxy.py @@ -49,6 +49,7 @@ def __init__( now: Callable[[], float] = time.monotonic, opener: Callable[[str, float], dict] | None = None, prepare_opener: Callable[[str, float], dict] | None = None, + api_key: Callable[[], str | None] = lambda: None, ) -> None: self._host = host self._ttl = ttl_s @@ -57,6 +58,7 @@ def __init__( self._now = now self._opener = opener or self._urlopen self._prepare_opener = prepare_opener or self._urlopen_prepare + self._api_key = api_key self._lock = threading.Lock() self._cache: dict[tuple[str, int], tuple[float, dict]] = {} @@ -109,19 +111,23 @@ def _fetch(self, path: str, port: int) -> dict: result["reachable"] = True return result - @staticmethod - def _urlopen(url: str, timeout: float) -> dict: - req = urllib.request.Request(url, headers={"Accept": "application/json"}, method="GET") + def _headers(self) -> dict[str, str]: + headers = {"Accept": "application/json"} + if key := self._api_key(): + headers["Authorization"] = f"Bearer {key}" + return headers + + def _urlopen(self, url: str, timeout: float) -> dict: + req = urllib.request.Request(url, headers=self._headers(), method="GET") with urllib.request.urlopen(req, timeout=timeout) as resp: raw = resp.read() return json.loads(raw.decode("utf-8")) - @staticmethod - def _urlopen_prepare(url: str, timeout: float) -> dict: + def _urlopen_prepare(self, url: str, timeout: float) -> dict: req = urllib.request.Request( url, data=b"{}", - headers={"Accept": "application/json", "Content-Type": "application/json"}, + headers={**self._headers(), "Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(req, timeout=timeout) as resp: diff --git a/python/freetoken/daemon/serve_manager.py b/python/freetoken/daemon/serve_manager.py index 681bdef497..86c5e2c671 100644 --- a/python/freetoken/daemon/serve_manager.py +++ b/python/freetoken/daemon/serve_manager.py @@ -26,6 +26,8 @@ from dataclasses import dataclass from typing import Any, Callable +from freetoken.launch import API_KEY_ENV + from . import osproc from .accounting import ( AccountingOutbox, @@ -37,7 +39,7 @@ class Conflict(RuntimeError): - """A different serve (model/port/args) is already running; the client should switch().""" + """A different serve (model/port/args/key) is already running; the client should switch().""" @dataclass @@ -128,7 +130,15 @@ def build_serve_command( return argv, log_path -def spawn_serve(argv: list[str], log_path: str) -> PopenChild: +def serve_env(api_key: str | None) -> dict[str, str]: + """The serve's environment: the daemon's own, with the API key set only by the start request.""" + env = {k: v for k, v in os.environ.items() if k != API_KEY_ENV} + if api_key: + env[API_KEY_ENV] = api_key + return env + + +def spawn_serve(argv: list[str], log_path: str, api_key: str | None = None) -> PopenChild: """Spawn the serve as a session leader with its stdout+stderr going to a real logfile fd (never a PIPE): the file survives daemon death, so a re-adopting daemon just resumes tailing it and the serve never writes to a dead pipe. ``start_new_session`` makes it a process @@ -138,6 +148,7 @@ def spawn_serve(argv: list[str], log_path: str) -> PopenChild: try: proc = subprocess.Popen( argv, + env=serve_env(api_key), stdout=logf, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, @@ -158,7 +169,7 @@ def __init__( ring, state_store, *, - spawn_fn: Callable[[str, int, list[str]], object] | None = None, + spawn_fn: Callable[[str, int, list[str], str | None], object] | None = None, adopt_fn: Callable[[object], object] | None = None, tailer_factory: Callable[[object], object] | None = None, signal_fn: Callable[[int, int], None] | None = None, @@ -217,6 +228,7 @@ def __init__( self._model: str | None = None self._port: int | None = None self._args: list[str] = [] + self._api_key: str | None = None self._started_at: float | None = None self._last_exit: ExitInfo | None = None self._adopted = False @@ -232,11 +244,11 @@ def __init__( # ---- default factories (overridable for tests) ---- - def _default_spawn(self, model: str, port: int, args: list[str]): + def _default_spawn(self, model: str, port: int, args: list[str], api_key: str | None): argv, log_path = build_serve_command( model, port, args, python=self._python, log_dir=self._log_dir ) - return spawn_serve(argv, log_path) + return spawn_serve(argv, log_path, api_key) def _default_adopt(self, state): if not osproc.is_ft_serve_on_port(state.pid, state.port, starttime=state.starttime): @@ -256,13 +268,25 @@ def _default_adopt(self, state): # ---- lifecycle ---- def start( - self, model: str, port: int, args: list[str] | None = None, *, _auto: bool = False + self, + model: str, + port: int, + args: list[str] | None = None, + api_key: str | None = None, + *, + _auto: bool = False, ) -> dict: with self._lifecycle: - return self._start(model, port, args, _auto=_auto) + return self._start(model, port, args, api_key, _auto=_auto) def _start( - self, model: str, port: int, args: list[str] | None = None, *, _auto: bool = False + self, + model: str, + port: int, + args: list[str] | None = None, + api_key: str | None = None, + *, + _auto: bool = False, ) -> dict: args = list(args or []) with self._cond: @@ -278,10 +302,13 @@ def _start( self._stop_requested = False # an explicit client start clears a prior stop intent while True: if self._child is not None and not self._stopping: - if (self._model, self._port, self._args) == (model, port, args): + if (self._model, self._port, self._args, self._api_key) == ( + model, port, args, api_key + ): return {"pid": self._child.pid, "idempotent": True} raise Conflict( - f"serve already running (model={self._model!r} port={self._port}); " + f"serve already running with a different config " + f"(model={self._model!r} port={self._port}); " f"use switch to replace it" ) if self._starting: @@ -299,7 +326,7 @@ def _start( child = None try: - child = self._spawn_fn(model, port, args) + child = self._spawn_fn(model, port, args, api_key) finally: if child is None: with self._cond: @@ -311,6 +338,7 @@ def _start( self._model = model self._port = port self._args = args + self._api_key = api_key self._started_at = self._now() self._last_exit = None self._adopted = getattr(child, "adopted", False) @@ -318,7 +346,7 @@ def _start( self._starting = False self._cond.notify_all() - self._persist(child, model, port, args) + self._persist(child, model, port, args, api_key) self._maybe_apply_oom(child.pid) self._begin_watch(child) self._emit(f"serve started (pid={child.pid} model={model} port={port})") @@ -407,10 +435,11 @@ def switch( port: int, args: list[str] | None = None, force: bool = False, + api_key: str | None = None, ) -> dict: with self._lifecycle: stopped = self._stop(force=force) - started = self._start(model, port, args) + started = self._start(model, port, args, api_key) return {**started, "accounting": stopped["accounting"]} def pending_accounting(self) -> list[dict[str, Any]]: @@ -734,6 +763,7 @@ def readopt(self) -> bool: self._model = state.model self._port = state.port self._args = list(state.args) + self._api_key = state.api_key self._started_at = self._now() # unknown true start; report uptime since adoption self._last_exit = None self._adopted = True @@ -824,13 +854,13 @@ def _exit_line(self, child, info: ExitInfo, was_stopping: bool) -> str: return f"serve exited with code {info.code} (pid={child.pid})" def _restart_async(self, dead_child) -> None: - model, port, args = self._model, self._port, list(self._args) + model, port, args, api_key = self._model, self._port, list(self._args), self._api_key if model is None or port is None: return def _run(): try: - self.start(model, port, args, _auto=True) + self.start(model, port, args, api_key, _auto=True) except Exception as exc: # noqa: BLE001 self._emit(f"auto-restart failed: {exc}") @@ -867,6 +897,10 @@ def serve_args(self) -> list[str]: with self._cond: return list(self._args) + def serve_api_key(self) -> str | None: + with self._cond: + return self._api_key + def status(self) -> dict: with self._cond: child = self._child @@ -892,7 +926,9 @@ def status(self) -> dict: # ---- helpers ---- - def _persist(self, child, model: str, port: int, args: list[str]) -> None: + def _persist( + self, child, model: str, port: int, args: list[str], api_key: str | None + ) -> None: from .pidfile import ServeState self._store.save( @@ -903,6 +939,7 @@ def _persist(self, child, model: str, port: int, args: list[str]) -> None: args=list(args), starttime=getattr(child, "starttime", None), log_path=getattr(child, "log_path", None), + api_key=api_key, ) ) diff --git a/python/freetoken/daemon/server.py b/python/freetoken/daemon/server.py index d4e742b0be..76e7e789c9 100644 --- a/python/freetoken/daemon/server.py +++ b/python/freetoken/daemon/server.py @@ -132,7 +132,8 @@ def main(argv: Sequence[str] | None = None, *, prog: str = "ft daemon") -> int: ring = LogRing(capacity=args.log_capacity) store = ServeStateStore(os.path.join(state_dir, "serve.json")) - probe = ServeProbe() + # read at call time: the running serve's key, from the manager created below + probe = ServeProbe(api_key=lambda: manager.serve_api_key()) def tailer_factory(child): log_path = getattr(child, "log_path", None) diff --git a/tests/daemon/test_daemon_serve_manager.py b/tests/daemon/test_daemon_serve_manager.py index fd9956ebba..04213cd546 100644 --- a/tests/daemon/test_daemon_serve_manager.py +++ b/tests/daemon/test_daemon_serve_manager.py @@ -1,19 +1,25 @@ from __future__ import annotations +import os import signal +import sys import threading import time +from concurrent.futures import ThreadPoolExecutor import pytest +from fastapi.testclient import TestClient +from freetoken.daemon import proxy from freetoken.daemon.accounting import ( AccountingOutbox, AccountingOutboxError, AccountingPrepareError, ) +from freetoken.daemon.app import build_app from freetoken.daemon.logring import LogRing from freetoken.daemon.pidfile import ServeState, ServeStateStore -from freetoken.daemon.serve_manager import Conflict, ExitInfo, ServeManager +from freetoken.daemon.serve_manager import Conflict, ExitInfo, ServeManager, spawn_serve # --------------------------------------------------------------------------- test doubles @@ -50,11 +56,13 @@ class Spawner: def __init__(self): self.children: list[FakeChild] = [] self.calls: list[tuple] = [] + self.api_keys: list[str | None] = [] self._pid = 1000 self.gate: threading.Event | None = None - def __call__(self, model, port, args) -> FakeChild: + def __call__(self, model, port, args, api_key=None) -> FakeChild: self.calls.append((model, port, list(args))) + self.api_keys.append(api_key) if self.gate is not None: self.gate.wait() self._pid += 1 @@ -743,6 +751,132 @@ def test_readopt_no_state_is_noop(tmp_path): assert mgr.readopt() is False +# --------------------------------------------------------------------------- API key + + +def test_api_key_is_serve_config_kept_owner_only_and_readopted(tmp_path): + sp = Spawner() + mgr, store, _ = make_manager(tmp_path, sp) + # an older daemon left a world-readable state file and a stale tmp behind + for path in (store.path, f"{store.path}.tmp"): + with open(path, "w") as fh: + fh.write("{}") + os.chmod(path, 0o644) + mgr.start("m", 1919, [], "k1") + assert sp.api_keys == ["k1"] and mgr.serve_api_key() == "k1" + assert mgr.start("m", 1919, [], "k1")["idempotent"] is True + with pytest.raises(Conflict): + mgr.start("m", 1919, [], "k2") + assert store.load().api_key == "k1" + assert os.stat(store.path).st_mode & 0o777 == 0o600 + + adopted = FakeChild(4242, adopted=True) + fresh = ServeManager( + LogRing(), store, spawn_fn=Spawner(), adopt_fn=lambda state: adopted, tailer_factory=None, + signal_fn=lambda p, s: None, apply_oom=False, + ) + assert fresh.readopt() is True and fresh.serve_api_key() == "k1" + adopted.die() + sp.children[0].die() + + +def test_switch_replaces_the_api_key(tmp_path): + sp = Spawner() + + def sig(pid, s): + if s == signal.SIGTERM: + sp.by_pid(pid).die(0) + + mgr, store, _ = make_manager(tmp_path, sp, signal_fn=sig) + mgr.start("m", 1919, [], "k1") + mgr.switch("m", 1919, [], False, "k2") + assert sp.api_keys == ["k1", "k2"] + assert mgr.serve_api_key() == "k2" and store.load().api_key == "k2" + sp.children[1].die() + + +def test_auto_restart_keeps_the_api_key(tmp_path): + sp = Spawner() + mgr, _, _ = make_manager(tmp_path, sp, auto_restart=True) + mgr.start("m", 1919, [], "k1") + sp.children[0].die(1) + assert wait_until(lambda: len(sp.children) == 2) + assert sp.api_keys == ["k1", "k1"] + sp.children[1].die() + + +def test_spawned_serve_sees_only_the_requested_key(tmp_path, monkeypatch): + monkeypatch.setenv("FREETOKEN_API_KEY", "from-daemon-env") + code = "import os; print(os.environ.get('FREETOKEN_API_KEY'))" + for api_key in (None, "k1"): + log = tmp_path / f"{api_key}.log" + child = spawn_serve([sys.executable, "-c", code], str(log), api_key) + child.wait() + child.close() + assert log.read_text().strip() == str(api_key) + + +def test_probe_sends_the_running_serves_key(monkeypatch): + seen = [] + + class Response: + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def read(self): + return b"{}" + + def fake_urlopen(req, timeout): + seen.append(req.get_header("Authorization")) + return Response() + + monkeypatch.setattr(proxy.urllib.request, "urlopen", fake_urlopen) + key = None + probe = proxy.ServeProbe(api_key=lambda: key) + probe.fresh_stats(1919) + key = "k1" + probe.fresh_stats(1919) + probe.prepare_stop(1919) + assert seen == [None, "Bearer k1", "Bearer k1"] + + +def test_start_and_switch_routes_hand_the_api_key_to_the_manager(): + calls = [] + + class Manager: + def status(self): + return {"running": False} + + def start(self, *args): + calls.append(args) + return {"pid": 1, "idempotent": False} + + def switch(self, *args): + calls.append(args) + return {"pid": 1, "idempotent": False, "accounting": None} + + with ThreadPoolExecutor(1) as lifecycle_pool, ThreadPoolExecutor(1) as proxy_pool: + app = build_app( + manager=Manager(), ring=LogRing(), probe=None, footprint_fn=lambda pid: {}, + lifecycle_pool=lifecycle_pool, proxy_pool=proxy_pool, + ) + with TestClient(app) as client: + client.post("/engine/start", json={"model": "m", "port": 1919, "apiKey": "k1"}) + client.post("/engine/start", json={"model": "m", "port": 1919, "apiKey": ""}) + client.post("/engine/start", json={"model": "m", "port": 1919}) + client.post("/engine/switch", json={"model": "m", "port": 1919, "apiKey": "k2"}) + # argparse also takes an unambiguous prefix of --api-key + for args in (["--api-key", "k3"], ["--api-key=k3"], ["--api", "k3"], ["--ap=k3"]): + rejected = client.post("/engine/start", json={"model": "m", "args": args}) + assert rejected.status_code == 400 + allowed = client.post("/engine/start", json={"model": "m", "args": ["--attn", "fa"]}) + assert allowed.status_code == 200 + assert [args[-1] for args in calls] == ["k1", None, None, "k2", None] + + # --------------------------------------------------------------------------- resource leak (L2) From 71dd98ac8368d58089084d68c832df2d5bee06b0 Mon Sep 17 00:00:00 2001 From: Xiaoze Fan Date: Fri, 9 Oct 2026 08:33:48 +0000 Subject: [PATCH 6/9] 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. --- python/freetoken/launch.py | 28 +++++++++++++++------------- tests/server/test_api_key.py | 17 +++++++++++++++++ 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/python/freetoken/launch.py b/python/freetoken/launch.py index fc7dcedfb4..5c31442f26 100644 --- a/python/freetoken/launch.py +++ b/python/freetoken/launch.py @@ -767,9 +767,8 @@ def prepare_dsh(ctx: LaunchContext) -> CommandSpec: replays tool-call arguments byte-verbatim and reasoning as ``reasoning_content``, while llm-pi-ai's JSON round-trip can change argument values and key order — drift that survives render canonicalization, cuts - the prefix cache, and shows the model a rewrite of its own output. Without - an API key, the dummy DEEPSEEK_API_KEY satisfies dsh's non-empty key - requirement.""" + the prefix cache, and shows the model a rewrite of its own output. The + dummy DEEPSEEK_API_KEY satisfies dsh's non-empty key requirement.""" settings_path = _dsh_home() / DSH_LAUNCH_SETTINGS_NAME patch_path = _dsh_home() / DSH_LAUNCH_PATCH_NAME if not ctx.dry_run: @@ -789,7 +788,7 @@ def prepare_dsh(ctx: LaunchContext) -> CommandSpec: window, output = _context_window(ctx), _max_output_tokens(ctx) modalities = ["text", "image"] if _accepts_images(ctx) else ["text"] - config["llm-deepseek"] = { + section: dict[str, object] = { "baseURL": ctx.server.openai_base_url, "models": [ { @@ -802,6 +801,10 @@ def prepare_dsh(ctx: LaunchContext) -> CommandSpec: for model_id in _ordered_model_ids(ctx) ], } + if ctx.api_key: + # not DEEPSEEK_API_KEY: dsh's web search also reads it and sends it to api.deepseek.com + section["apiKeyEnv"] = API_KEY_ENV + config["llm-deepseek"] = section config["agent-default-model"] = { "provider": "deepseek-official", "model": ctx.model.model_id, @@ -819,15 +822,14 @@ def prepare_dsh(ctx: LaunchContext) -> CommandSpec: elif len(app_args) >= 2 and app_args[0] == "--profile": profile, app_args = app_args[1], app_args[2:] argv = ["dsh", "--profile", profile, "--patch", str(patch_path), *app_args] - return CommandSpec( - argv=argv, - env={ - "DEEPSEEK_BASE_URL": ctx.server.openai_base_url, - "DEEPSEEK_API_KEY": ctx.api_key or DSH_API_KEY, - "DSH_TELEMETRY_DISABLED": "1", - }, - unset_env=CLOUD_PROVIDER_API_KEY_ENV, - ) + env = { + "DEEPSEEK_BASE_URL": ctx.server.openai_base_url, + "DEEPSEEK_API_KEY": DSH_API_KEY, + "DSH_TELEMETRY_DISABLED": "1", + } + if ctx.api_key: + env[API_KEY_ENV] = ctx.api_key + return CommandSpec(argv=argv, env=env, unset_env=CLOUD_PROVIDER_API_KEY_ENV) PREPARERS = { diff --git a/tests/server/test_api_key.py b/tests/server/test_api_key.py index 627dba9138..612827704d 100644 --- a/tests/server/test_api_key.py +++ b/tests/server/test_api_key.py @@ -7,6 +7,7 @@ from unittest.mock import patch import pytest +import yaml from fastapi import FastAPI from fastapi.testclient import TestClient from freetoken import launch @@ -99,3 +100,19 @@ def test_dry_run_prints_a_placeholder_instead_of_the_key(agent, monkeypatch, cap assert key not in out and json.dumps(key)[1:-1] not in out if agent in ("claude", "codex", "dsh", "opencode"): assert "" in out + + +def test_dsh_keeps_the_key_out_of_the_variable_its_web_search_reads(monkeypatch, tmp_path): + monkeypatch.setenv("DSH_HOME", str(tmp_path)) + monkeypatch.setattr(launch, "_warn_dsh_node_version", lambda: None) + ctx = launch.LaunchContext( + server=launch.resolve_server_url("http://127.0.0.1:1919"), + model=launch.ServedModel(model_id="m", models=["m"], context_length=8192), + extra_args=[], + dry_run=False, + api_key=KEY, + ) + env = launch.prepare_dsh(ctx).env + settings = yaml.safe_load((tmp_path / launch.DSH_LAUNCH_SETTINGS_NAME).read_text()) + assert env["DEEPSEEK_API_KEY"] != KEY + assert env[settings["llm-deepseek"]["apiKeyEnv"]] == KEY From d18a3d9112e8c9fda09a5b7168f12a71d3798ad5 Mon Sep 17 00:00:00 2001 From: Xiaoze Fan Date: Sat, 10 Oct 2026 05:46:49 +0000 Subject: [PATCH 7/9] fix(server): check the API key on OPTIONS requests --- python/freetoken/server/api_server.py | 12 +++++------- tests/server/test_api_key.py | 5 +++++ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/python/freetoken/server/api_server.py b/python/freetoken/server/api_server.py index 0e6dd1c499..ba5263bf2c 100644 --- a/python/freetoken/server/api_server.py +++ b/python/freetoken/server/api_server.py @@ -421,7 +421,7 @@ def install_cors(app: FastAPI, origins_csv: str) -> None: class _ApiKeyMiddleware: - """Answer 401 unless the request carries the key, except /health and OPTIONS requests.""" + """Answer 401 unless the request carries the key, except /health.""" def __init__(self, app, api_key: str) -> None: self.app = app @@ -434,11 +434,7 @@ def _matches(self, value: str | None) -> bool: ) async def __call__(self, scope, receive, send) -> None: - if ( - scope["type"] not in ("http", "websocket") - or scope.get("method") == "OPTIONS" - or scope["path"] == "/health" - ): + if scope["type"] not in ("http", "websocket") or scope["path"] == "/health": return await self.app(scope, receive, send) headers = Headers(scope=scope) scheme, _, token = headers.get("authorization", "").partition(" ") @@ -450,7 +446,9 @@ async def __call__(self, scope, receive, send) -> None: def install_api_key(app: FastAPI, api_key: str | None) -> None: - """Require ``api_key`` on every route but /health; run before install_cors so 401s get CORS headers.""" + """Require ``api_key`` on every route but /health. + + Run it before install_cors, so CORS answers preflights and adds its headers to 401s.""" if api_key is None: return app.add_middleware(_ApiKeyMiddleware, api_key=api_key) diff --git a/tests/server/test_api_key.py b/tests/server/test_api_key.py index 612827704d..5b6fe33dd1 100644 --- a/tests/server/test_api_key.py +++ b/tests/server/test_api_key.py @@ -73,6 +73,10 @@ def test_cors_preflight_passes_and_401_carries_cors_headers(): assert denied.headers["access-control-allow-origin"] == ORIGIN +def test_options_outside_a_cors_preflight_needs_the_key(): + assert _client(KEY).options("/v1/models").status_code == 401 + + def test_flag_wins_over_env_and_empty_values_do_not_enable_auth(monkeypatch): monkeypatch.delenv("FREETOKEN_API_KEY", raising=False) assert _parse().api_key is None @@ -116,3 +120,4 @@ def test_dsh_keeps_the_key_out_of_the_variable_its_web_search_reads(monkeypatch, settings = yaml.safe_load((tmp_path / launch.DSH_LAUNCH_SETTINGS_NAME).read_text()) assert env["DEEPSEEK_API_KEY"] != KEY assert env[settings["llm-deepseek"]["apiKeyEnv"]] == KEY + From 5a1d4eeec1f781177e456fbb0fab56b094f1d169 Mon Sep 17 00:00:00 2001 From: Xiaoze Fan Date: Sat, 10 Oct 2026 05:46:49 +0000 Subject: [PATCH 8/9] fix(launch): write agent configs that hold the API key owner-only --- python/freetoken/launch.py | 24 ++++++++++++++++++------ tests/server/test_api_key.py | 23 +++++++++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/python/freetoken/launch.py b/python/freetoken/launch.py index 5c31442f26..7fbc4eb4d6 100644 --- a/python/freetoken/launch.py +++ b/python/freetoken/launch.py @@ -5,6 +5,7 @@ import os import shlex import shutil +import stat import subprocess import sys import time @@ -248,15 +249,22 @@ def _backup_path(path: Path) -> Path: return path.with_name(f"{path.name}.{time.time_ns()}.bak") -def _write_text_with_backup(path: Path, text: str) -> None: +def _write_text_with_backup(path: Path, text: str, *, private: bool = False) -> None: path.parent.mkdir(parents=True, exist_ok=True) if path.exists() and path.read_text() != text: - shutil.copy2(path, _backup_path(path)) + backup = _backup_path(path) + # copy2 sets the mode only after copying the content, so create the backup with it first + backup.touch(mode=stat.S_IMODE(path.stat().st_mode)) + shutil.copy2(path, backup) + if private: + # before the write, so the key never sits in a file others can read + path.touch(mode=0o600) + path.chmod(0o600) path.write_text(text) -def _write_json_with_backup(path: Path, value: object) -> None: - _write_text_with_backup(path, json.dumps(value, indent=2) + "\n") +def _write_json_with_backup(path: Path, value: object, *, private: bool = False) -> None: + _write_text_with_backup(path, json.dumps(value, indent=2) + "\n", private=private) def _toml_string(value: str) -> str: @@ -676,7 +684,9 @@ def prepare_openclaw(ctx: LaunchContext) -> CommandSpec: if not _openclaw_has_freetoken_provider(config): if not ctx.assume_yes and not _confirm_openclaw_first_patch(ctx, config_path): raise RuntimeError("OpenClaw config patch cancelled") - _write_json_with_backup(config_path, _patch_openclaw_config(ctx, config)) + _write_json_with_backup( + config_path, _patch_openclaw_config(ctx, config), private=bool(ctx.api_key) + ) _clear_openclaw_session_overrides(ctx.model.model_id) argv = ["openclaw", *ctx.extra_args] if ctx.extra_args else ["openclaw", "chat"] @@ -727,7 +737,9 @@ def prepare_hermes(ctx: LaunchContext) -> CommandSpec: file=sys.stderr, ) - _write_text_with_backup(config_path, yaml.safe_dump(config, sort_keys=False)) + _write_text_with_backup( + config_path, yaml.safe_dump(config, sort_keys=False), private=bool(ctx.api_key) + ) argv = ["hermes", *ctx.extra_args] if ctx.extra_args else ["hermes", "chat"] return CommandSpec(argv=argv, env={}) diff --git a/tests/server/test_api_key.py b/tests/server/test_api_key.py index 5b6fe33dd1..a655c68831 100644 --- a/tests/server/test_api_key.py +++ b/tests/server/test_api_key.py @@ -3,6 +3,8 @@ from __future__ import annotations import json +import os +import stat from types import SimpleNamespace from unittest.mock import patch @@ -121,3 +123,24 @@ def test_dsh_keeps_the_key_out_of_the_variable_its_web_search_reads(monkeypatch, assert env["DEEPSEEK_API_KEY"] != KEY assert env[settings["llm-deepseek"]["apiKeyEnv"]] == KEY + +@pytest.mark.parametrize("agent", ["hermes", "openclaw"]) +def test_config_files_holding_the_key_are_owner_only(agent, monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + old_umask = os.umask(0o022) + try: + for key in ("sk-first", "sk-second"): + ctx = launch.LaunchContext( + server=launch.resolve_server_url("http://127.0.0.1:1919"), + model=launch.ServedModel(model_id="m", models=["m"], context_length=65536), + extra_args=[], + dry_run=False, + assume_yes=True, + api_key=key, + ) + launch.PREPARERS[agent](ctx) + finally: + os.umask(old_umask) + written = [p for p in tmp_path.rglob("*") if p.is_file() and "sk-" in p.read_text()] + assert len(written) == 2 + assert all(stat.S_IMODE(p.stat().st_mode) == 0o600 for p in written) From 006cb32d931065e09710ccef339b343e0c18492a Mon Sep 17 00:00:00 2001 From: Xiaoze Fan Date: Sat, 10 Oct 2026 06:46:26 +0000 Subject: [PATCH 9/9] chore: drop comments that restate the code --- python/freetoken/daemon/app.py | 1 - python/freetoken/daemon/pidfile.py | 8 ++++---- python/freetoken/daemon/serve_manager.py | 1 - python/freetoken/launch.py | 2 +- python/freetoken/server/api_server.py | 2 -- python/freetoken/server/args.py | 2 +- tests/daemon/test_daemon_serve_manager.py | 1 - 7 files changed, 6 insertions(+), 11 deletions(-) diff --git a/python/freetoken/daemon/app.py b/python/freetoken/daemon/app.py index ce7802f448..dd506f78fc 100644 --- a/python/freetoken/daemon/app.py +++ b/python/freetoken/daemon/app.py @@ -30,7 +30,6 @@ class StartBody(BaseModel): model: str port: int | None = None args: list[str] = [] - # Handed to the serve as FREETOKEN_API_KEY, never on its command line; absent = no auth. apiKey: str | None = None diff --git a/python/freetoken/daemon/pidfile.py b/python/freetoken/daemon/pidfile.py index c015ebf773..b9865a83d3 100644 --- a/python/freetoken/daemon/pidfile.py +++ b/python/freetoken/daemon/pidfile.py @@ -4,10 +4,10 @@ open for the daemon's whole life. flock (not a bare port-bind) avoids TIME_WAIT races and is released automatically if the daemon dies, so a crashed daemon never wedges its own restart. -``ServeStateStore`` persists ``{model, port, pid, args, starttime, log_path, api_key}`` as JSON on -every lifecycle change, so a restarted daemon can re-adopt a still-running serve. ``starttime`` + -``args`` are what make re-adoption PID-reuse-safe and config-exact. The file is owner-only -because it holds the serve's API key.""" +``ServeStateStore`` persists ``{model, port, pid, args, starttime, log_path, api_key}`` as JSON on every +lifecycle change, so a restarted daemon can re-adopt a still-running serve. ``starttime`` + +``args`` are what make re-adoption PID-reuse-safe and config-exact. +The file is owner-only because it holds the serve's API key.""" from __future__ import annotations diff --git a/python/freetoken/daemon/serve_manager.py b/python/freetoken/daemon/serve_manager.py index 86c5e2c671..9d5e51fe71 100644 --- a/python/freetoken/daemon/serve_manager.py +++ b/python/freetoken/daemon/serve_manager.py @@ -131,7 +131,6 @@ def build_serve_command( def serve_env(api_key: str | None) -> dict[str, str]: - """The serve's environment: the daemon's own, with the API key set only by the start request.""" env = {k: v for k, v in os.environ.items() if k != API_KEY_ENV} if api_key: env[API_KEY_ENV] = api_key diff --git a/python/freetoken/launch.py b/python/freetoken/launch.py index 7fbc4eb4d6..e3c42154ab 100644 --- a/python/freetoken/launch.py +++ b/python/freetoken/launch.py @@ -253,7 +253,7 @@ def _write_text_with_backup(path: Path, text: str, *, private: bool = False) -> path.parent.mkdir(parents=True, exist_ok=True) if path.exists() and path.read_text() != text: backup = _backup_path(path) - # copy2 sets the mode only after copying the content, so create the backup with it first + # copy2 sets the mode only after it copies the content, so create the backup with the source mode first backup.touch(mode=stat.S_IMODE(path.stat().st_mode)) shutil.copy2(path, backup) if private: diff --git a/python/freetoken/server/api_server.py b/python/freetoken/server/api_server.py index ba5263bf2c..a9b16adfd7 100644 --- a/python/freetoken/server/api_server.py +++ b/python/freetoken/server/api_server.py @@ -421,8 +421,6 @@ def install_cors(app: FastAPI, origins_csv: str) -> None: class _ApiKeyMiddleware: - """Answer 401 unless the request carries the key, except /health.""" - def __init__(self, app, api_key: str) -> None: self.app = app self._digest = hashlib.sha256(api_key.encode()).digest() diff --git a/python/freetoken/server/args.py b/python/freetoken/server/args.py index 5779345462..5e6caa759a 100644 --- a/python/freetoken/server/args.py +++ b/python/freetoken/server/args.py @@ -38,7 +38,7 @@ def _nvfp4_entry(value: str) -> str: class ServerArgs(SchedulerConfig): server_host: str = "127.0.0.1" server_port: int = 1919 - # None serves without authentication; kept out of repr so a logged config never shows it + # kept out of repr: parse_args logs the whole ServerArgs api_key: str | None = field(default=None, repr=False) num_tokenizer: int = 0 silent_output: bool = False diff --git a/tests/daemon/test_daemon_serve_manager.py b/tests/daemon/test_daemon_serve_manager.py index 04213cd546..cfd84ae6e6 100644 --- a/tests/daemon/test_daemon_serve_manager.py +++ b/tests/daemon/test_daemon_serve_manager.py @@ -868,7 +868,6 @@ def switch(self, *args): client.post("/engine/start", json={"model": "m", "port": 1919, "apiKey": ""}) client.post("/engine/start", json={"model": "m", "port": 1919}) client.post("/engine/switch", json={"model": "m", "port": 1919, "apiKey": "k2"}) - # argparse also takes an unambiguous prefix of --api-key for args in (["--api-key", "k3"], ["--api-key=k3"], ["--api", "k3"], ["--ap=k3"]): rejected = client.post("/engine/start", json={"model": "m", "args": args}) assert rejected.status_code == 400