Hello Hummingbird — Das Skelett

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 --version sollte 6.x zeigen)
  • IDE: Xcode 26 oder VS Code (macOS), nur VS Code (Linux/Windows)
  • Codeberg-Account und das Repository swift-mlx-gateway geklont

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:

  • --host steuert, auf welchem Interface der Server lauscht (127.0.0.1 für lokal, 0.0.0.0 für alle Interfaces)
  • --port legt den Port fest
  • --log-level ermö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.

Hummingbird Gateway läuft in Xcode 26 — Dateistruktur links, Log-Output unten

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

DateiInhalt
Package.swiftAbhängigkeiten, Target-Konfiguration
Sources/gateway/App.swift@main, CLI-Argumente
Sources/gateway/Application+build.swiftServer-Konfiguration, AppArguments
Sources/gateway/Router+build.swiftRouter, alle Routes
Sources/gateway/Models/ModelTypes.swiftCodable-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