Eidex is a minimalstic but fast systemone router
  • Go 99.6%
  • Dockerfile 0.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
engel75 80fe899337
Some checks failed
Docker Build / test (push) Failing after 3s
Docker Build / docker (push) Has been skipped
docs: document the access-log levels and fields (readme, example config)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-10-20 06:31:02 +00:00
.forgejo/workflows ci(forgejo): docker build (with go test) and release workflows 2026-10-02 09:46:35 +00:00
cmd/eidex feat(api): configurable HTTP access log middleware 2026-10-20 06:31:02 +00:00
deploy chore(deploy): host networking, systemd var expansion, loopback workers 2026-10-02 11:29:12 +00:00
internal feat(api): configurable HTTP access log middleware 2026-10-20 06:31:02 +00:00
.dockerignore build(docker): multi-stage distroless image for eidex 2026-10-02 09:44:12 +00:00
.gitignore chore: untrack .omo/ (session-local orchestration state) 2026-10-02 10:42:10 +00:00
config.example.yaml docs: document the access-log levels and fields (readme, example config) 2026-10-20 06:31:02 +00:00
Dockerfile build(docker): multi-stage distroless image for eidex 2026-10-02 09:44:12 +00:00
go.mod feat(config): eidex module skeleton with yaml+env config and validation 2026-10-02 07:07:33 +00:00
go.sum feat(config): eidex module skeleton with yaml+env config and validation 2026-10-02 07:07:33 +00:00
README.md docs: document the access-log levels and fields (readme, example config) 2026-10-20 06:31:02 +00:00

eidex

Was ist eidex

eidex ist ein HTTP-Proxy-Router zwischen dem bifrost-ew-Provider und sglang-Workern (ab v0.5.21) für die Decision-API POST /v1/systemone. Das sglang-model-gateway unterstützt /v1/systemone nicht — eidex schließt diese Lücke und leitet Requests an registrierte sglang-Worker weiter, wobei der Request-Body byte-identisch durchgereicht wird (Routing-Entscheidung anhand von model und state, ohne das Body umzuschreiben). Als Ersatz für die cache_aware-Policy des Gateways dient das cache-affine state_hash-Routing: ein FNV-1a-Hash über das state-Feld verteilt Requests auf einem Hash-Ring, sodass dasselbe Dokument konsistent beim selben Worker landet und dessen KV-Cache trifft. Worker können statisch über die Konfiguration oder dynamisch über POST /workers registriert werden; ein Health-Manager mit zwei Probes pflegt den Zustand jeder Worker-Instanz.

Architektur

+-----------------------+         +------------------------------------------+
|  bifrost (ew-Provider)|         |                  eidex                   |
|  url = eidex-Basis-URL|  POST   |  /v1/systemone: state_hash-Hash-Ring,    |
|  Authorization wird   |-------->|  Failover; /workers-CRUD; Health-Probes  |
|  1:1 durchgereicht    |         +------+----------------+----------+----+
+-----------------------+                |                |          |
                                         v                v          v
                                     worker-1         worker-2   worker-N
                                  (sglang v0.5.21+)  (sglang)    (sglang)

API-Referenz

eidex stellt exakt zehn Endpunkte bereit. GET-Routen bedienen auch HEAD. Jeder Fehler, den eidex selbst erzeugt, wird im einheitlichen Envelope mit X-SMG-Error-Code-Header beantwortet:

{"error":{"type":"<HTTP-Status-Text>","code":"<Code>","message":"<Details>"}}

Die Beispiele gehen von http://localhost:8080 aus.

GET /v1/models

Statische Model-Liste aus der Konfiguration (models[]), im sglang-OpenAI-Format; parent wird explizit als null serialisiert.

$ curl http://localhost:8080/v1/models
{"object":"list","data":[{"id":"dex","object":"model","created":1790921023,"owned_by":"sglang","root":"dex","parent":null,"max_model_len":262144}]}

POST /v1/systemone

Datenpfad. Der Body wird 1:1 an den gewählten Worker (worker-url + /v1/systemone) weitergeleitet; die Worker-Antwort (Status, Body, Content-Type) wird unverändert zurückgegeben — mit einer Ausnahme: Ist routing.model_override.enabled aktiv (Default), ersetzt eidex in jeder committed Antwort, deren Body ein JSON-Objekt mit top-level "model"-String ist, den Wert von "model" durch den Ersatznamen (routing.model_override.model; ist dieser leer, das "model" des Requests). Antworten ohne JSON-Objekt oder ohne top-level "model"-String (z. B. "model": null), nicht-JSON-Bodies und Antworten, deren "model" bereits dem Ersatznamen entspricht, bleiben byte-identisch; committed wird unabhängig vom Worker-Status (2xx wie durchgereichte 4xx). Antworten mit Failover-Signal (502/503/504) werden verworfen und nie umgeschrieben. model und state des Requests werden nur für die Routing-Entscheidung extrahiert, nie ersetzt. Authorization-Header wandern unverändert zum Worker. Kandidaten-Auswahl: zuerst gesunde Worker mit exakt passender model_id, sonst Wildcards (model_id leer), bei Requests ohne model alle gesunden Worker. Failover auf den nächsten Kandidaten bei Transport-Fehlern und Worker-502/503/504 (bis routing.retry Wiederholungen); Worker-Antworten mit anderem Status werden sofort committed und nicht wiederholt.

$ curl -X POST http://localhost:8080/v1/systemone \
    -H 'Content-Type: application/json' \
    -d '{"model":"dex","state":"<state-token>","questions":{"<id>":{"question":"<text>","choices":["<a>","<b>"]}}}'

Erfolgreiche Antworten entsprechen der sglang-System-One-Antwort des Workers. Fehlerfälle von eidex selbst (siehe auch Abschnitt Fehler-Envelope):

  • 503 no_workers — kein gesunder Kandidat vorhanden:
    {"error":{"type":"Service Unavailable","code":"no_workers","message":"No available workers for model 'dex'"}}
    
    Ohne extrahierbares model: "message":"No available workers".
  • 502 workers_unreachable — Failover erschöpft, alle Versuche fehlgeschlagen:
    {"error":{"type":"Bad Gateway","code":"workers_unreachable","message":"all worker attempts failed (last: 503 from http://worker1:8000)"}}
    
  • 413 REQUEST_ENTITY_TOO_LARGE — Body größer als server.max_body_bytes:
    {"error":{"type":"Request Entity Too Large","code":"REQUEST_ENTITY_TOO_LARGE","message":"request body exceeds max_body_bytes"}}
    

POST /workers

Registriert einen Worker dynamisch (synchron; der Health-Manager nimmt ihn ab dem nächsten Tick auf, Startzustand healthy). Felder: url (Pflicht, http/https, Abschluss-Slashes werden gestrippt), model_id (optional, leer = Wildcard), worker_type (nur regular, Default), priority (Default 50; wird gespeichert, aber von keiner Policy genutzt), labels (optional). Unbekannte Felder werden ignoriert, damit erweiterte Gateway-Clients nicht brechen.

$ curl -i -X POST http://localhost:8080/workers \
    -H 'Content-Type: application/json' \
    -d '{"url":"http://worker3:8000","model_id":"dex","worker_type":"regular","priority":50,"labels":{}}'
HTTP/1.1 202 Accepted
Location: /workers/7d2f9e0a-1b3c-4d5e-8f90-a1b2c3d4e5f6

{"status":"accepted","worker_id":"7d2f9e0a-1b3c-4d5e-8f90-a1b2c3d4e5f6","url":"http://worker3:8000","location":"/workers/7d2f9e0a-1b3c-4d5e-8f90-a1b2c3d4e5f6","message":"Worker registered"}

400 BAD_REQUEST bei: fehlender/leerer url, ungültiger URL (kein http/https oder kein Host), worker_type != regular, invalide JSON. Ein Body über server.max_body_bytes ergibt hier ebenfalls 400 BAD_REQUEST mit "message":"request body too large".

GET /workers

Listet alle registrierten Worker mit Gesundheits- und Lastzustand (load = in-flight Requests).

$ curl http://localhost:8080/workers
{"workers":[{"id":"7d2f9e0a-...","url":"http://worker3:8000","model_id":"dex","worker_type":"regular","priority":50,"labels":{},"is_healthy":true,"load":0}],"total":1,"stats":{"prefill_count":0,"decode_count":0,"regular_count":1}}

GET /workers/{id}

Einzelner Worker im selben Format; unbekannte ID → 404 WORKER_NOT_FOUND:

$ curl http://localhost:8080/workers/7d2f9e0a-...
{"error":{"type":"Not Found","code":"WORKER_NOT_FOUND","message":"worker not found"}}

DELETE /workers/{id}

Entfernt einen Worker (Hash-Ring wird neu gebaut); unbekannte ID → 404 WORKER_NOT_FOUND.

$ curl -i -X DELETE http://localhost:8080/workers/7d2f9e0a-...
HTTP/1.1 202 Accepted

{"status":"accepted","worker_id":"7d2f9e0a-...","message":"Worker removed"}

GET /liveness

Prozess-Lebendigkeit, unabhängig vom Worker-Zustand; immer 200 als text/plain:

$ curl http://localhost:8080/liveness
OK

GET /health

200, solange mindestens ein Worker healthy ist; sonst 503.

$ curl http://localhost:8080/health
{"status":"ok","healthy_workers":2,"total_workers":2}

Bei null gesunden Workern:

{"status":"unhealthy","healthy_workers":0,"total_workers":2,"reason":"insufficient healthy workers"}

GET /readiness

Identisches Verhalten wie GET /health (gleicher Handler).

GET /version

Vom Build eingestempelte Version (-ldflags "-X main.version=..."), ohne Build-Stamp dev.

$ curl http://localhost:8080/version
{"version":"v0.1.0"}

Fehler-Envelope und Fehlerfälle

Alle von eidex erzeugten Fehler folgen dem Envelope oben; der Code steht zusätzlich im X-SMG-Error-Code-Header. Beim Datenpfad wird nie 404 geantwortet — unbekannte Pfade/Methoden betreffen nur die Verwaltungsfläche:

Status Code Wann
400 BAD_REQUEST invalide JSON, fehlende url, ungültige Worker-URL, worker_type != regular, übergroßer Body bei POST /workers
404 NOT_FOUND unbekannter Pfad ("unknown endpoint")
404 WORKER_NOT_FOUND unbekannte Worker-ID
405 METHOD_NOT_ALLOWED bekannten Pfad mit nicht unterstützter Methode; Antwort enthält Allow-Header
413 REQUEST_ENTITY_TOO_LARGE Body > server.max_body_bytes bei POST /v1/systemone
502 workers_unreachable alle Failover-Versuche fehlgeschlagen
503 no_workers kein gesunder Kandidat für das Request-Modell

Antworten der Worker selbst (auch 4xx wie 422/400 von sglang) werden 1:1 durchgereicht, nicht im Envelope verpackt — abgesehen vom Model-Override des Datenpfads (siehe POST /v1/systemone).

Konfiguration

Geladen wird YAML vom Pfad, der über die Präzedenz -config-Flag > EIDEX_CONFIG > Default /etc/eidex/config.yaml bestimmt wird. Eine fehlende Datei ist kein Fehler (Warnung im Log, Start mit Defaults); eine invalide Datei führt zu exit 1. Unbekannte YAML-Keys werden ignoriert — eine Konfiguration mit Keys einer älteren eidex-Version lädt trotzdem. Vollständiges Beispiel: config.example.yaml.

Key Default Bedeutung
server.listen "0.0.0.0:8080" Listen-Adresse (host:port)
server.max_body_bytes 33554432 (32 MiB) maximale Request-Body-Größe
server.access_log "off" Access-Logging-Stufe: off | error | warning | info | debug (Groß-/Kleinschreibung egal), siehe Abschnitt Access-Logging
models[] — statische /v1/models-Einträge: id (Pflicht), created (Unix-Timestamp), owned_by, root, parent (null erlaubt), max_model_len
routing.policy state_hash state_hash | round_robin | least_outstanding
routing.virtual_nodes 100 virtuelle Knoten pro Worker auf dem Hash-Ring (nur state_hash)
routing.request_timeout "300s" Timeout pro Weiterleitungsversuch an einen Worker
routing.retry 1 Failover-Wiederholungen auf den nächsten Kandidaten
routing.model_override.enabled true Model-Override auf dem Datenpfad: das top-level "model" committed JSON-Antworten von /v1/systemone ersetzen (false = Antworten byte-identisch durchreichen)
routing.model_override.model "" Ersatzname für das "model" der Antwort: fixer Name für alle Antworten; leer (Default) = das "model" des Requests
health.health_endpoint "/health" Light-Probe-Endpunkt
health.health_gen_endpoint "/health_generate" Deep-Probe-Endpunkt
health.health_check_interval "5s" Intervall Light-Probe ("0" = deaktiviert)
health.health_gen_check_interval "60s" Intervall Deep-Probe ("0" = deaktiviert)
health.health_timeout "2s" Timeout einer einzelnen Probe
health.health_checks_down 2 Fehlversuche in Folge bis unhealthy
health.health_checks_up 1 Erfolge in Folge bis wieder healthy
workers[] — initiale Worker: url (Pflicht), model_id (leer = Wildcard), worker_type (Default regular), priority (Default 50, von keiner Policy genutzt), labels (Default {})

Umgebungsvariablen: EIDEX_CONFIG (Pfad der Konfigurationsdatei), EIDEX_SERVER_LISTEN (überschreibt server.listen) und EIDEX_SERVER_ACCESS_LOG (überschreibt server.access_log); leere Werte gelten als nicht gesetzt. Beide Health-Intervalle gleichzeitig auf "0" ist unzulässig (Validierungsfehler: mindestens eine Probe muss aktiv sein). Intervalle und Timeouts sind Go-Duration-Strings.

Health-Manager

Der Health-Manager betreibt zwei Probes gegen jeden registrierten Worker:

  • Light-Probe GET worker-url/health alle health.health_check_interval (Default 5s),
  • Deep-Probe GET worker-url/health_generate alle health.health_gen_check_interval (Default 60s).

Ein Intervall von 0 deaktiviert die jeweilige Probe komplett (beide auf 0 lehnt die Validierung ab). Eine Probe gilt genau dann als erfolgreich, wenn die Antwort HTTP 200 ist; jeder andere Status, Timeout (health.health_timeout, Default 2s) oder Transportfehler ist ein Fehlschlag. Probiert wird über alle Worker, auch über unhealthy — so bleibt Recovery möglich. Nach health_checks_down (Default 2) Fehlversuchen in Folge gilt ein Worker als unhealthy, nach health.health_checks_up (Default 1) Erfolg in Folge wieder als healthy; bei jedem Zustandswechsel werden beide Streaks zurückgesetzt, damit Flapping nicht über einen gemischten Streak entsteht. Neu registrierte Worker (statisch wie dynamisch) starten optimistisch als healthy; die erste Probe eines Loops feuert erst nach einem Intervall. Zustandswechsel werden als worker health changed ins JSON-Log geschrieben.

Access-Logging

Mit server.access_log (Default "off") lässt sich ein HTTP-Access-Log zuschalten: jeder angenommene Request erzeugt höchstens eine JSON-Logzeile mit der msg request. Die Stufe legt fest, welche Requests aufgezeichnet werden, gemessen an der Schwere der Antwort:

  • off — kein Access-Log (die Middleware liegt gar nicht im Handler-Pfad),
  • error — nur 5xx-Antworten (als Log-Level ERROR),
  • warning — ab 4xx (4xx als WARN, 5xx als ERROR),
  • info — jeder Request mit Metadaten (erfolgreiche Requests als INFO),
  • debug — wie info, zusätzlich mit Request- und Response-Inhalten.

Jede Logzeile enthält method, path, status, duration_ms (Millisekunden), request_bytes/response_bytes, request_chars/response_chars (UTF-8-Zeichen) und remote. Wenn vorhanden, kommen dazu request_id (aus dem X-Request-ID-Header des Clients; eidex generiert selbst keine IDs und der Header wandert ohnehin unverändert zum Worker), x_forwarded_for, user_agent und error_code (nur bei Fehlern, die eidex selbst erzeugt — Worker-Antworten tragen den Header nie). Auf dem Datenpfad (POST /v1/systemone) kommen attempts (Anzahl der tatsächlich probierten Kandidaten) und worker (der Worker, dessen Antwort committed wurde) hinzu. Bei debug zusätzlich request_body und response_body sowie — nur wenn Inhalt abgeschnitten wurde — die Flags request_truncated/response_truncated; pro Body werden maximal 1 MiB gespeichert, gezählt wird immer vollständig.

Bewusst nicht geloggt werden Tokens: eidex betreibt keinen Tokenizer und rechnet auch keine Schätzwerte, die Zeichen- und Bytezahlen sind die einzige Größenaussage. Der Authorization-Header wird zu keinem Zeitpunkt protokolliert. Mit EIDEX_SERVER_ACCESS_LOG lässt sich die Stufe per Umgebungsvariable überschreiben (Groß-/Kleinschreibung egal, z. B. Debug = debug).

Deployment

Docker

Multi-Stage-Build (golang:1.27-alpine Builder, distroless nonroot Runtime); die Version wird per Build-Arg eingestempelt:

$ docker build --build-arg VERSION=v0.1.0 -t forge.engelmann.me/engel75/eidex:v0.1.0 .

Der Container liest /etc/eidex/config.yaml (per CMD gesetzt; ohne Mount gelten die Defaults) und lauscht auf Port 8080.

systemd

Die Unit startet das Container-Image per docker run (Port-Mapping 8080, Read-Only-Mount von /etc/eidex/config.yaml); Restart-Management übernimmt systemd (Restart=on-failure), bewusst ohne docker --restart. Vor dem Start das Image-Tag in der Unit auf die gewünschte Release-Version pinnen (Default der Unit: v0.1.0) und die Config nach /etc/eidex/config.yaml legen:

$ cp deploy/eidex.service /etc/systemd/system/
$ systemctl daemon-reload
$ systemctl enable --now eidex

Forgejo-CI

  • .forgejo/workflows/docker-build.yml — läuft bei jedem Push und jeder Pull-Request: Job test (go vet ./..., go test ./... -race -count=1), danach Job docker (needs: [test]), der das Image als forge.engelmann.me/<repo>:${branch}-${sha} in die Registry forge.engelmann.me pusht und per docker run + /liveness + /version gegenprüft.
  • .forgejo/workflows/docker-release.yml — läuft bei Push eines v*-Tags (und manuell per workflow_dispatch): baut und pusht forge.engelmann.me/<repo>:<tag> mit VERSION=<tag>.

Beide Workflows benötigen die Repository-Secrets PACKAGE_USER und PACKAGE_TOKEN für den Registry-Login; sie müssen im Repo gesetzt sein.

bifrost-Integration

Der bifrost-ew-Provider zeigt mit url auf die eidex-Basis-URL; bifrost sendet POST {url}/v1/systemone:

providers:
  - type: ew
    url: http://eidex:8080

Der Authorization-Header der Clients wird von eidex unverändert an den Worker durchgereicht — kein Override, keine Prüfung. Nicht-200-Antworten behandelt der ew-Provider gemäß seiner Semantik (404/405/501 gelten als nicht implementiert; eidex antwortet auf dem Datenpfad nie mit diesen Statuscodes).

Betriebshinweise

  1. model-Feld in Antworten — die Worker-Antwort enthält als model den --served-model-name des Workers. Mit dem Model-Override (Default aktiv, siehe routing.model_override) lässt eidex dieses model-Feld committed Antworten überschreiben — auf den fix konfigurierten Namen oder, bei leerem routing.model_override.model, auf das model des Requests; die Worker müssen dann nicht mehr zwingend mit --served-model-name dex gestartet werden. Ist der Mechanismus deaktiviert (routing.model_override.enabled: false), gilt das alte Verhalten: eidex ändert den Namen nicht, und Clients, die "dex" erwarten, müssen die Worker mit --served-model-name dex starten.
  2. 503 no_workers vs. 502 workers_unreachable — 503 heißt: es gab keinen gesunden Kandidaten (Registrierung/Health-Zustand prüfen, GET /workers). 502 heißt: es gab Kandidaten, aber alle Versuche scheiterten (letzte Diagnose steht in der Message, z. B. Worker-503 oder Timeout).
  3. Dynamische Worker sind nicht persistiert — per POST /workers registrierte Worker überdauern keinen Neustart; danach gilt wieder die Konfigurationsdatei (workers[]).
  4. Keine Authentifizierung — eidex prüft keine Credentials. Nur in vertrauenswürdigen Netzen exponieren; der Authorization-Header wird nur durchgereicht.