Der Swift-Client: erste Verbindung zum Modell

Der Swift-Client: erste Verbindung zum Modell

Artikel 3 · Serie: Ein lokaler Coding-Agent mit apfel

In Artikel 2 haben wir das OpenAI-Protokoll von apfel mit curl von Hand durchgespielt. Jetzt schreiben wir den ersten eigenen Code, der an diesen Server andockt. Wir legen ein Swift-Package an, bauen einen async HTTP-Client auf URLSession, schicken einen Chat-Completion-Request an /v1/chat/completions und verarbeiten die Antwort als Server-Sent-Events, Token für Token. Am Ende steht ein kleines CLI: Prompt rein, gestreamte Antwort raus. Wir wählen Swift, weil dieselbe Sprache uns vom ersten HTTP-Request bis zur Xcode-Anbindung am Ende der Reihe trägt. Der Stand dieses Artikels ist eingefroren als Tag v0.3 im Demo-Repo: https://codeberg.org/rotecodefraktion/apfel-coding-agent/src/tag/v0.3

Das Package-Skelett

Wir beginnen mit einem ausführbaren Swift-Package, das zwei Targets trennt: eine Library AgentCore und ein Executable apfel-agent. Die Library hält die Logik, das Executable nur den CLI-Einstieg.

// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "apfel-coding-agent",
    platforms: [.macOS(.v13)],
    products: [
        .library(name: "AgentCore", targets: ["AgentCore"]),
        .executable(name: "apfel-agent", targets: ["apfel-agent"]),
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"),
    ],
    targets: [
        .target(name: "AgentCore"),
        .executableTarget(
            name: "apfel-agent",
            dependencies: [
                "AgentCore",
                .product(name: "ArgumentParser", package: "swift-argument-parser"),
            ]
        ),
        .testTarget(
            name: "AgentCoreTests",
            dependencies: ["AgentCore"],
            resources: [.copy("Fixtures")]
        ),
    ]
)

Die Trennung in Library und Executable kostet jetzt eine Zeile mehr und zahlt sich ab dem nächsten Artikel aus: Tests hängen an AgentCore, und alles, was wir später bauen (Tool-Registry, Agent-Loop, MCP-Anbindung), wächst in der Library, nicht im CLI-Einstieg. Die einzige externe Abhängigkeit ist Apples swift-argument-parser für das CLI. Beim Bau hat der Resolver Version 1.8.2 aufgelöst; wir halten diesen Stand zusammen mit der Swift-Toolchain (6.3.2) in docs/setup.md fest, damit der getaggte Stand reproduzierbar bleibt.

Die Request-Modelle als Codable

Das Protokoll ist JSON, also modellieren wir Request und Response als Codable-Typen. Eine Message hat eine Rolle und einen Inhalt, ein Request nennt Modell, Messages und optional Temperatur und Streaming-Flag.

public struct ChatMessage: Codable, Sendable, Equatable {
    public let role: String
    public let content: String
}

public struct ChatRequest: Codable, Sendable, Equatable {
    public let model: String
    public let messages: [ChatMessage]
    public let temperature: Double?
    public let stream: Bool?

    public init(
        model: String = "apple-foundationmodel",
        messages: [ChatMessage],
        temperature: Double? = nil,
        stream: Bool? = nil
    ) {
        self.model = model
        self.messages = messages
        self.temperature = temperature
        self.stream = stream
    }
}

Bemerkenswert ist hier, was fehlt. In Artikel 2 haben wir gesehen, dass apfel die Parameter stop, n, logprobs, presence_penalty und frequency_penalty mit HTTP 400 ablehnt. Also modellieren wir sie gar nicht erst. JSONEncoder lässt nil-Optionals automatisch weg, der erzeugte Request enthält also nur Felder, die der Server akzeptiert. Ein Test hält das fest:

@Test("Omitted optionals do not appear in the JSON")
func omitsNilFields() throws {
    let request = ChatRequest(messages: [ChatMessage(role: "user", content: "hi")])
    let object = try encodedObject(request)
    #expect(object["stream"] == nil)
    #expect(object["temperature"] == nil)
    #expect(object["stop"] == nil)
}

Der erste Round-Trip ohne Streaming

Der Client ist ein struct mit Basis-URL, Modell-ID und einer URLSession. Die complete-Methode baut den Request, schickt ihn ab, prüft den Status und dekodiert die Antwort.

public func complete(
    _ messages: [ChatMessage],
    temperature: Double? = nil
) async throws -> ChatResponse {
    let request = try makeRequest(
        ChatRequest(model: model, messages: messages, temperature: temperature, stream: false)
    )

    let data: Data
    let response: URLResponse
    do {
        (data, response) = try await session.data(for: request)
    } catch {
        throw ClientError.connectionFailed(error.localizedDescription)
    }

    try Self.check(response: response, body: data)

    do {
        return try JSONDecoder().decode(ChatResponse.self, from: data)
    } catch {
        throw ClientError.decoding("\(error)")
    }
}

Die ChatResponse spiegelt das Shape aus Artikel 2: ein Array choices, jedes mit message und finish_reason, dazu ein usage-Objekt mit der Token-Zählung. Ein realer Aufruf gegen das laufende Modell:

$ swift run apfel-agent --no-stream "List three primary colors, comma separated."
Red, blue, yellow

Streaming mit Server-Sent Events in Swift

Für die gestreamte Variante öffnen wir die Verbindung mit URLSession.bytes(for:). Das liefert eine AsyncBytes-Sequenz, über deren .lines-Property wir zeilenweise und asynchron lesen. Jede data:-Zeile geht durch den Parser, jeder dekodierte Chunk wird in einen AsyncThrowingStream gelegt, bis [DONE] den Strom beendet.

public func stream(
    _ messages: [ChatMessage],
    temperature: Double? = nil
) -> AsyncThrowingStream<ChatChunk, Error> {
    AsyncThrowingStream { continuation in
        let task = Task {
            do {
                let request = try makeRequest(
                    ChatRequest(model: model, messages: messages, temperature: temperature, stream: true)
                )

                let (bytes, response) = try await session.bytes(for: request)
                try Self.check(streamResponse: response)

                for try await line in bytes.lines {
                    switch try SSEParser.parse(line: line) {
                    case .chunk(let chunk): continuation.yield(chunk)
                    case .done: continuation.finish(); return
                    case .ignored: continue
                    }
                }
                continuation.finish()
            } catch {
                continuation.finish(throwing: error)
            }
        }
        continuation.onTermination = { _ in task.cancel() }
    }
}

Der Stream gibt den vollen ChatChunk heraus, nicht nur den Text. Für dieses CLI greifen wir zwar nur choices.first?.delta.content ab, aber finish_reason und, ab Artikel 4, die Tool-Call-Deltas reisen nicht über delta.content. Den Chunk dekodieren wir ohnehin; ihn auf einen String einzudampfen würde uns im nächsten Artikel zum Umbau zwingen. Also behalten wir die Struktur.

SSE robust parsen

Die naheliegende Versuchung ist, den ganzen Antwort-Body auf \n zu splitten und die Stücke zu nehmen. Das zerbricht an Chunk-Grenzen, an den Leerzeilen zwischen den Events und am [DONE]-Terminator. Stattdessen parsen wir zeilenbasiert. Jede Zeile wird zu genau einem von drei Ergebnissen: ein dekodierter Chunk, das Ende-Signal, oder etwas, das wir ignorieren.

public enum SSELine: Sendable, Equatable {
    case chunk(ChatChunk)
    case done
    case ignored
}

public enum SSEParser {
    public static func parse(line rawLine: String) throws -> SSELine {
        let line = rawLine.hasSuffix("\r") ? String(rawLine.dropLast()) : rawLine

        guard line.hasPrefix("data:") else { return .ignored }

        let payload = line.dropFirst("data:".count).trimmingCharacters(in: .whitespaces)
        if payload == "[DONE]" { return .done }
        if payload.isEmpty { return .ignored }

        do {
            let chunk = try JSONDecoder().decode(ChatChunk.self, from: Data(payload.utf8))
            return .chunk(chunk)
        } catch {
            throw ClientError.decoding("SSE chunk: \(error)")
        }
    }
}

Wir prüfen das gegen eine echte Aufzeichnung. Eine längere Antwort des Modells kommt als mehrere Content-Chunks, getrennt durch Leerzeilen, abgeschlossen durch einen Chunk mit finish_reason: stop und dann [DONE]. Der Test fährt die aufgezeichnete Sequenz durch den Parser und prüft, dass der zusammengesetzte Text stimmt, dass das Abschluss-Signal kam und dass [DONE] erkannt wurde:

@Test("Recorded stream reconstructs the full assistant content")
func reconstructContent() throws {
    let sse = try fixture("chat-stream", "sse")
    var content = ""
    var sawDone = false

    for line in sse.components(separatedBy: "\n") {
        switch try SSEParser.parse(line: line) {
        case .chunk(let chunk):
            if let delta = chunk.choices.first?.delta.content { content += delta }
        case .done:
            sawDone = true
        case .ignored:
            continue
        }
    }

    #expect(content == "Recursion is a method of solving problems by breaking them down into smaller, more manageable sub-problems of the same type. It involves a function calling itself with a modified argument until it reaches a base case, which stops the recursion.")
    #expect(sawDone)
}

Die Fixture ist keine erfundene Beispielantwort, sondern ein echter Mitschnitt von apfel 1.5.1. So laufen die Tests offline gegen aufgezeichnete Daten, ohne dass ein Server laufen muss, und prüfen trotzdem genau das Wire-Format, das der Server wirklich liefert.

Das Protokoll als Architekturentscheidung

Der Client spricht HTTP gegen apfel --serve, nicht das FoundationModels-Framework direkt. Die Begründung haben wir in Artikel 2 ausführlich gezogen und im Demo-Repo als docs/adr/001-serve-modus-als-backend.md festgehalten: Das HTTP-Protokoll ist eine stabile, dokumentierte Naht. Antworten lassen sich als Fixtures aufzeichnen und offline gegen sie testen, das Modell-Backend bleibt austauschbar, und die plattformabhängige API von Apple liegt hinter apfel, nicht in unserem Code. Den Preis dafür, einen lokalen HTTP-Hop und einen laufenden apfel-Prozess, nehmen wir für ein lokales Entwicklerwerkzeug gern in Kauf.

Fehler als Wert statt als Absturz

Ein Client, der bei jedem Verbindungsproblem abstürzt, taugt nicht als Baustein für einen Agenten. Wir modellieren die Fehlerfälle als typisierte Werte:

public enum ClientError: Error, Sendable, Equatable, CustomStringConvertible {
    case connectionFailed(String)
    case httpStatus(code: Int, body: String)
    case serverError(type: String, message: String)
    case decoding(String)
    case invalidResponse
}

Der serverError-Fall deckt den 400-Pfad aus Artikel 2 ab: Lehnt apfel einen Parameter ab, kommt eine strukturierte invalid_request_error-Antwort, die wir dekodieren und als Fehlertext durchreichen, statt nur einen nackten Statuscode zu sehen. Bei einer non-stream-Antwort liest die Statusprüfung den Body und mappt ihn auf diesen Fall:

private static func check(response: URLResponse, body: Data) throws {
    guard let http = response as? HTTPURLResponse else { throw ClientError.invalidResponse }
    guard (200..<300).contains(http.statusCode) else {
        if let envelope = try? JSONDecoder().decode(ServerErrorEnvelope.self, from: body) {
            throw ClientError.serverError(
                type: envelope.error.type ?? "error",
                message: envelope.error.message ?? ""
            )
        }
        throw ClientError.httpStatus(code: http.statusCode, body: String(decoding: body, as: UTF8.self))
    }
}

Läuft kein Server, fängt complete den URLSession-Fehler und gibt connectionFailed zurück. Das CLI macht daraus eine Meldung auf stderr und einen Exit-Code ungleich null, ohne Stacktrace.

Das CLI zusammenstecken

Der Einstieg ist eine AsyncParsableCommand. Der Prompt ist ein positionales Argument, dazu kommen --base-url und ein --stream/--no-stream-Flag mit Streaming als Default.

@main
struct AgentCommand: AsyncParsableCommand {
    @Argument(help: "The prompt to send to the model.")
    var prompt: String

    @Option(name: .long, help: "Base URL of the apfel serve endpoint.")
    var baseURL: String = "http://127.0.0.1:11434"

    @Flag(inversion: .prefixedNo, help: "Stream tokens as they arrive, or wait for the full reply.")
    var stream: Bool = true

    mutating func run() async throws {
        guard let url = URL(string: baseURL) else {
            throw ValidationError("Invalid base URL: \(baseURL)")
        }
        let client = APFELClient(baseURL: url)
        let messages = [ChatMessage(role: "user", content: prompt)]

        do {
            if stream {
                for try await chunk in client.stream(messages) {
                    if let text = chunk.choices.first?.delta.content {
                        FileHandle.standardOutput.write(Data(text.utf8))
                    }
                }
                FileHandle.standardOutput.write(Data("\n".utf8))
            } else {
                let response = try await client.complete(messages)
                print(response.choices.first?.message.content ?? "")
            }
        } catch let error as ClientError {
            FileHandle.standardError.write(Data((error.description + "\n").utf8))
            throw ExitCode.failure
        }
    }
}

Ein gestreamter Lauf gegen das laufende Modell:

$ swift run apfel-agent "Explain recursion to a beginner in two sentences."
Recursion is a method of solving problems by breaking them down into smaller,
more manageable sub-problems that resemble the original problem. It involves a
function calling itself with a modified argument until a base condition is met,
which stops the recursion.

Und der Fehlerpfad, wenn kein Server antwortet:

$ swift run apfel-agent --base-url http://127.0.0.1:11499 "hi"
Could not reach apfel --serve: Could not connect to the server.
$ echo $?
1

Demo-Repo: apfel-coding-agent v0.3

Der Stand dieses Artikels ist eingefroren als Tag v0.3: https://codeberg.org/rotecodefraktion/apfel-coding-agent/src/tag/v0.3

Demo-Repo apfel-coding-agent v0.3 einrichten

Klonen (falls noch nicht geschehen) und auf den Tag einsteigen:

git clone https://codeberg.org/rotecodefraktion/apfel-coding-agent.git
cd apfel-coding-agent
git checkout v0.3

Neu in v0.3 gegenüber v0.2:

  • Package.swift — Swift-Package mit Library AgentCore und Executable apfel-agent
  • Sources/AgentCore/Client/ChatModels, APFELClient, SSEParser, ClientError
  • Sources/apfel-agent/AgentCommand.swift — der CLI-Einstieg
  • Tests/AgentCoreTests/ — Unit-Tests mit echten apfel-Captures als Fixtures
  • docs/adr/001-serve-modus-als-backend.md — die Backend-Entscheidung
  • scripts/smoke-client.sh — End-to-End-Test gegen einen laufenden apfel-Serve

Bauen, testen, laufen lassen:

swift build
swift test                        # offline, kein apfel nötig
swift run apfel-agent "Explain higher-order functions in one sentence."

Die Unit-Tests laufen ohne apfel und ohne Apple Intelligence. Der End-to-End-Test braucht beides:

./scripts/smoke-client.sh

Zeigt der letzte Output SMOKE OK, läuft alles. Voraussetzungen (apfel-Installation, Apple Intelligence) stehen in docs/setup.md.

Stolpersteine aus dem Bau

AsyncParsableCommand, nicht ParsableCommand. swift-argument-parser hat zwei Command-Protokolle. Wer eine async run()-Methode an das synchrone ParsableCommand hängt, riskiert, dass die Methode gar nicht ausgeführt wird. Für async-Code muss das Root-Command AsyncParsableCommand sein und run() als async markiert. Das ist in Apples Doku vermerkt, fällt aber leicht durchs Raster, wenn man von einem synchronen CLI ausgeht.

Echte Chunks tragen mehr Felder als das Schema. Die aufgezeichneten SSE-Chunks von apfel enthalten Felder, die wir nicht modelliert haben, etwa logprobs und created. Das ist unkritisch: JSONDecoder ignoriert unbekannte Keys von sich aus. Der Decoder fällt also nicht über Zusatzfelder, und wir müssen nicht jedes Feld nachbilden, nur die, die wir lesen.

Kurze Antworten kommen als ein Chunk. Bei einer kurzen Antwort streamt apfel den ganzen Content in einem einzigen Content-Chunk, nicht token-für-token. Erst bei längeren Antworten sehen wir mehrere Chunks. Genau deshalb muss der Parser zeilenbasiert arbeiten und mit einem wie mit vielen Content-Chunks gleich umgehen.

usage fehlt im Stream. Die non-stream-Antwort trägt ein usage-Objekt mit der Token-Zählung, die gestreamte nicht. Wer beim Streaming Token zählen will, kann sich nicht auf den Stream verlassen, sondern muss separat rechnen. Das usage-Feld ist in unserem ChatChunk deshalb optional und in jedem aufgezeichneten Stream leer.

Wie es weitergeht

Mit dem Client steht die Verbindung zum Modell. Artikel 4 nimmt sich das Tool-Calling vor: Wie sieht eine Tool-Definition im OpenAI-Schema aus, wie reicht apfel sie an das Foundation Model durch, und wie läuft der Round-Trip ab, bei dem das Modell ein Werkzeug aufruft und wir das Ergebnis zurückspeisen. Dort zahlt sich die Entscheidung aus, den vollen ChatChunk zu streamen statt nur den Text: Die Tool-Call-Deltas, die jetzt gleich ins Spiel kommen, reisen genau in den Chunk-Feldern, die wir bereits behalten haben.


Vorheriger Artikel: Der Serve-Modus und das OpenAI-Protokoll. Nächster Artikel: Tool-Calling verstehen: vom Schema zum Round-Trip. Repo-Tag: v0.3.