n4nAI

Calling the OpenAI API from Kotlin with Retrofit

Hands-on Kotlin Retrofit tutorial for the OpenAI API: step-by-step models, auth, streaming, and error handling with runnable code.

n4n Team2 min read471 words

Audio narration

Coming soon — every post will get a voice note here.

The kotlin retrofit openai api combination is the fastest path to a typed LLM client on the JVM or Android. This tutorial builds a minimal but production-shaped integration: data models, authenticated calls, streaming, and failure handling, all with runnable Kotlin.

Prerequisites

  • Kotlin 1.9+ (JVM or Android minSdk 24)
  • Coroutines familiarity (suspend, viewModelScope)
  • Retrofit 2.11 and OkHttp 4.12
  • An OpenAI API key (or a key for any OpenAI-compatible endpoint)
  • Gradle Kotlin DSL build file access

If you are on Android, use a viewModelScope instead of runBlocking for UI calls. The network code is identical.

Dependencies

Add these to your module build.gradle.kts. Versions are current as of mid-2024; bump as needed.

dependencies {
    implementation("com.squareup.retrofit2:retrofit:2.11.0")
    implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
    testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
}

Apply the serialization plugin:

plugins {
    kotlin("plugin.serialization") version "1.9.24"
}

Data Models

OpenAI’s chat completion contract is stable but verbose. Declare only the fields you use. kotlinx.serialization handles the JSON mapping.

import kotlinx.serialization.*

@Serializable
data class ChatMessage(val role: String, val content: String)

@Serializable
data class ChatRequest(
    val model: String,
    val messages: List<ChatMessage>,
    val temperature: Double = 0.7,
    val stream: Boolean = false
)

@Serializable
data class ChatChoice(
    val index: Int,
    val message: ChatMessage,
    val finish_reason: String? = null
)

@Serializable
data class ChatResponse(
    val id: String,
    val `object`: String,
    val created: Long,
    val model: String,
    val choices: List<ChatChoice>
)

Ignore unknown keys so provider additions don’t break your client:

val json = Json { ignoreUnknownKeys = true }

Retrofit Interface

A single POST method is enough for non-streaming calls. Use suspend for coroutine support.

import retrofit2.http.Body
import retrofit2.http.POST

interface OpenAIService {
    @POST("chat/completions")
    suspend fun createChatCompletion(@Body request: ChatRequest): ChatResponse
}

The kotlin retrofit openai api pattern stays clean because the URL path is relative to the base URL defined later.

Auth and Client Setup

Never hardcode keys. Use an OkHttp interceptor to inject the bearer token and set sane timeouts for LLM latency.

import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit

val authInterceptor = Interceptor { chain ->
    val req = chain.request().newBuilder()
        .addHeader("Authorization", "Bearer ${System.getenv("OPENAI_API_KEY")}")
        .build()
    chain.proceed(req)
}

val client = OkHttpClient.Builder()
    .addInterceptor(authInterceptor)
    .connectTimeout(30, TimeUnit.SECONDS)
    .readTimeout(60, TimeUnit.SECONDS)
    .build()

val retrofit = Retrofit.Builder()
    .baseUrl("https://api.openai.com/v1/")
    .client(client)
    .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
    .build()

val service = retrofit.create(OpenAIService::class.java)

Making the Call

Run a quick sanity check from a main function.

import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    val request = ChatRequest(
        model = "gpt-4o-mini",
        messages = listOf(ChatMessage("user", "Explain Retrofit in one sentence."))
    )
    val response = service.createChatCompletion(request)
    println("Model: ${response.model}")
    println("Reply: ${response.choices.first().message.content}")
}

Expected output:

Model: gpt-4o-mini
Reply: Retrofit is a type-safe HTTP client for Android and Java that turns REST endpoints into declarative Kotlin interfaces.

That confirms the kotlin retrofit openai api wiring works end to end.

Streaming Responses

For chat UIs you want token-by-token output. OpenAI streams Server-Sent Events. Retrofit can’t map SSE directly, so use @Streaming and parse the raw body.

First, add a chunk model:

@Serializable
data class ChatChunk(
    val id: String,
    val choices: List<ChatChunkChoice>
)

@Serializable
data class ChatChunkChoice(val delta: Delta, val finish_reason: String? = null)

@Serializable
data class Delta(val content: String? = null)

Extend the interface:

import retrofit2.Call
import retrofit2.http.Streaming
import okhttp3.ResponseBody

interface OpenAIService {
    @POST("chat/completions")
    suspend fun createChatCompletion(@Body request: ChatRequest): ChatResponse

    @POST("chat/completions")
    @Streaming
    fun streamChatCompletion(@Body request: ChatRequest): Call<ResponseBody>
}

Consume the stream:

import okio.BufferedSource

fun streamExample() {
    val call = service.streamChatCompletion(ChatRequest("gpt-4o-mini",
        listOf(ChatMessage("user", "Count to 3.")), stream = true))
    val response = call.execute()
    val source: BufferedSource = response.body!!.source()
    while (!source.exhausted()) {
        val line = source.readUtf8Line() ?: continue
        if (!line.startsWith("data:")) continue
        val data = line.removePrefix("data:").trim()
        if (data == "[DONE]") break
        val chunk = json.decodeFromString<ChatChunk>(data)
        chunk.choices.firstOrNull()?.delta?.content?.let { print(it) }
    }
}

You now have incremental rendering with the same kotlin retrofit openai api client.

Error Handling

OpenAI returns 429 for rate limits and 5xx for upstream faults. Wrap calls in Response<T> or catch HttpException.

import retrofit2.Response

@POST("chat/completions")
suspend fun createChatCompletionSafe(@Body request: ChatRequest): Response<ChatResponse>

suspend fun askSafe(prompt: String): String? {
    val resp = service.createChatCompletionSafe(
        ChatRequest("gpt-4o-mini", listOf(ChatMessage("user", prompt)))
    )
    if (!resp.isSuccessful) {
        println("Error ${resp.code()}: ${resp.errorBody()?.string()}")
        return null
    }
    return resp.body()?.choices?.first()?.message?.content
}

For retry, use OkHttp’s RetryInterceptor or a small loop with exponential backoff on 429/503.

Android ViewModel Integration

On Android, inject the service and use viewModelScope:

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch

class ChatViewModel(private val service: OpenAIService) : ViewModel() {
    val reply = MutableStateFlow("")

    fun ask(prompt: String) = viewModelScope.launch {
        try {
            val resp = service.createChatCompletion(
                ChatRequest("gpt-4o-mini", listOf(ChatMessage("user", prompt)))
            )
            reply.value = resp.choices.first().message.content
        } catch (e: Exception) {
            reply.value = "Failed: ${e.message}"
        }
    }
}

Keep the Retrofit instance as a singleton in a NetworkModule to avoid leaking connections.

Testing with MockWebServer

Validate parsing without hitting the network:

import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.MockResponse
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals

class OpenAIServiceTest {
    @Test
    fun parsesResponse() = runBlocking {
        val server = MockWebServer()
        server.enqueue(MockResponse().setBody(
            """{"id":"1","object":"chat.completion","created":0,"model":"gpt-4o-mini",
            "choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}"""
        ))
        val svc = retrofit.newBuilder().baseUrl(server.url("/")).build()
            .create(OpenAIService::class.java)
        val resp = svc.createChatCompletion(ChatRequest("gpt-4o-mini", emptyList()))
        assertEquals("ok", resp.choices.first().message.content)
        server.shutdown()
    }
}

Using an OpenAI-Compatible Gateway

If you want automatic fallback when a provider is rate-limited, point the base URL at an OpenAI-compatible gateway. For example, n4n.ai exposes one OpenAI-compatible endpoint covering 240+ models and forwards provider cache-control hints, so the same ChatRequest and Retrofit interface work unchanged:

.baseUrl("https://api.n4n.ai/v1/")

You get per-token metering and failover without writing retry logic. The kotlin retrofit openai api client needs zero modifications beyond the URL.

Checkpoint: Full Log

A complete non-streaming run prints:

Model: gpt-4o-mini
Reply: Retrofit is a type-safe HTTP client for Android and Java that turns REST endpoints into declarative Kotlin interfaces.

Streaming prints the same text without the newlines, token by token. With the gateway swap, the log is identical but the request may have been served by a different backend provider after a fallback.

Retrofit is not the only HTTP client, but for typed LLM calls on Kotlin it removes boilerplate and keeps your surface area small. Ship the interface, test it with MockWebServer, and handle 429 before you scale.

Tagskotlinandroidretrofitopenai-api

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All kotlin & android llm integration posts →