chore(taygeta): remove librechat stack

Remove LibreChat, its nginx vhost, local MongoDB persistence, and the Codex OpenAI proxy package/module it depended on. Also drop the now-unused taygeta sops entries.

Assisted-by: pi (gpt-5.5)
This commit is contained in:
Gabriel Fontes
2026-06-29 18:25:23 -03:00
parent a7970fc4c5
commit bb1ff3b61b
8 changed files with 2 additions and 548 deletions
+2 -4
View File
@@ -1,7 +1,5 @@
minecraft-secrets: ENC[AES256_GCM,data:hLwPnL+7vK5jQJGYe4ngBrU6XPeD9IzLPrGlKde7KLUQDh3zl6ewgZc6cqTfRFGxM0bs8JQKy98TqzbJ5FyhOIw39+D2znnldbPGb5TbKu5GdM63eJwM68TiTtmHhQKhaebVOz7QjsLXv4PuitSnqs4c38KZHxOVXmkqORjKKw==,iv:A7fTWZdkQvGEtATzB6bfOVFRU8gISw9NF3ILqt1WlFE=,tag:CLHzt/4wedw2qZvtFPTv/g==,type:str]
factorio-server-password: ENC[AES256_GCM,data:PlrQnU9iQOEh,iv:s/02HaXC9VifwRlmSV6TV6W+bRe6C+O3kgT4zvbNrUY=,tag:gA7gjPXFxuDVafj+Jwq0Og==,type:str]
codex-refresh-token: ENC[AES256_GCM,data:A0YUtbbO3Q86uedhEHodxsL4Ae01VMp5ko4Zgi+jbN8HiiykW2tcW7itMpReC3z7J64vbnmiV9V3Yay+u7Myonv8MX4xdJMRvFd0DvJKjdk4Mc4l8tXOoGBqC8m+Ifs18tPVlAgdBLBx2YC0fCNH4lgUiWsHngkZYqvLApBAY2kvg2NL7m+eCFLvf1zn5spVrTqjostoS6izb6/W9A1lEeyGzVaiB5VOkJbeYmMZy00+185dAvNn4kGw/K8Ntc4MVACrK0tZW0LoG3NhHSHb2J7nsk6QfOYLYg==,iv:AksMor1v2nWt9YmK1M4+EaODf+GyBK01gvZzb5pIIPk=,tag:hGqMZ7VGncrZQjjDhetbLQ==,type:str]
librechat-creds: ENC[AES256_GCM,data:Su1lWNXyRLvlD5SwxVv2Jc6X7IiwBAcLdZXnMdtNJC7yIW525Q+EMdwzECefvdU/BRJHLrEV8mU5AkeefzBQMai1t95m5vanmKWtpvrUAAbaR3n+muehNKzDJyAqwGP6173z3LAq/zPhAh6Y1CywU+aOF96CyODp0IgIJ/O0CziOqetkqMZz879beLeCzQOxio3/NuvyPkgI3FnkYHnz4q+DeD0FF30Zh8GBk05/4LQBliLuZm125a9HNH6Uvfgv2jdIFC4ac3wAm99X1IR88xCf81NOL6FYyHabESC4dMU6XPGFcSPNvOyVdEWTbGlFoV23HxlmpbVC/x67AAryArpq9fRI9uZLKhpN9T4xG2FBvofFPj4L5mHZjvZgcA6AyrTihv4+/fUmOlmJRzTpiCwbt92cNTjkzoN9pqwmt+Zah/sqjILNMU56ptSScmODDs/yIFJN4ZVUpAlkVIpV,iv:7T0itgslEwwaksXDlwlIJkd9qePa6EiNIPNhUzhhlkU=,tag:19cYAXaa2jeMcV/tPB9VXA==,type:str]
sops:
age:
- enc: |
@@ -13,8 +11,8 @@ sops:
pw//QqqFD4Y1tO/sUCHvdES1AJK/itAkOuuEdJ1YA6H4VN5NmKgrDg==
-----END AGE ENCRYPTED FILE-----
recipient: age1szxq4pz3wuxrs5ynqt3gys4wxslg27lr67smsqvg5guhap800szs3y73vd
lastmodified: "2026-06-29T04:30:10Z"
mac: ENC[AES256_GCM,data:1QJkRfsninrokxzzkKQko1yQSDC/h5MQS+SMiGu5hRibZ7hFVxOnk87FhA0dvu9H1BTWHH4ekNFA5pI1OSPDaJ0nET8Bch9UsmhOUBgpJb8XkxR7BlK8C9u4WQ/3tAaWXRrtv4mrKnBKrVZPnQ/2QgNreRY9qr9Z73u83nakJc0=,iv:LD8s17rucCoUsvTaEhex4ahtmjyAWg/XkZEA8lHtqJM=,tag:rYWNes3hk6xZb8C/IE0GFA==,type:str]
lastmodified: "2026-06-29T21:24:55Z"
mac: ENC[AES256_GCM,data:xIu85hmZu3Ji45G8tuHejg7/Rn2d23pIDlSPzbGUZavV1gRu6vyBUejf1m8Ls0Q13XTN4JZZ7apJWxRq4T4W50jJsQoQYU16eGHid1fE1ks7RAJHxb0vc/mxb8pzd0U23DlpD/23fE19SnfR6lik9+QlFSNGXb76vsLFPY3o9OY=,iv:AdaJRBQ0r/by6DQ5T89Dhpx0YKBep28CVt8wzpLpUyk=,tag:caXrSep5WsptpBnqUWoIpw==,type:str]
pgp:
- created_at: "2024-12-30T18:52:52Z"
enc: |-
-1
View File
@@ -3,6 +3,5 @@
../../common/optional/mysql.nix
./minecraft
./factorio.nix
./librechat.nix
];
}
-122
View File
@@ -1,122 +0,0 @@
{
config,
outputs,
pkgs,
...
}: let
hs = outputs.nixosConfigurations.alcyone.config.services.headscale.settings.prefixes;
in {
# codex-openai-proxy: exposes the ChatGPT Codex subscription as a local
# Responses API endpoint for LibreChat to talk to. The refresh token is read
# by systemd (as root) from the sops secret before privileges are dropped.
sops.secrets.codex-refresh-token.sopsFile = ../secrets.yaml; # CODEX_REFRESH_TOKEN=...
services.codex-openai-proxy = {
enable = true;
host = "127.0.0.1";
environmentFile = config.sops.secrets.codex-refresh-token.path;
};
# LibreChat: only reachable on localhost; nginx fronts it (see below).
# Uses a local mongodb. mongodb-ce ships official prebuilt binaries, so it's
# a cheap fetch+unpack rather than a from-source build (the default mongodb
# package is SSPL/unfree and uncached). FerretDB 1.x was too incompatible
# (incomplete findAndModify, breaking user registration).
sops.secrets.librechat-creds.sopsFile = ../secrets.yaml; # CREDS_KEY, CREDS_IV, JWT_SECRET, JWT_REFRESH_SECRET
services.mongodb.package = pkgs.mongodb-ce;
services.librechat = {
enable = true;
enableLocalDB = true; # local mongodb, sets MONGO_URI
credentialsFile = config.sops.secrets.librechat-creds.path;
env = {
HOST = "127.0.0.1";
ALLOW_REGISTRATION = true;
};
settings = {
version = "1.2.1";
# Web search via Tavily (TAVILY_API_KEY in the sops creds file). Tavily
# serves as both search provider and scraper; reranking is disabled so
# users aren't prompted for a Jina/Cohere key. Values must reference env
# var names, not literals -- escaped so Nix doesn't interpolate them.
webSearch = {
searchProvider = "tavily";
scraperProvider = "tavily";
tavilyApiKey = "\${TAVILY_API_KEY}";
rerankerType = "none";
};
endpoints.custom = [
{
name = "Codex";
# Bound to localhost and key-less, so a placeholder key is fine.
apiKey = "codex-proxy";
baseURL = "http://127.0.0.1:${toString config.services.codex-openai-proxy.port}/v1";
# Talk the native Responses API rather than chat-completions; the
# proxy only serves /v1/responses. `useResponsesApi` is not an
# endpoint-level key (LibreChat silently drops unknown keys here) --
# it's a per-conversation model param, so default it on via
# customParams.paramDefinitions instead.
customParams = {
# useResponsesApi is an OpenAI param, so pull the OpenAI parameter
# set rather than the bare `custom` one.
defaultParamsEndpoint = "openAI";
paramDefinitions = [
{
key = "useResponsesApi";
default = true;
}
{
# Default the web-search toggle on for new conversations.
key = "web_search";
default = true;
}
];
};
models = {
# Augmented at runtime from the proxy's GET /v1/models; default is
# the required seed/fallback list (LibreChat rejects it missing).
default = ["gpt-5.5" "gpt-5.4" "gpt-5.4-mini"];
fetch = true;
};
# Title generation also goes through /v1/responses (non-streaming,
# which the proxy reassembles).
titleConvo = true;
titleModel = "gpt-5.4-mini";
modelDisplayLabel = "Codex";
}
];
};
};
services.nginx.virtualHosts."ai.m7.rs" = {
forceSSL = true;
enableACME = true;
locations."/" = {
proxyPass = "http://127.0.0.1:${toString config.services.librechat.env.PORT}";
proxyWebsockets = true;
# Locked to tailscale; the ACME challenge location is exempt.
extraConfig = ''
allow 127.0.0.1;
allow ::1;
allow ${hs.v4};
allow ${hs.v6};
deny all;
'';
};
};
environment.persistence."/persist".directories = [
{
directory = "/var/lib/librechat";
user = "librechat";
group = "librechat";
mode = "0700";
}
{
directory = "/var/lib/mongodb";
user = "mongodb";
group = "mongodb";
mode = "0700";
}
];
}
-65
View File
@@ -1,65 +0,0 @@
{
pkgs,
lib,
config,
...
}: let
cfg = config.services.codex-openai-proxy;
in {
options.services.codex-openai-proxy = {
enable = lib.mkEnableOption "the OpenAI-compatible proxy for a ChatGPT Codex subscription";
package = lib.mkPackageOption pkgs "codex-openai-proxy" {};
host = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
description = "Address the proxy binds to.";
};
port = lib.mkOption {
type = lib.types.port;
default = 8788;
description = "Port the proxy listens on.";
};
environmentFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
example = "/run/secrets/codex-openai-proxy";
description = ''
Path to an environment file, read by systemd as root before privileges
are dropped. Must provide `CODEX_REFRESH_TOKEN`, and may also set
`CODEX_CLIENT_ID` and `CODEX_API_KEY`.
'';
};
};
config = lib.mkIf cfg.enable {
systemd.services.codex-openai-proxy = {
description = "OpenAI-compatible proxy for the ChatGPT Codex subscription";
after = ["network-online.target"];
wants = ["network-online.target"];
wantedBy = ["multi-user.target"];
environment = {
CODEX_HOST = cfg.host;
CODEX_PORT = toString cfg.port;
};
serviceConfig = {
ExecStart = lib.getExe cfg.package;
EnvironmentFile = lib.mkIf (cfg.environmentFile != null) cfg.environmentFile;
DynamicUser = true;
Restart = "on-failure";
RestartSec = 10;
# Hardening
CapabilityBoundingSet = "";
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
PrivateDevices = true;
RestrictAddressFamilies = ["AF_INET" "AF_INET6"];
};
};
};
}
-1
View File
@@ -5,5 +5,4 @@
openrgb = import ./openrgb.nix;
nix-registry-prometheus-exporter = import ./nix-registry-prometheus-exporter.nix;
llama-router = import ./llama-router.nix;
codex-openai-proxy = import ./codex-openai-proxy.nix;
}
@@ -1,328 +0,0 @@
#!/usr/bin/env python3
# Note: vibecoded (pi running claude-opus-4-8)
"""Tiny OpenAI-compatible proxy for a ChatGPT Codex subscription.
Bridges LibreChat's native Responses API custom endpoint to the ChatGPT Codex
backend at ``https://chatgpt.com/backend-api/codex/responses``. It does the
three things a plain custom-endpoint config in LibreChat cannot:
1. Exchanges a refresh token for an access token on demand and derives the
ChatGPT account id from the id_token, so no separate refresh cron is
needed and nothing is persisted to disk.
2. Injects the headers the Codex backend gates on (account id, originator,
OpenAI-Beta, session id).
3. Reshapes the request body into what the backend accepts (``store: false``,
streaming, reasoning carried inline).
Endpoints: ``GET /v1/models``, ``POST /v1/responses`` (passthrough),
``GET /health``.
Config via environment:
CODEX_REFRESH_TOKEN OAuth refresh token (required)
CODEX_CLIENT_ID OAuth client id (default: the Codex CLI's)
CODEX_HOST listen address (default 127.0.0.1)
CODEX_PORT listen port (default 8788)
CODEX_API_KEY if set, clients must send it as a Bearer token
"""
from __future__ import annotations
import base64
import json
import os
import sys
import threading
import time
import urllib.error
import urllib.request
import uuid
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# --- Constants ---------------------------------------------------------------
TOKEN_URL = "https://auth.openai.com/oauth/token"
CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"
# Refresh this many seconds before the token expires.
REFRESH_MARGIN = 300
# Models the subscription exposes through the Codex backend. The ChatGPT
# account only accepts the base ids here, not the `-codex`/`-mini`/`-pro`
# variants; probe with /v1/responses if OpenAI rotates the allowed set.
MODELS = ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"]
# OAuth client id used by the Codex CLI; the refresh endpoint only honours this.
CLIENT_ID = os.environ.get("CODEX_CLIENT_ID", "app_EMoamEEZ73f0CkXaXp7hrann")
REFRESH_TOKEN = os.environ.get("CODEX_REFRESH_TOKEN")
CLIENT_API_KEY = os.environ.get("CODEX_API_KEY")
def log(*args: object) -> None:
print("[codex-proxy]", *args, file=sys.stderr, flush=True)
# --- Auth --------------------------------------------------------------------
def account_id_from_jwt(token: str) -> str:
"""Extract the ChatGPT account id from an id_token's claims."""
payload = token.split(".")[1]
payload += "=" * (-len(payload) % 4) # restore base64 padding
claims = json.loads(base64.urlsafe_b64decode(payload))
return claims.get("https://api.openai.com/auth", {}).get("chatgpt_account_id", "")
class AuthManager:
"""Exchanges a refresh token for access tokens, in memory, on demand."""
def __init__(self, refresh_token: str) -> None:
self._refresh = refresh_token
self._access = ""
self._account = ""
self._expires = 0
self._lock = threading.Lock()
def _do_refresh(self) -> None:
log("refreshing access token")
payload = json.dumps(
{
"client_id": CLIENT_ID,
"grant_type": "refresh_token",
"refresh_token": self._refresh,
"scope": "openid profile email",
}
).encode()
req = urllib.request.Request(
TOKEN_URL,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
body = json.load(resp)
self._access = body["access_token"]
if body.get("refresh_token"):
self._refresh = body["refresh_token"]
if body.get("id_token"):
self._account = account_id_from_jwt(body["id_token"])
self._expires = int(time.time() * 1000) + int(body["expires_in"]) * 1000
def get(self) -> tuple[str, str]:
"""Return (access_token, account_id), refreshing if near expiry."""
with self._lock:
if self._expires - int(time.time() * 1000) < REFRESH_MARGIN * 1000:
self._do_refresh()
return self._access, self._account
if not REFRESH_TOKEN:
sys.exit("CODEX_REFRESH_TOKEN is required")
AUTH = AuthManager(REFRESH_TOKEN)
# --- Upstream ----------------------------------------------------------------
def upstream_headers() -> dict:
token, account = AUTH.get()
return {
"Authorization": f"Bearer {token}",
"chatgpt-account-id": account,
"OpenAI-Beta": "responses=experimental",
"originator": "codex_cli_rs",
"session_id": str(uuid.uuid4()),
"Content-Type": "application/json",
"Accept": "text/event-stream",
"User-Agent": "codex_cli_rs/0.0.0",
}
def shape_responses_body(body: dict) -> dict:
"""Coerce a Responses body into what the Codex backend accepts."""
body = dict(body)
# The backend is stateless for subscriptions: store must be false and
# reasoning has to be carried inline rather than referenced by id.
body["store"] = False
body["stream"] = True
# LibreChat sends reasoning controls as flat top-level params, but the
# Responses API wants them nested under `reasoning`; fold them in (the
# backend 400s on the flat `reasoning_effort`/`reasoning_summary` keys).
reasoning = dict(body.get("reasoning") or {})
if body.get("reasoning_effort"):
reasoning["effort"] = body["reasoning_effort"]
if body.get("reasoning_summary"):
reasoning["summary"] = body["reasoning_summary"]
# Always request a human-readable CoT summary, even when LibreChat sends no
# effort (its "auto"): without this only the opaque encrypted_content comes
# back and LibreChat shows no reasoning. Omitting `effort` lets the backend
# pick the model default, so "auto" works. "auto" summary lets the model
# pick summary granularity.
reasoning.setdefault("summary", "auto")
body["reasoning"] = reasoning
include = set(body.get("include") or [])
include.add("reasoning.encrypted_content")
body["include"] = sorted(include)
# Params the Codex backend rejects: the flat reasoning keys folded in
# above, sampling knobs gpt-5 reasoning models don't take, plus `user`,
# which LibreChat injects but the backend 400s on.
for bad in ("reasoning_effort", "reasoning_summary", "temperature",
"top_p", "max_tokens", "frequency_penalty",
"presence_penalty", "user"):
body.pop(bad, None)
# store=false means nothing is persisted server-side, so input items can't
# be referenced by id. On multi-turn/tool rounds LibreChat replays prior
# reasoning items (rs_...) by id without their encrypted_content; the
# backend 404s ("Items are not persisted... remove this item"). Drop bare
# reasoning references -- keep any that still carry encrypted_content.
if isinstance(body.get("input"), list):
body["input"] = [
item for item in body["input"]
if not (isinstance(item, dict)
and item.get("type") == "reasoning"
and not item.get("encrypted_content"))
]
return body
def open_upstream(body: dict):
"""POST a (shaped) Responses body; return the streaming HTTP response."""
payload = json.dumps(shape_responses_body(body)).encode()
req = urllib.request.Request(
CODEX_RESPONSES_URL, data=payload, headers=upstream_headers(),
method="POST",
)
return urllib.request.urlopen(req, timeout=600)
# --- SSE parsing -------------------------------------------------------------
def sse_events(resp):
"""Yield parsed JSON objects from an SSE stream."""
for raw in resp:
line = raw.decode("utf-8", "replace").strip()
if not line.startswith("data:"):
continue
data = line[len("data:"):].strip()
if not data or data == "[DONE]":
continue
try:
yield json.loads(data)
except json.JSONDecodeError:
continue
# --- HTTP handler ------------------------------------------------------------
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args): # quieter default logging
return
def _authorized(self) -> bool:
if not CLIENT_API_KEY:
return True
auth = self.headers.get("Authorization", "")
return auth == f"Bearer {CLIENT_API_KEY}"
def _json(self, code: int, obj: dict) -> None:
data = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _begin_sse(self) -> None:
# No Content-Length and we don't chunk, so the client detects the end
# of the stream by the connection closing -- force that.
self.close_connection = True
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "close")
self.end_headers()
def _error(self, code: int, message: str) -> None:
self._json(code, {"error": {"message": message, "type": "proxy_error"}})
def _read_body(self) -> dict:
length = int(self.headers.get("Content-Length", 0))
return json.loads(self.rfile.read(length) or b"{}")
# -- routing --
def do_GET(self):
if self.path == "/health":
return self._json(200, {"status": "ok"})
if self.path.rstrip("/") == "/v1/models":
if not self._authorized():
return self._error(401, "invalid api key")
return self._json(200, {
"object": "list",
"data": [
{"id": m, "object": "model", "owned_by": "openai"}
for m in MODELS
],
})
return self._error(404, f"no route for GET {self.path}")
def do_POST(self):
if not self._authorized():
return self._error(401, "invalid api key")
try:
body = self._read_body()
except (ValueError, json.JSONDecodeError):
return self._error(400, "invalid json body")
route = self.path.rstrip("/")
try:
if route == "/v1/responses":
return self._handle_responses(body)
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", "replace")
log(f"upstream {e.code}: {detail[:500]}")
return self._error(e.code, f"codex backend: {detail[:500]}")
except Exception as e: # noqa: BLE001 - surface to client
log("error:", repr(e))
return self._error(502, str(e))
return self._error(404, f"no route for POST {self.path}")
# -- /v1/responses : passthrough (optionally de-streamed) --
def _handle_responses(self, body: dict):
wants_stream = body.get("stream", False)
resp = open_upstream(body)
if wants_stream:
self._begin_sse()
for raw in resp:
self.wfile.write(raw)
self.wfile.write(b"data: [DONE]\n\n")
return
# Reassemble a non-streaming response object for the caller. The Codex
# backend leaves `output` empty on `response.completed`; the finished
# items arrive on `response.output_item.done` events instead.
final = None
items: list[dict] = []
for evt in sse_events(resp):
t = evt.get("type")
if t == "response.output_item.done" and evt.get("item"):
items.append(evt["item"])
elif t == "response.completed":
final = evt.get("response")
if final is None:
return self._error(502, "no response.completed event from backend")
if not final.get("output"):
final["output"] = items
return self._json(200, final)
def main() -> None:
host = os.environ.get("CODEX_HOST", "127.0.0.1")
port = int(os.environ.get("CODEX_PORT", "8788"))
server = ThreadingHTTPServer((host, port), Handler)
log(f"listening on http://{host}:{port}")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()
-26
View File
@@ -1,26 +0,0 @@
# Note: vibecoded (pi running claude-opus-4-8)
{
lib,
python3,
stdenvNoCC,
}:
stdenvNoCC.mkDerivation {
pname = "codex-openai-proxy";
version = "0.1.0";
src = ./codex-openai-proxy.py;
dontUnpack = true;
installPhase = ''
runHook preInstall
install -Dm755 $src $out/bin/codex-openai-proxy
substituteInPlace $out/bin/codex-openai-proxy \
--replace-fail '#!/usr/bin/env python3' '#!${lib.getExe python3}'
runHook postInstall
'';
meta = {
description = "OpenAI-compatible proxy for a ChatGPT Codex subscription";
mainProgram = "codex-openai-proxy";
license = lib.licenses.mit;
platforms = lib.platforms.linux;
};
}
-1
View File
@@ -12,7 +12,6 @@
clip-notify = pkgs.callPackage ./clip-notify {};
jagex-auth = pkgs.callPackage ./jagex-auth {};
llm-suggest-lsp = pkgs.callPackage ./llm-suggest-lsp {};
codex-openai-proxy = pkgs.callPackage ./codex-openai-proxy {};
# My slightly customized plymouth theme, just makes the blue outline white
plymouth-spinner-monochrome = pkgs.callPackage ./plymouth-spinner-monochrome {};