Der Serve-Modus und das OpenAI-Protokoll

Der Serve-Modus und das OpenAI-Protokoll

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

Artikel 1 hat gezeigt, was apfel auf der Kommandozeile kann. Jetzt interessiert uns die andere Seite: apfel --serve macht aus dem lokalen Foundation Model einen HTTP-Server mit einer OpenAI-kompatiblen API. Das ist die Schicht, an die unser Swift-Client in Artikel 3 andocken wird: kein direkter Framework-Aufruf, kein Plattform-Lock, sondern ein Protokoll, das jedes SDK spricht, das einen OpenAI-Endpoint erwartet. In diesem Artikel starten wir den Server, lesen alle Endpoints durch, schicken echte Requests von Hand und arbeiten heraus, wo das Protokoll von OpenAI abweicht. Der Stand dieses Artikels ist eingefroren als Tag v0.2 im Demo-Repo: https://codeberg.org/rotecodefraktion/apfel-coding-agent/src/tag/v0.2

apfel –serve starten

apfel --serve

Im Terminal gibt der Server einen Banner mit den aktiven Einstellungen aus (hier mit --debug gestartet, deshalb die Hummingbird-Logzeile am Ende):

apfel server v1.5.1
├ endpoint: http://127.0.0.1:11434
├ model:    apple-foundationmodel
├ cors:     disabled
├ origin:   localhost only (http://127.0.0.1, http://localhost, http://[::1])
├ token:    none
├ health:   public
├ max concurrent: 5
├ debug:    on
└ ready

Endpoints:
  POST http://127.0.0.1:11434/v1/chat/completions
  GET  http://127.0.0.1:11434/v1/models
  GET  http://127.0.0.1:11434/v1/logs
  GET  http://127.0.0.1:11434/v1/logs/stats
  GET  http://127.0.0.1:11434/health

2026-06-03T08:19:01+0200 info Hummingbird: [HummingbirdCore] Server started and listening on 127.0.0.1:11434

Sobald └ ready erscheint, nimmt der Server Requests an. Dass das Modell beim Start bereits in den Arbeitsspeicher geladen wurde, sehen wir gleich in der /health-Antwort am Feld prewarmed: true, der erste Request kommt also ohne Kalt-Start-Latenz an.

Der HTTP-Server läuft auf Hummingbird, dem Swift-HTTP-Framework von swift-server/hummingbird. Die Logzeile [HummingbirdCore] Server started and listening on 127.0.0.1:11434 macht das sichtbar. Wer die hummingbird-llm-Reihe von rotecodefraktion kennt, erkennt dieselbe Server-Schicht: hier ist sie fertig in apfel verpackt, ohne dass wir eigenen Server-Code schreiben müssen. (Das schließen wir aus dem Log-Prefix, nicht aus einem Source-Audit.)

Server-Optionen. Der Banner zeigt die Defaults; alle lassen sich überschreiben:

Flag / EnvDefaultFunktion
--port <n> / APFEL_PORT11434TCP-Port
--host <addr> / APFEL_HOST127.0.0.1Bind-Adresse
--corsausCORS-Header für Browser-Clients
APFEL_TOKENkeinerBearer-Token; wenn gesetzt, brauchen alle Requests Authorization: Bearer <token>
--debugausRequest-Log und Event-Traces (Voraussetzung für /v1/logs)

Ein abgewandelter Start für lokale Entwicklung mit CORS-Freigabe und anderem Port:

apfel --serve --port 3000 --host 0.0.0.0 --cors

Zu den Implikationen von --host 0.0.0.0 kommen wir in der Sektion zur Sicherheitslage.

Die Endpoint-Landkarte

apfel stellt fünf Endpoints bereit:

EndpointMethodeOpenAI-PendantAnmerkung
/healthGETneinLiveness + Modell-Status
/v1/modelsGETjaModell-Karte mit supported/unsupported parameters
/v1/chat/completionsPOSTjaChat-Completion, non-stream und SSE
/v1/logsGETneinLetzte Requests mit vollen Bodies + Event-Trace
/v1/logs/statsGETneinAggregierte Stats

/health ist öffentlich, auch wenn APFEL_TOKEN gesetzt ist. Die beiden /v1/logs-Endpoints sind apfel-eigene Erweiterungen ohne OpenAI-Pendant; sie liefern nur Daten, wenn der Server mit --debug gestartet wurde. Ohne --debug antwortet apfel mit HTTP 400 und "Request log stats are only available when the server is started with --debug.".

/health gibt den Systemstatus auf einen Blick:

curl -s http://127.0.0.1:11434/health | jq .
{
  "active_requests": 0,
  "context_window": 4096,
  "model": "apple-foundationmodel",
  "model_available": true,
  "prewarmed": true,
  "status": "ok",
  "supported_languages": ["fr","da","it","pt","es","sv","de","vi","ja","nl","nb","zh","en","tr","ko"],
  "version": "1.5.1"
}

model_available: true bedeutet, das Foundation Model ist geladen und antwortet. context_window: 4096 ist der harte Limit-Wert, auf den wir in der Abweichungstabelle zurückkommen. Die Sprach-Liste in der Live-Antwort enthält einige Codes doppelt; oben die deduplizierte Menge. (Eigenmessung 2026-06-03 mit apfel 1.5.1 auf macOS 26.3.)

/v1/models liefert, was apfel über sich selbst deklariert:

curl -s http://127.0.0.1:11434/v1/models | jq '.data[0] | {id, owned_by, context_window, supported_parameters, unsupported_parameters}'

Das eine registrierte Modell ist apple-foundationmodel, owned_by: apple, context_window: 4096. Die Felder supported_parameters und unsupported_parameters sind apfel-eigene Ergänzungen zum OpenAI-Schema:

  • Unterstützt: temperature, max_tokens, seed, stream, tools, tool_choice, response_format, x_context_strategy, x_context_max_turns, x_context_output_reserve
  • Nicht unterstützt: logprobs, n, stop, presence_penalty, frequency_penalty

Die x_-Parameter sind apfels Kontext-Management-Flags aus dem Chat-Modus, die auf den Request-Body gemappt werden. Dazu mehr in der Abweichungssektion.

Ein Chat-Completion-Request von Hand

curl -s http://127.0.0.1:11434/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "apple-foundationmodel",
    "messages": [{"role": "user", "content": "Name three primary colors, comma separated."}],
    "temperature": 0
  }' | jq .

Antwort (real, Eigenmessung 2026-06-03):

{
  "id": "chatcmpl-...",
  "object": "chat.completion",
  "model": "apple-foundationmodel",
  "choices": [
    {
      "index": 0,
      "finish_reason": "stop",
      "message": {
        "role": "assistant",
        "content": "Red, blue, yellow.",
        "refusal": null
      }
    }
  ],
  "usage": {
    "prompt_tokens": 10,
    "completion_tokens": 4,
    "total_tokens": 14
  }
}

Das Shape folgt dem OpenAI-Standard. choices ist ein Array; bei apfel ist es immer ein Element (n=1 ist fest). finish_reason: stop bedeutet, das Modell hat die Antwort abgeschlossen. Das usage-Objekt zählt Tokens: Prompt (10), Completion (4), Summe (14).

Den Content direkt extrahieren:

curl -s http://127.0.0.1:11434/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"apple-foundationmodel","messages":[{"role":"user","content":"Name three primary colors, comma separated."}],"temperature":0}' \
  | jq -r '.choices[0].message.content'
# Red, blue, yellow.

Die Message-Rollen folgen dem OpenAI-Konvention: system für den System-Prompt, user für den Input, assistant für frühere Modell-Antworten im Gesprächsverlauf. Ein System-Prompt im Request:

curl -s http://127.0.0.1:11434/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "apple-foundationmodel",
    "messages": [
      {"role": "system", "content": "You answer in exactly one word."},
      {"role": "user", "content": "What is the capital of France?"}
    ],
    "temperature": 0
  }' | jq -r '.choices[0].message.content'

Streaming mit Server-Sent Events

Streaming aktivieren wir mit "stream": true. Das Protokoll ist Server-Sent Events (SSE): eine einzelne HTTP-Verbindung bleibt offen, der Server schickt die Antwort als Strom von data:-Zeilen, jede mit einem JSON-Objekt. Die Verbindung schließt mit der Sentinel-Zeile data: [DONE].

curl -s http://127.0.0.1:11434/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "apple-foundationmodel",
    "messages": [{"role": "user", "content": "Name three primary colors, comma separated."}],
    "temperature": 0,
    "stream": true
  }'

Ausgabe (schematisch, real gemessen):

data: {"id":"chatcmpl-...","object":"chat.completion.chunk","model":"apple-foundationmodel","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}

data: {"id":"chatcmpl-...","object":"chat.completion.chunk","model":"apple-foundationmodel","choices":[{"index":0,"delta":{"content":"Red, blue, yellow."},"finish_reason":null}]}

data: {"id":"chatcmpl-...","object":"chat.completion.chunk","model":"apple-foundationmodel","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: [DONE]

Die Reihenfolge ist immer dieselbe: erst ein Chunk mit delta.role, dann Chunk(s) mit delta.content, dann Chunk mit finish_reason: stop, dann [DONE].

Beobachtung bei kurzen Antworten: der Content kommt als ein einziger Chunk, nicht token-für-token. apfel streamt bei kurzen Outputs grob-granular. Bei längeren Antworten sehen wir mehrere Content-Chunks. Das ist keine Protokollverletzung, der Standard schreibt keine bestimmte Chunk-Granularität vor.

Wann Streaming sinnvoll ist: wenn der Client die Antwort während der Generierung anzeigen soll, etwa in einer Oberfläche mit Live-Ausgabe. Im Agenten, den wir in späteren Artikeln bauen, ist Streaming vor allem für Tool-Calling-Loops relevant, wo der Client auf den Abschluss eines Aufrufs wartet.

Wo das Protokoll von OpenAI abweicht

„OpenAI-kompatibel" heißt nicht „OpenAI-austauschbar". Diese Unterschiede sind beim Umstieg von einem echten OpenAI-Endpoint auf apfel relevant:

PunktOpenAIapfel
stopbis zu 4 Stop-SequenzenHTTP 400
nmehrere Choices pro RequestHTTP 400 (immer n=1)
logprobsToken-Log-ProbabilitiesHTTP 400
presence_penalty-2.0 bis 2.0HTTP 400
frequency_penalty-2.0 bis 2.0HTTP 400
Kontextfensterje nach Modell (8k–128k+)4096 Tokens
x_context_strategy etc.nicht vorhandenapfel-eigene Erweiterung
usage in SSE-Chunksoptional via stream_options.include_usagenicht im Chunk; nur in non-stream response

Den 400-Fehler bei unsupported parameters sieht man so:

curl -s http://127.0.0.1:11434/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "apple-foundationmodel",
    "messages": [{"role": "user", "content": "Test."}],
    "stop": ["\n"]
  }' | jq .
{
  "error": {
    "message": "Parameter 'stop' is not supported by Apple's on-device model.",
    "type": "invalid_request_error"
  }
}

Das ist die praktische Konsequenz für bestehende OpenAI-SDKs: viele SDKs schicken presence_penalty und frequency_penalty mit Default-Werten mit, auch wenn man sie nicht explizit setzt. Wer ein SDK unverändert an apfel klemmt, läuft in 400er. Die Felder müssen entweder im SDK-Aufruf explizit weggelassen oder auf null gesetzt werden, sofern das SDK das erlaubt.

Die drei x_-Parameter (x_context_strategy, x_context_max_turns, x_context_output_reserve) gehen in die andere Richtung: sie erweitern das Protokoll über OpenAI hinaus. Sie mappen apfels Kontext-Management-Flags aus dem Chat-Modus auf den Request-Body. Ein OpenAI-Client ignoriert sie, weil er sie nicht kennt; ein apfel-bewusster Client kann sie setzen, um Überlauf-Verhalten zu steuern.

Das 4096-Token-Kontextfenster ist der prominenteste Unterschied. Aktuelle OpenAI-Produktionsmodelle fangen bei 8k an und gehen bis 128k und mehr. Für den Agenten, den wir bauen, bedeutet das: Kontext sorgfältig verwalten, Tool-Outputs kürzen, keine langen Datei-Dumps in den Request-Body.

Warum wir gegen das Protokoll bauen, nicht gegen FoundationModels

apfel bietet zwei Wege ans Modell: die CLI (Artikel 1) und den Serve-Modus. Für den Agenten wählen wir den Serve-Modus als Schnittstelle, aus drei Gründen.

Austauschbarkeit. Jedes SDK, das einen OpenAI-Endpoint erwartet, dockt an http://127.0.0.1:11434 an. Wenn wir das on-device-Modell gegen ein externes Modell tauschen wollen (für Tests, für Fallback), ändern wir eine URL, nicht den Client-Code.

Testbarkeit. curl und Shell-Skripte sind die einfachsten denkbaren Clients. Wir können jeden Endpoint vor dem ersten Swift-Byte ausprobieren, Bugs isolieren und Smoke-Tests schreiben, die ohne Xcode laufen. scripts/smoke-serve.sh im Demo-Repo ist ein Beispiel: drei Zeilen Bash, die den Server starten, /health abfragen und einen Chat-Completion-Round-Trip machen.

Trennung. Die Agent-Logik kennt keine Foundation-Models-Framework-Details. Sie schickt HTTP-Requests und wertet JSON aus. Das Foundation Model ist ein austauschbarer Baustein hinter dem Protokoll, kein fester Kern der Architektur.

Die Sicherheitslage des Servers

Der Default-Start ist konservativ konfiguriert. Bind-Adresse 127.0.0.1 bedeutet: der Server ist nur vom lokalen Rechner erreichbar, nicht vom Netzwerk. CORS ist aus, Token-Auth ist aus.

APFEL_TOKEN schützt den Server mit einem Bearer-Token:

APFEL_TOKEN=mein-geheimnis apfel --serve

Alle Requests brauchen dann:

curl -s http://127.0.0.1:11434/v1/chat/completions \
  -H "Authorization: Bearer mein-geheimnis" \
  -H "Content-Type: application/json" \
  -d '...'

Requests ohne gültigen Token kommen mit HTTP 401 zurück. Der /health-Endpoint bleibt auch mit gesetztem Token öffentlich erreichbar, was für Liveness-Probes sinnvoll ist.

Die gefährliche Kombination ist --host 0.0.0.0 --cors ohne APFEL_TOKEN. Damit ist das Modell für jeden im lokalen Netzwerk erreichbar und Browser-Requests sind erlaubt. Wer das in einem Unternehmensnetz aufmacht, öffnet einen Modell-Endpoint ohne Zugangskontrolle. Für die lokale Entwicklung am eigenen Rechner ist der Default (127.0.0.1, kein Token, CORS aus) ausreichend und richtig.

Beobachtbarkeit über den logs-Endpoint

Mit --debug protokolliert apfel jeden Request mit vollem Body und einem Event-Trace:

apfel --serve --debug
curl -s http://127.0.0.1:11434/v1/logs | jq .
curl -s http://127.0.0.1:11434/v1/logs/stats | jq .

/v1/logs/stats (real, Eigenmessung 2026-06-03):

{
  "total_requests": 5,
  "total_errors": 1,
  "avg_duration_ms": 503,
  "requests_per_minute": 8.1,
  "max_concurrent": 5,
  "active_requests": 0,
  "uptime_seconds": 37
}

Für den Agenten in späteren Artikeln ist /v1/logs das wichtigste Debugging-Fenster. Wenn ein Tool-Calling-Loop nicht das tut, was wir erwarten, sehen wir in /v1/logs, was der Agent tatsächlich an das Modell geschickt hat, nicht nur, was wir im Swift-Code hingeschrieben haben. Das ist der Unterschied zwischen Logging auf der Client-Seite und Logging auf der Protokoll-Ebene.

avg_duration_ms: 503 zeigt die mittlere Request-Latenz für kurze Prompts (Eigenmessung 2026-06-03 mit apfel 1.5.1 auf macOS 26.3, M-Series-Mac). Der Wert schwankt je nach Prompt-Länge und Modell-Auslastung; für den Agenten-Loop ist er ein nützlicher Referenzpunkt.

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

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

Demo-Repo apfel-coding-agent v0.2 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.2
chmod +x scripts/*.sh

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

  • docs/serve-protocol.md — Endpoint-Referenz + Abweichungstabelle OpenAI vs. apfel
  • scripts/curl-examples.sh — Round-Trips über alle Endpoints inkl. SSE-Streaming und 400-Fehlerpfad
  • scripts/smoke-serve.sh — startet den Server, prüft /health, führt einen Chat-Completion-Round-Trip durch; exit 0 = grün

Erster Test:

./scripts/smoke-serve.sh

Wenn der letzte Output SMOKE OK zeigt, läuft alles. Das Skript beendet sich sauber, der Server wird danach gestoppt.

Stolpersteine aus dem Bau

Ollama-Port-Kollision. apfels Default-Port 11434 ist derselbe wie Ollama. Läuft Ollama im Hintergrund, antwortet curl http://127.0.0.1:11434/ mit dem Text Ollama is running statt apfel-JSON. Das sieht aus wie ein apfel-Fehler und ist keiner. Lösung: Ollama stoppen oder apfel auf einem anderen Port starten (--port 3001).

/v1/logs braucht --debug. Ohne das Flag kommt ein HTTP 400 mit der Meldung „Request log stats are only available when the server is started with –debug." Das steht nicht prominent in der Basis-Hilfe; der Fehlertext liefert die Erklärung erst beim ersten Treffer.

400 bei unsupported parameters. Viele OpenAI-SDKs schicken presence_penalty und frequency_penalty mit Default-Werten mit, ohne dass man sie explizit setzt. Das führt sofort zu einem 400-Fehler bei apfel. Wer ein OpenAI-SDK blind anklemmt und sich über 400er wundert, schaut zuerst auf den Request-Body und nicht auf den apfel-Server. Das Debugging-Pattern: Request mit curl von Hand nachbauen und Felder einzeln hinzufügen, bis der Fehler erscheint.

Wie es weitergeht

Artikel 3 baut den Swift-Client, der an diesen Server andockt. Wir legen ein Swift-Package an, verbinden uns über das URLSession-basierte HTTP-Layer mit /v1/chat/completions und verarbeiten SSE-Streaming im Client. Das OpenAI-Protokoll, das wir in diesem Artikel von Hand durchgespielt haben, ist die Grundlage, auf der der Client sitzt.


Vorheriger Artikel: apfel von der Kommandozeile. Nächster Artikel: Der Swift-Client: erste Verbindung zum Modell (Platzhalter — Link wird mit Publish von Artikel 3 final). Repo-Tag: v0.2.