- Go 99.6%
- Dockerfile 0.4%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> |
||
| .forgejo/workflows | ||
| cmd/eidex | ||
| deploy | ||
| internal | ||
| .dockerignore | ||
| .gitignore | ||
| config.example.yaml | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| README.md | ||
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:
Ohne extrahierbares{"error":{"type":"Service Unavailable","code":"no_workers","message":"No available workers for model 'dex'"}}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 alsserver.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/healthallehealth.health_check_interval(Default 5s), - Deep-Probe
GET worker-url/health_generateallehealth.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— wieinfo, 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: Jobtest(go vet ./...,go test ./... -race -count=1), danach Jobdocker(needs: [test]), der das Image alsforge.engelmann.me/<repo>:${branch}-${sha}in die Registryforge.engelmann.mepusht und perdocker run+/liveness+/versiongegenprüft..forgejo/workflows/docker-release.yml— läuft bei Push einesv*-Tags (und manuell perworkflow_dispatch): baut und pushtforge.engelmann.me/<repo>:<tag>mitVERSION=<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
model-Feld in Antworten — die Worker-Antwort enthält alsmodelden--served-model-namedes Workers. Mit dem Model-Override (Default aktiv, sieherouting.model_override) lässt eidex diesesmodel-Feld committed Antworten überschreiben — auf den fix konfigurierten Namen oder, bei leeremrouting.model_override.model, auf dasmodeldes Requests; die Worker müssen dann nicht mehr zwingend mit--served-model-name dexgestartet 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 dexstarten.503 no_workersvs.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).- Dynamische Worker sind nicht persistiert — per
POST /workersregistrierte Worker überdauern keinen Neustart; danach gilt wieder die Konfigurationsdatei (workers[]). - Keine Authentifizierung — eidex prüft keine Credentials. Nur in vertrauenswürdigen Netzen exponieren; der
Authorization-Header wird nur durchgereicht.