Zwei Protokolle, ein Backend — Anthropic und OpenAI mit Codable

Zwei Protokolle, ein Backend — Anthropic und OpenAI mit Codable

In Artikel 1 hat unser Server /healthz und einen statischen /v1/models-Endpoint beantwortet. Jetzt machen wir aus dem Skelett einen Gateway, der zwei echte LLM-API-Standards spricht: die Anthropic Messages API für Claude Code und die OpenAI Chat Completions API für Cursor, Aider und andere Tools. Beide Endpoints geben noch Mock-Antworten zurück; das echte MLX-Backend folgt in Artikel 3.

Die zwei Formate im Vergleich

Beide Protokolle folgen demselben Grundprinzip: ein messages-Array mit role und content, dazu Steuerparameter wie model und temperature. Die Unterschiede liegen in den Details.

FeldAnthropicOpenAI
systemTop-Level-StringMessage mit role: "system"
max_tokensPflichtOptional
content im RequestString oder Block-ArrayString
content in der ResponseArray von Content-BlocksString in choices[i].message.content
Token-Felderinput_tokens, output_tokensprompt_tokens, completion_tokens, total_tokens
Finish-Feldstop_reasonfinish_reason
Response-Wrapperdirektchoices[]

Die größte Stolperstelle ist der system-Prompt: bei Anthropic ist er ein eigenes Top-Level-Feld, bei OpenAI eine reguläre Message mit role: "system" im Array. Das Gateway muss beide Konventionen kennen.

Wir legen zwei neue Dateien an: Sources/gateway/Models/Anthropic.swift und Sources/gateway/Models/OpenAI.swift.

Codable-Typen für Anthropic

Der interessanteste Teil der Anthropic-Spec ist das content-Feld in Messages: es kann ein einfacher String sein oder ein Array von Content-Blocks. Claude Code sendet in der Regel Strings; andere Clients schicken manchmal strukturierte Blocks. Unser Gateway muss beide Formate akzeptieren.

Dafür nutzen wir ein Swift-Enum mit einem eigenen init(from:):

//  Models/Anthropic.swift

import Foundation
import Hummingbird

enum AnthropicRole: String, Codable {
    case user
    case assistant
}

enum AnthropicContent: Codable {
    case text(String)
    case blocks([AnthropicContentBlock])

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        if let string = try? container.decode(String.self) {
            self = .text(string)
            return
        }
        if let blocks = try? container.decode([AnthropicContentBlock].self) {
            self = .blocks(blocks)
            return
        }
        throw DecodingError.typeMismatch(
            AnthropicContent.self,
            .init(
                codingPath: decoder.codingPath,
                debugDescription: "content must be a string or an array of content blocks"
            )
        )
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        switch self {
        case .text(let string):
            try container.encode(string)
        case .blocks(let blocks):
            try container.encode(blocks)
        }
    }

    var asText: String {
        switch self {
        case .text(let string):
            return string
        case .blocks(let blocks):
            return blocks.map(\.text).joined(separator: "\n")
        }
    }
}

struct AnthropicContentBlock: Codable {
    let type: String
    let text: String
}

struct AnthropicMessage: Codable {
    let role: AnthropicRole
    let content: AnthropicContent
}

struct MessageRequest: Codable {
    let model: String
    let maxTokens: Int
    let system: AnthropicContent?
    let messages: [AnthropicMessage]
    let stream: Bool?
    let temperature: Double?
    let topP: Double?
    let stopSequences: [String]?

    enum CodingKeys: String, CodingKey {
        case model
        case maxTokens = "max_tokens"
        case system
        case messages
        case stream
        case temperature
        case topP = "top_p"
        case stopSequences = "stop_sequences"
    }
}

struct MessageResponse: Codable, ResponseGenerator {
    let id: String
    let type: String
    let role: AnthropicRole
    let content: [AnthropicContentBlock]
    let model: String
    let stopReason: String
    let stopSequence: String?
    let usage: AnthropicUsage

    enum CodingKeys: String, CodingKey {
        case id, type, role, content, model
        case stopReason = "stop_reason"
        case stopSequence = "stop_sequence"
        case usage
    }

    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 AnthropicUsage: Codable {
    let inputTokens: Int
    let outputTokens: Int

    enum CodingKeys: String, CodingKey {
        case inputTokens = "input_tokens"
        case outputTokens = "output_tokens"
    }
}

Drei Beobachtungen zu diesem Code:

singleValueContainer statt eines normalen Containers, weil das content-Feld keinen eigenen Schlüssel an seiner Stelle im JSON hat, sondern direkt ein primitiver Wert oder ein Array ist. Das try?-Pattern versucht zuerst den einfacheren Fall (String), schlägt still fehl wenn das nicht passt, und versucht dann Blocks. Erst wenn beides scheitert, wird ein kontrollierter Fehler geworfen.

asText auf dem Enum ist Convenience für die Verarbeitung: egal in welchem Format der Inhalt einging, wir können ihn als String extrahieren. Das brauchen wir in der Mock-Inferenz.

Im Response ist content immer [AnthropicContentBlock], kein Enum. Die Anthropic-Spec spezifiziert den Request flexibel, den Response strikt — das spiegeln wir genau ab.

Codable-Typen für OpenAI

Die OpenAI-Typen sind strukturell ähnlich, aber durchgehend simpler: content ist immer ein String, system kommt als reguläre Message, und die meisten Parameter sind optional.

//  Models/OpenAI.swift

import Foundation
import Hummingbird

enum ChatRole: String, Codable {
    case system
    case user
    case assistant
    case tool
}

struct ChatMessage: Codable {
    let role: ChatRole
    let content: String
}

struct ChatCompletionRequest: Codable {
    let model: String
    let messages: [ChatMessage]
    let maxTokens: Int?
    let temperature: Double?
    let topP: Double?
    let stream: Bool?
    let stop: [String]?
    let presencePenalty: Double?
    let frequencyPenalty: Double?
    let user: String?

    enum CodingKeys: String, CodingKey {
        case model, messages, temperature, stream, stop, user
        case maxTokens = "max_tokens"
        case topP = "top_p"
        case presencePenalty = "presence_penalty"
        case frequencyPenalty = "frequency_penalty"
    }
}

struct ChatCompletionResponse: Codable, ResponseGenerator {
    let id: String
    let object: String
    let created: Int
    let model: String
    let choices: [ChatCompletionChoice]
    let usage: ChatCompletionUsage

    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 ChatCompletionChoice: Codable {
    let index: Int
    let message: ChatMessage
    let finishReason: String

    enum CodingKeys: String, CodingKey {
        case index, message
        case finishReason = "finish_reason"
    }
}

struct ChatCompletionUsage: Codable {
    let promptTokens: Int
    let completionTokens: Int
    let totalTokens: Int

    enum CodingKeys: String, CodingKey {
        case promptTokens = "prompt_tokens"
        case completionTokens = "completion_tokens"
        case totalTokens = "total_tokens"
    }
}

ChatRole enthält neben system, user und assistant auch .tool — für spätere Function-Calling-Erweiterungen, die nicht im Scope dieser Reihe liegen, aber sauber mitmodelliert werden. maxTokens ist optional; OpenAI hat serverseitig einen Default, Anthropic nicht.

Routes und Mock-Inferenz

Die zwei neuen POST-Endpoints kommen in Router+build.swift. request.decode(as:context:) ist Hummingbirds Standardpfad: der BasicRequestContext bringt einen JSON-Decoder mit, der den Request-Body direkt in unsere Codable-Typen überführt.

// Ergänzungen in Router+build.swift

// Anthropic Messages API
router.post("v1/messages") { request, context -> MessageResponse in
    let payload = try await request.decode(as: MessageRequest.self, context: context)
    try validate(payload)
    return mockResponse(for: payload)
}

// OpenAI Chat Completions
router.post("v1/chat/completions") { request, context -> ChatCompletionResponse in
    let payload = try await request.decode(as: ChatCompletionRequest.self, context: context)
    try validate(payload)
    return mockResponse(for: payload)
}

Die Validation prüft die Minimalanforderungen beider Specs:

private func validate(_ request: MessageRequest) throws {
    guard !request.messages.isEmpty else {
        throw HTTPError(.badRequest, message: "messages array must not be empty")
    }
    guard request.maxTokens > 0 else {
        throw HTTPError(.badRequest, message: "max_tokens must be greater than 0")
    }
}

private func validate(_ request: ChatCompletionRequest) throws {
    guard !request.messages.isEmpty else {
        throw HTTPError(.badRequest, message: "messages array must not be empty")
    }
}

HTTPError(.badRequest, message:) serialisiert Hummingbird zu {"error":{"message":"..."}}. Das entspricht nicht exakt dem Anthropic- oder OpenAI-Error-Format — spec-konforme Fehlerstrukturen folgen in Artikel 5, wenn wir den RequestContext erweitern.

Die Mock-Inferenz-Funktionen arbeiten mit Swift-Overloading: beide heißen mockResponse(for:), der Compiler unterscheidet sie am Parametertyp.

private let mockSuffix = "\n\n(Mock response from swift-mlx-gateway. Article 3 will wire up the MLX backend.)"

private func lastUserText(in messages: [AnthropicMessage]) -> String {
    for message in messages.reversed() where message.role == .user {
        return message.content.asText
    }
    return ""
}

private func lastUserText(in messages: [ChatMessage]) -> String {
    for message in messages.reversed() where message.role == .user {
        return message.content
    }
    return ""
}

private func mockResponse(for request: MessageRequest) -> MessageResponse {
    let echo = lastUserText(in: request.messages)
    let text = "Echo: \(echo)" + mockSuffix
    let id = "msg_" + String(UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased().prefix(24))
    return MessageResponse(
        id: id,
        type: "message",
        role: .assistant,
        content: [AnthropicContentBlock(type: "text", text: text)],
        model: request.model,
        stopReason: "end_turn",
        stopSequence: nil,
        usage: AnthropicUsage(
            inputTokens: estimateTokens(echo),
            outputTokens: estimateTokens(text)
        )
    )
}

private func mockResponse(for request: ChatCompletionRequest) -> ChatCompletionResponse {
    let echo = lastUserText(in: request.messages)
    let text = "Echo: \(echo)" + mockSuffix
    let id = "chatcmpl-" + String(UUID().uuidString.prefix(8).lowercased())
    let promptTokens = estimateTokens(echo)
    let completionTokens = estimateTokens(text)
    return ChatCompletionResponse(
        id: id,
        object: "chat.completion",
        created: Int(Date().timeIntervalSince1970),
        model: request.model,
        choices: [
            ChatCompletionChoice(
                index: 0,
                message: ChatMessage(role: .assistant, content: text),
                finishReason: "stop"
            )
        ],
        usage: ChatCompletionUsage(
            promptTokens: promptTokens,
            completionTokens: completionTokens,
            totalTokens: promptTokens + completionTokens
        )
    )
}

private func estimateTokens(_ text: String) -> Int {
    max(1, text.count / 4)
}

estimateTokens ist eine grobe Heuristik (1 Token entspricht ungefähr 4 Zeichen). Das reicht für die Mock-Response; Artikel 3 ersetzt das durch echte Token-Counts vom MLX-Backend.

Test mit curl

Server starten:

swift run gateway

Anthropic-Endpoint:

curl -s -X POST localhost:8080/v1/messages \
  -H "Content-Type: application/json" \
  -d '{"model":"claude-3-5-sonnet","max_tokens":256,"messages":[{"role":"user","content":"Hallo Welt"}]}' | jq .
{
  "id": "msg_02acd2c6af7943fd8f623aa5",
  "type": "message",
  "role": "assistant",
  "content": [
    {
      "type": "text",
      "text": "Echo: Hallo Welt\n\n(Mock response from swift-mlx-gateway. Article 3 will wire up the MLX backend.)"
    }
  ],
  "model": "claude-3-5-sonnet",
  "stop_reason": "end_turn",
  "stop_sequence": null,
  "usage": { "input_tokens": 2, "output_tokens": 24 }
}

OpenAI-Endpoint:

curl -s -X POST localhost:8080/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-4","messages":[{"role":"user","content":"Hallo Welt"}]}' | jq .
{
  "id": "chatcmpl-c7573297",
  "object": "chat.completion",
  "created": 1778786375,
  "model": "gpt-4",
  "choices": [
    {
      "index": 0,
      "message": { "role": "assistant", "content": "Echo: Hallo Welt\n\n(Mock response ...)" },
      "finish_reason": "stop"
    }
  ],
  "usage": { "prompt_tokens": 2, "completion_tokens": 24, "total_tokens": 26 }
}

Das content-Feld als Block-Array funktioniert genauso:

curl -s -X POST localhost:8080/v1/messages \
  -H "Content-Type: application/json" \
  -d '{"model":"x","max_tokens":256,"messages":[{"role":"user","content":[{"type":"text","text":"Block-Form"}]}]}' | jq .

Validation-Fehler liefern 400:

# Leeres messages-Array
curl -s -i -X POST localhost:8080/v1/messages \
  -H "Content-Type: application/json" \
  -d '{"model":"x","max_tokens":256,"messages":[]}'
# HTTP/1.1 400 Bad Request
# {"error":{"message":"messages array must not be empty"}}

# max_tokens nicht gesetzt oder 0
curl -s -i -X POST localhost:8080/v1/messages \
  -H "Content-Type: application/json" \
  -d '{"model":"x","max_tokens":0,"messages":[{"role":"user","content":"hi"}]}'
# HTTP/1.1 400 Bad Request
# {"error":{"message":"max_tokens must be greater than 0"}}

Claude Code anbinden

Claude Code respektiert ANTHROPIC_BASE_URL und leitet alle API-Anfragen an die angegebene Basis-URL um:

export ANTHROPIC_BASE_URL=http://localhost:8080
claude

Nach dem Start von claude landet jede Anfrage am lokalen Gateway. Die Mock-Antwort enthält das Echo der Eingabe.

Das bestätigt, dass der Protokoll-Handshake durchläuft; die Antwort ist natürlich nicht sinnvoll — das echte Modell kommt in Artikel 3.

Commit und Tag

git add .
git commit -m "article-02: Anthropic Messages API & OpenAI Chat Completions"
git tag article-02
git push origin main --tags

Zwei Dateien, zwei Protokolle, ein Backend

DateiInhalt
Sources/gateway/App.swiftunverändert
Sources/gateway/Application+build.swiftunverändert
Sources/gateway/Router+build.swiftzwei POST-Endpoints, Validation, Mock-Inferenz
Sources/gateway/Models/ModelTypes.swiftunverändert
Sources/gateway/Models/Anthropic.swiftRequest, Response, Content-Enum, Usage
Sources/gateway/Models/OpenAI.swiftRequest, Response, Choice, Usage

Die Codable-Typen sind jetzt die Vertragsfläche zwischen Gateway-Schicht und Backend. Artikel 3 bringt das MLX-Backend — die mockResponse-Funktionen werden durch einen echten MLXClient ersetzt, der dieselben Typen zurückgibt.

Quellen