Most Kotlin backend and Android apps talk to LLM endpoints over HTTP and get back JSON that looks simple until it isn’t. In this tutorial we build a robust kotlinx.serialization llm api parsing layer for OpenAI-compatible chat completions, covering data modeling, unknown fields, streaming chunks, and error envelopes.
Prerequisites
- Kotlin 1.9.0+ (tested with 1.9.22)
kotlinx-serialization-json1.6.0+- Coroutines 1.7+ for async clients
- A JVM or Android project; Gradle Kotlin DSL
- Familiarity with the OpenAI chat completion JSON shape
Add the dependency:
// build.gradle.kts
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
}
If you plan to issue live requests, also pull in a client. We use Ktor CIO later:
implementation("io.ktor:ktor-client-cio:2.3.7")
implementation("io.ktor:ktor-client-content-negotiation:2.3.7")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.7")
Modeling the non-streaming response
The OpenAI completion object nests choices → message. Tool calls add a nested function with an arguments string that is itself JSON. Model it explicitly so the parser doesn’t choke.
import kotlinx.serialization.*
import kotlinx.serialization.json.*
@Serializable
data class ChatCompletion(
val id: String,
val `object`: String = "chat.completion",
val created: Long,
val model: String,
val choices: List<Choice>,
val usage: Usage? = null,
val system_fingerprint: String? = null
)
@Serializable
data class Choice(
val index: Int,
val message: Message,
val finish_reason: String? = null
)
@Serializable
data class Message(
val role: String,
val content: String? = null,
val tool_calls: List<ToolCall>? = null
)
@Serializable
data class ToolCall(
val id: String,
val type: String = "function",
val function: FunctionCall
)
@Serializable
data class FunctionCall(
val name: String,
val arguments: String // raw JSON string, not an object
)
@Serializable
data class Usage(
val prompt_tokens: Int,
val completion_tokens: Int,
val total_tokens: Int
)
Configure the parser to tolerate fields you didn’t model:
val json = Json {
ignoreUnknownKeys = true
isLenient = true
coerceInputValues = true
}
Parse a concrete payload:
val sample = """
{
"id": "chatcmpl-abc",
"object": "chat.completion",
"created": 1699000000,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "Hello!" },
"finish_reason": "stop"
}
],
"usage": { "prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12 }
}
"""
val resp = json.decodeFromString<ChatCompletion>(sample)
println(resp.choices.first().message.content)
Expected output:
Hello!
Dealing with unknown fields and provider metadata
Real gateways inject extra keys. A response might carry provider or cached flags. Because ignoreUnknownKeys is on, your core model is safe. If you want the metadata typed, add an optional field:
@Serializable
data class ProviderMeta(
val id: String? = null,
val cached: Boolean? = null
)
@Serializable
data class ChatCompletion(
val id: String,
val `object`: String = "chat.completion",
val created: Long,
val model: String,
val choices: List<Choice>,
val usage: Usage? = null,
val system_fingerprint: String? = null,
val provider: ProviderMeta? = null
)
If you point the base URL at n4n.ai’s OpenAI-compatible endpoint, the same ChatCompletion model decodes responses from 240+ models without changes, and per-token usage metering arrives in the usage field as shown above. Provider cache-control hints are forwarded in headers, not the body, so they never perturb your JSON mapping.
Streaming chunks
Streaming changes message to delta and sends many small objects terminated by data: [DONE]. Model the chunk separately.
@Serializable
data class ChatChunk(
val id: String,
val `object`: String = "chat.completion.chunk",
val created: Long,
val model: String,
val choices: List<ChunkChoice>
)
@Serializable
data class ChunkChoice(
val index: Int,
val delta: Delta,
val finish_reason: String? = null
)
@Serializable
data class Delta(
val role: String? = null,
val content: String? = null,
val tool_calls: List<ToolCallDelta>? = null
)
@Serializable
data class ToolCallDelta(
val index: Int,
val id: String? = null,
val function: FunctionCallDelta? = null
)
@Serializable
data class FunctionCallDelta(
val name: String? = null,
val arguments: String? = null
)
A minimal SSE line parser:
fun parseSseLine(line: String): ChatChunk? {
if (!line.startsWith("data:")) return null
val payload = line.removePrefix("data:").trim()
if (payload == "[DONE]") return null
return json.decodeFromString<ChatChunk>(payload)
}
Test it:
val line = """data: {"id":"1","object":"chat.completion.chunk","created":1,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":"Hi"}}]}"""
val chunk = parseSseLine(line)
println(chunk?.choices?.first()?.delta?.content)
Expected output:
Hi
Accumulate content across chunks in a StringBuilder and capture finish_reason when non-null.
Error envelopes
A 4xx or 5xx returns a different shape:
{
"error": {
"message": "Rate limit reached",
"type": "rate_limit_error",
"code": "429"
}
}
Model it and branch on HTTP status before decoding success:
@Serializable
data class ErrorEnvelope(val error: ErrorBody)
@Serializable
data class ErrorBody(
val message: String,
val type: String? = null,
val param: String? = null,
val code: String? = null
)
fun parseError(body: String): ErrorBody? =
runCatching { json.decodeFromString<ErrorEnvelope>(body).error }
.getOrNull()
Making the call with Ktor
Wire the models to a real client. ContentNegotiation uses the same Json instance.
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.client.request.*
import io.ktor.http.*
val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(json)
}
}
suspend fun complete(prompt: String): String {
val resp = client.post("https://api.openai.com/v1/chat/completions") {
contentType(ContentType.Application.Json)
setBody(
mapOf(
"model" to "gpt-4o-mini",
"messages" to listOf(mapOf("role" to "user", "content" to prompt))
)
)
}.body<ChatCompletion>()
return resp.choices.first().message.content ?: ""
}
Swap the URL for any OpenAI-compatible base. The kotlinx.serialization llm api parsing code stays identical because the contract is stable.
Defensive patterns for production
Decode tool arguments lazily. The arguments string is JSON, not a struct. Parse only when needed:
val args: Map<String, JsonElement> =
json.decodeFromString(toolCall.function.arguments)
Use @SerialName for snake_case if you prefer camelCase properties:
@Serializable
data class Usage(
@SerialName("prompt_tokens") val promptTokens: Int,
@SerialName("completion_tokens") val completionTokens: Int,
@SerialName("total_tokens") val totalTokens: Int
)
Keep a raw escape hatch. When a provider ships an unexpected nested object, map it as JsonObject and inspect later:
@Serializable
data class ChatCompletion(
// ...
val extensions: JsonObject? = null
)
Never assume content is non-null. Assistant turns with tool calls omit content. Check message.tool_calls first.
The kotlinx.serialization llm api parsing approach above gives you a strict, compile-time-checked boundary between unreliable network JSON and your Kotlin domain types. Build the models once, ignore the noise, and decode streaming and errors with the same primitives.