Hello Hummingbird — Das Skelett
Im Prolog haben wir erklärt, warum wir ein lokales LLM-Gateway in Swift mit Hummingbird bauen und was am Ende dabei herauskommt. In diesem Artikel legen wir los. Nach diesen Schritten haben wir ein funktionierendes HTTP-Binary, das /healthz und /v1/models beantwortet und sich mit --host und --port konfigurieren lässt. Kein echtes Modell, keine Auth, kein Streaming. Das kommt in den nächsten Artikeln.
Voraussetzungen
- Swift 6.x installiert (
swift --versionsollte6.xzeigen) - IDE: Xcode 26 oder VS Code (macOS), nur VS Code (Linux/Windows)
- Codeberg-Account und das Repository
swift-mlx-gatewaygeklont
Der Code-Stand dieses Artikels liegt unter dem Tag article-01.
Repository auf Codeberg anlegen und klonen
Einmalige Einrichtung, nur notwendig wenn du das Repo noch nicht hast.
1. Codeberg-Account anlegen
Falls noch kein Account vorhanden: codeberg.org → „Register". Codeberg ist eine europäische, nicht-kommerzielle Forgejo-Instanz, keine Cloud-Abhängigkeit von US-Anbietern.
2. SSH-Key hinterlegen (empfohlen)
# SSH-Key generieren (falls noch keiner vorhanden)
ssh-keygen -t ed25519 -C "deine@email.de"
# Öffentlichen Key anzeigen und kopieren
cat ~/.ssh/id_ed25519.pub
Dann in Codeberg: Einstellungen → SSH-Schlüssel → Neuen Schlüssel hinzufügen.
3. Repository anlegen
Auf codeberg.org: „+" → „Neues Repository" → Name: swift-mlx-gateway, Sichtbarkeit nach Wahl, ohne automatisches README initialisieren (wir schieben selbst einen ersten Commit).
4. Klonen
git clone git@codeberg.org:<dein-username>/swift-mlx-gateway.git
cd swift-mlx-gateway
Ab jetzt arbeiten wir in diesem Verzeichnis. Alle Artikel-Tags werden in dieses Repo gepusht.
Das Paket anlegen
Wir starten mit swift package init im Repository-Verzeichnis:
swift package init --name gateway --type executable
Das legt die Standardstruktur an: Package.swift, Sources/gateway/ mit gateway.swift als Einstiegspunkt und Tests/. Statt gateway.swift zu löschen, benennen wir sie um, so bleibt das Target in Xcode jederzeit gültig.
mv Sources/gateway/gateway.swift Sources/gateway/App.swift
In Xcode 26 öffnen (nur macOS)
open Package.swift
# oder
xed .
Xcode erkennt Package.swift automatisch und löst die Dependencies auf. Vor dem ersten Build in der Toolbar das Scheme-Dropdown öffnen und gateway auswählen. Erst dann funktionieren ⌘B (Build) und ⌘R (Run).
In VS Code öffnen (macOS, Linux, Windows)
code .
Voraussetzung ist die Swift-Extension (sswg.swift-lang). Nach dem Öffnen lädt die Extension die Package-Dependencies automatisch. Server starten über das integrierte Terminal mit swift run gateway. Auf Linux und Windows ist VS Code die einzige IDE-Option; wer Swift auf Windows entwickeln muss, hat ohnehin genug zu kämpfen.
Package.swift
Die drei Abhängigkeiten, die das Skelett braucht:
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "gateway",
platforms: [.macOS(.v26)],
dependencies: [
.package(
url: "https://github.com/hummingbird-project/hummingbird.git",
from: "2.0.0"
),
.package(
url: "https://github.com/apple/swift-log.git",
from: "1.6.3"
),
.package(
url: "https://github.com/apple/swift-argument-parser.git",
from: "1.3.0"
),
],
targets: [
.executableTarget(
name: "gateway",
dependencies: [
.product(name: "Hummingbird", package: "hummingbird"),
.product(name: "Logging", package: "swift-log"),
.product(name: "ArgumentParser", package: "swift-argument-parser"),
]
),
]
)
platforms: [.macOS(.v26)] setzt macOS 26 (Tahoe) voraus, die aktuelle Version. Für ein neues Projekt in 2026 ist das der richtige Baseline. swift-tools-version:6.2 ist ab Swift 6.2 (Xcode 26) erforderlich: erst ab dieser Version werden Unsafe-Build-Flags in Abhängigkeiten wie swift-log nicht mehr blockiert. Zusätzlich aktiviert es striktes Swift-6-Concurrency-Checking, Fehler werden zur Compile-Zeit sichtbar.
Der Einstiegspunkt
Wir legen Sources/gateway/App.swift an. Das ist der @main-Typ; er empfängt CLI-Argumente und startet den Server.
import ArgumentParser
import Hummingbird
import Logging
extension Logger.Level: @retroactive ExpressibleByArgument {}
@main
struct App: AsyncParsableCommand {
@Option(help: "Hostname, auf dem der Server lauscht")
var host: String = "127.0.0.1"
@Option(help: "Port, auf dem der Server lauscht")
var port: Int = 8080
@Option(help: "Log-Level (trace/debug/info/warning/error)")
var logLevel: Logger.Level = .info
func run() async throws {
let app = try await buildApplication(self)
try await app.runService()
}
}
AsyncParsableCommand aus dem ArgumentParser-Package ist der sauberste Weg, CLI-Argumente in einen async-Kontext zu bringen. runService() startet den Server und blockiert, bis ein Signal kommt. Graceful Shutdown ist automatisch eingebaut.
Drei Argumente reichen für das Skelett:
--hoststeuert, auf welchem Interface der Server lauscht (127.0.0.1für lokal,0.0.0.0für alle Interfaces)--portlegt den Port fest--log-levelermöglicht im Betrieb zu kippen, ohne neu zu bauen
Logger.Level ist Codable und RawRepresentable mit String, also versteht ArgumentParser es direkt.
Application+build.swift — eine sinnvolle Konvention
App.swift enthält nur den Einstiegspunkt. Die eigentliche Konfiguration liegt in Sources/gateway/Application+build.swift. Diese Trennung ist in allen offiziellen Hummingbird-Beispielen zu finden: App.swift ist klein und stabil, Application+build.swift wächst mit dem Projekt.
import Hummingbird
import Logging
protocol AppArguments {
var host: String { get }
var port: Int { get }
var logLevel: Logger.Level { get }
}
extension App: AppArguments {}
func buildApplication(_ args: some AppArguments) async throws -> some ApplicationProtocol {
var logger = Logger(label: "swift-mlx-gateway")
logger.logLevel = args.logLevel
let router = buildRouter()
let app = Application(
router: router,
configuration: .init(
address: .hostname(args.host, port: args.port)
),
logger: logger
)
return app
}
Das AppArguments-Protokoll ist kein Overhead: es macht buildApplication testbar. In Artikel 6 schreiben wir Tests, die das Protokoll mit einem TestArguments-Struct implementieren, ohne dass App selbst referenziert werden muss.
Router+build.swift
buildRouter() lebt in einer eigenen Datei Sources/gateway/Router+build.swift. Das hält Application+build.swift stabil über alle Artikel hinweg; neue Routes landen ausschließlich in Router+build.swift.
func buildRouter() -> Router<BasicRequestContext> {
let router = Router(context: BasicRequestContext.self)
router.addMiddleware {
LogRequestsMiddleware(.info)
}
router.get("healthz") { _, _ in
HTTPResponse.Status.ok
}
router.get("v1/models") { _, _ in
ModelList(
object: "list",
data: [
ModelInfo(id: "mlx-placeholder", object: "model", ownedBy: "local")
]
)
}
return router
}
Die Codable-Typen für die Modell-Liste legen wir in Sources/gateway/Models/ModelTypes.swift:
import Foundation
import Hummingbird
struct ModelList: Codable, ResponseGenerator {
let object: String
let data: [ModelInfo]
public func response(from request: Request, context: some RequestContext) throws -> Response {
let encoder = JSONEncoder()
let data = try encoder.encode(self)
return Response(
status: .ok,
headers: [.contentType: "application/json"],
body: .init(byteBuffer: .init(data: data))
)
}
}
struct ModelInfo: Codable {
let id: String
let object: String
let ownedBy: String
enum CodingKeys: String, CodingKey {
case id
case object
case ownedBy = "owned_by"
}
}
Warum ResponseGenerator statt einfach Codable zurückgeben? Hummingbird 2 serialisiert Codable-Werte automatisch, wenn der Request-Context einen passenden RequestDecoder/Encoder hat. BasicRequestContext setzt JSON als Default. Das ResponseGenerator-Protokoll ist die explizite Alternative: besser lesbar, kein implizites Verhalten.
LogRequestsMiddleware(.info) loggt jeden eingehenden Request mit Methode, Pfad und Status-Code. Das kostet nichts und spart viele Debugging-Stunden.
Der Platzhalter mlx-placeholder in der Modell-Liste wird in Artikel 3 durch die tatsächlich konfigurierten MLX-Modelle aus einer YAML-Datei ersetzt.
Der erste Start
swift run gateway
Der erste Build dauert, weil Swift Package Manager alle Abhängigkeiten abruft und kompiliert. Ab dem zweiten Start ist es deutlich schneller.
Wenn der Server läuft:
curl -s localhost:8080/healthz
# → 200 OK, leerer Body
curl -s localhost:8080/v1/models | jq .
Erwartete Ausgabe:
{
"object": "list",
"data": [
{
"id": "mlx-placeholder",
"object": "model",
"owned_by": "local"
}
]
}
Mit anderen Parametern starten:
swift run gateway --host 0.0.0.0 --port 8081 --log-level debug
--help zeigt die verfügbaren Optionen mit den konfigurierten Hilfetexten.

Der Log-Output zeigt die Requests von curl in Echtzeit: Methode, Pfad und Request-ID. Oben rechts: „Running gateway".
Fünf Dateien, ein HTTP-Server
| Datei | Inhalt |
|---|---|
Package.swift | Abhängigkeiten, Target-Konfiguration |
Sources/gateway/App.swift | @main, CLI-Argumente |
Sources/gateway/Application+build.swift | Server-Konfiguration, AppArguments |
Sources/gateway/Router+build.swift | Router, alle Routes |
Sources/gateway/Models/ModelTypes.swift | Codable-Typen für /v1/models |
Das Skelett enthält noch keine Anwendungslogik. Das ist Absicht. Jeder Teil hat genau eine Aufgabe, und die Trennung von Einstiegspunkt, Konfiguration und Typen zieht sich durch die gesamte Reihe.
In Artikel 2 implementieren wir beide Protokolle: zuerst die Anthropic Messages API (/v1/messages) damit Claude Code funktioniert, dann die OpenAI-kompatible Schnittstelle (/v1/chat/completions) für Cursor, Aider und andere Tools.
Commit und Push
Den Stand als article-01-Tag festhalten und auf Codeberg schieben:
git add .
git commit -m "article-01: Hello Hummingbird — das Skelett"
git tag article-01
git push origin main --tags
Der Tag article-01 markiert genau diesen Stand. Wer in einer späteren Folge direkt einsteigen will, kann hier beginnen:
git checkout article-01
Quellen
- Hummingbird 2 Dokumentation, docs.hummingbird.codes
- Hummingbird Beispiel-Projekte, GitHub
- swift-argument-parser, GitHub
- swift-log, GitHub
- Swift Static Linux SDK, swift.org