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.
| Feld | Anthropic | OpenAI |
|---|---|---|
system | Top-Level-String | Message mit role: "system" |
max_tokens | Pflicht | Optional |
content im Request | String oder Block-Array | String |
content in der Response | Array von Content-Blocks | String in choices[i].message.content |
| Token-Felder | input_tokens, output_tokens | prompt_tokens, completion_tokens, total_tokens |
| Finish-Feld | stop_reason | finish_reason |
| Response-Wrapper | direkt | choices[] |
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
| Datei | Inhalt |
|---|---|
Sources/gateway/App.swift | unverändert |
Sources/gateway/Application+build.swift | unverändert |
Sources/gateway/Router+build.swift | zwei POST-Endpoints, Validation, Mock-Inferenz |
Sources/gateway/Models/ModelTypes.swift | unverändert |
Sources/gateway/Models/Anthropic.swift | Request, Response, Content-Enum, Usage |
Sources/gateway/Models/OpenAI.swift | Request, 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
- Anthropic Messages API Reference, docs.anthropic.com
- OpenAI Chat Completions Reference, platform.openai.com
- Hummingbird Dokumentation, docs.hummingbird.codes
- Claude Code: Bedrock, Vertex & Proxy Configuration, code.claude.com