Build a CometAPI Kotlin Client with Ktor and kotlinx.serialization
Last reviewed: November 6, 2026 (Asia/Shanghai); official references fetched November 6, 2026 UTC.
Direct answer
Build the client around one application-owned Ktor HttpClient(CIO) with ContentNegotiation and the kotlinx JSON serializer. Model the smallest request and response contract you can accept, send POST /v1/chat/completions with a bearer key from process configuration, check the HTTP status and completion shape, then close the client at the application boundary. Keep timeouts, response limits, and model selection in trusted configuration.
CometAPI’s current chat-completion reference
documents the OpenAI-compatible route, model, messages, streaming, finish reasons, and usage fields. Ktor’s client application guide
documents the CIO engine and the need to close an HttpClient; its request guide
documents post, headers, contentType, and setBody. Ktor’s serialization guide
and Kotlin’s serialization documentation
provide the JSON plugin and @Serializable model boundary used below.
The example intentionally accepts one ordinary assistant text answer. A refusal, tool call, length finish, malformed JSON document, or empty content is an explicit application outcome. HTTP 200 alone does not mean the result meets that contract.
Who this is for
Use this guide for a Kotlin/JVM backend, worker, or command-line application that wants direct ownership of HTTP behavior without hiding the wire contract behind a provider-specific SDK. You need a Gradle Kotlin project, a current Kotlin serialization plugin, and a current Ktor release. The Ktor page currently displays version 3.6.1; use your project’s approved version catalog rather than copying a version indefinitely.
The sample uses non-streaming Chat Completions and a short text prompt. It does not claim an authenticated request, a deployment, a production latency result, or a particular model’s availability. Check the live model catalog and account permissions before a staging smoke test. If you need Python’s asynchronous client pattern, compare the site’s HTTPX client guide separately.
Key takeaways
- Reuse one
HttpClientfor the application lifetime and close it during shutdown. - Keep the API key in environment or secret-manager configuration; never put it in a request body, URL, or log.
- Use
ContentNegotiation { json(...) }sosetBodyandbody<ChatResponse>()share one explicit serializer. - Set a connect, request, and socket timeout appropriate to the service; these are application controls, not provider defaults.
- Check status before deserializing and validate the response object, choice count, role, finish reason, refusal, and non-blank content.
- Treat streaming as a separate protocol. This article sends
stream: false; SSE needs its own decoder, cancellation, and size policy. - Test with a local fake endpoint first. A passing fixture proves local serialization and parsing, not credentials or live model access.
Set up the Kotlin project
Add Ktor’s core and CIO engine, content-negotiation support, kotlinx JSON serialization, and the Kotlin serialization compiler plugin through your normal Gradle version catalog. The Ktor documentation identifies these roles as ktor-client-core, ktor-client-cio, ktor-client-content-negotiation, and ktor-serialization-kotlinx-json. A minimal dependency block is:
plugins {
kotlin("jvm") version "<approved-kotlin-version>"
kotlin("plugin.serialization") version "<same-kotlin-version>"
}
dependencies {
implementation("io.ktor:ktor-client-core:<approved-ktor-version>")
implementation("io.ktor:ktor-client-cio:<approved-ktor-version>")
implementation("io.ktor:ktor-client-content-negotiation:<approved-ktor-version>")
implementation("io.ktor:ktor-serialization-kotlinx-json:<approved-ktor-version>")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:<approved-json-version>")
}
Pin versions through the project’s dependency management and review updates as a set. The Kotlin documentation explains that the serialization plugin generates serializers for @Serializable classes; without the plugin, the data classes below cannot be encoded through kotlinx.serialization.
Define a narrow wire contract
Keep the data classes close to the fields the application actually needs. CometAPI’s reference includes optional request parameters, multimodal content, streaming chunks, structured output, and tool calls. Starting with a plain text, non-streaming request reduces accidental assumptions.
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
@Serializable
data class ChatMessage(
val role: String,
val content: String? = null,
val refusal: String? = null,
@SerialName("tool_calls")
val toolCalls: List<JsonObject>? = null
)
@Serializable
data class ChatRequest(
val model: String,
val messages: List<ChatMessage>,
val stream: Boolean = false,
@SerialName("max_completion_tokens")
val maxCompletionTokens: Int? = null
)
@Serializable
data class ChatChoice(
val message: ChatMessage,
@SerialName("finish_reason")
val finishReason: String? = null
)
@Serializable
data class ChatResponse(
val id: String? = null,
val model: String? = null,
@SerialName("object")
val objectName: String? = null,
val choices: List<ChatChoice> = emptyList()
)
The response’s JSON key is object, so map it with @SerialName("object") rather than relying on a Kotlin property named object. You can add usage with another serializable data class when the application needs token evidence; do not infer billing from a local estimate.
Configure Ktor and make the request
The client below keeps the key outside the data model and uses Ktor’s JSON converter for both serialization and deserialization. The exact timeout values are illustrative application settings.
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
class CometClient(
private val apiKey: String,
private val model: String,
private val endpoint: String = "https://api.cometapi.com/v1/chat/completions",
private val http: HttpClient = HttpClient(CIO) {
install(HttpTimeout) {
requestTimeoutMillis = 30_000
connectTimeoutMillis = 5_000
socketTimeoutMillis = 25_000
}
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
explicitNulls = false
})
}
}
) {
init {
require(apiKey.isNotBlank()) { "missing_api_key" }
require(model.isNotBlank()) { "missing_model" }
require(endpoint.startsWith("https://")) { "endpoint_must_use_https" }
}
suspend fun complete(prompt: String): String {
require(prompt.isNotBlank() && prompt.length <= 8_000) {
"invalid_prompt"
}
val response: HttpResponse = http.post(endpoint) {
header(HttpHeaders.Authorization, "Bearer $apiKey")
contentType(ContentType.Application.Json)
setBody(
ChatRequest(
model = model,
messages = listOf(ChatMessage("user", prompt)),
stream = false,
maxCompletionTokens = 512
)
)
}
if (response.status.value !in 200..299) {
throw CometHttpException(response.status.value)
}
val data: ChatResponse = response.body()
if (data.objectName != "chat.completion") {
throw CometResponseException("unexpected_object")
}
if (data.choices.size != 1) {
throw CometResponseException("unexpected_choice_count")
}
val choice = data.choices.single()
if (choice.finishReason != "stop" || choice.message.role != "assistant" ||
choice.message.refusal != null || choice.message.toolCalls != null) {
throw CometResponseException("unaccepted_finish_or_role_or_tool")
}
val text = choice.message.content?.trim().orEmpty()
if (text.isEmpty()) throw CometResponseException("empty_content")
return text
}
fun close() = http.close()
}
class CometHttpException(val statusCode: Int) :
RuntimeException("cometapi_http_$statusCode")
class CometResponseException(code: String) :
RuntimeException("cometapi_response_$code")
The Ktor request guide documents post, contentType, and setBody; the serialization guide explains why ContentNegotiation can turn the ChatRequest object into JSON and decode ChatResponse. Ktor’s timeout reference
distinguishes the whole request, connection establishment, and inactivity between packets. ignoreUnknownKeys allows additive provider fields without making the client discard a response, while the application still checks the fields it needs. If your policy requires rejection of every unrecognized field, set it to false and maintain the model explicitly.
The method checks status before body<ChatResponse>(). That keeps an HTML proxy error or provider error body from being mistaken for a successful completion. The code distinguishes transport timeout, non-2xx status, malformed JSON, and an unaccepted completion. Map those exceptions to your application’s stable error contract without returning the upstream body to an end user.
Contract details to verify
Before a staging call, verify the boundaries that the client and provider must share. Keep this checklist beside the code and recheck it whenever Ktor, the selected model, or the endpoint configuration changes.
| Boundary | Local check | Evidence to retain |
|---|---|---|
| Endpoint and method | Confirm the configured URL is HTTPS and ends in /v1/chat/completions; inspect that the request method is POST. | Redacted request metadata and the current CometAPI chat reference. |
| Authentication | Read the key from the runtime secret mechanism and send it only as a bearer header. | Header-presence assertion with the value redacted; no key in logs or serialized models. |
| JSON request | Serialize ChatRequest through ContentNegotiation; verify model, messages, stream: false, and the optional token ceiling. | Parsed fake-server body and content type. |
| Timeout budget | Set request, connection, and socket limits deliberately. Ktor defines request timeout as the whole HTTP call, connection timeout as establishment, and socket timeout as inactivity between packets. | Configuration review plus a fake server that delays connection and response bytes separately. |
| Response envelope | Check status, JSON decoding, object: chat.completion, exactly one choice, assistant role, and finish_reason: stop. | Success fixture and negative fixtures for non-2xx, malformed JSON, wrong object, and multiple choices. |
| Result safety | Reject refusal, tool calls, blank content, and length completion unless the application has a separate typed result. | Assertions showing each outcome remains distinct from ordinary text. |
| Lifetime | Reuse one client across requests and call close() during shutdown. | Resource and shutdown test showing the engine is closed once. |
The CometAPI reference documents additional fields for streaming, structured output, and tools. Do not add those fields because they appear in an example. Extend the serializable models only after checking the selected model’s current support and writing an acceptance policy for the new response mode. A tool call also needs authorization and idempotency rules before any side effect is executed.
Keep local limits separate from provider limits. The 8,000-character prompt check, 512 completion-token ceiling, and timeout values in this article are application settings. They are useful defaults for a bounded example, but they do not establish an account quota, model maximum, billing rule, or service-level guarantee. Confirm current model availability and account permissions in a separately authorized staging request.
When a timeout or connection failure occurs, the remote outcome may be uncertain. Record an attempt identifier and apply a bounded retry policy only if replay is safe for the user action. Do not treat a local cancellation as proof that generation stopped or that no usage was recorded. Reconcile usage evidence with the account’s billing records before making cost claims.
Own lifetime and configuration
Create one client at the service boundary, pass it to request handlers, and close it in a shutdown hook or structured lifecycle. Do not construct a new CIO client per request: each instance owns connection and coroutine resources. Ktor’s client guide explicitly calls close() the operation that releases streams and associated resources.
Read the key and model from a secret manager or environment at startup: COMETAPI_KEY and COMETAPI_MODEL are reasonable names. Validate the model against the current CometAPI catalog in a controlled staging check. Never log the Authorization header, prompt, completion, or complete exception body. A sanitized event can include operation, model, status_class, outcome, and elapsed milliseconds.
For a response-size boundary in another runtime, see the site’s Node.js byte-limit guide . The same principle applies here: a token limit is not a byte limit, and a client should choose a bounded body policy before accepting untrusted or unexpectedly large data.
Verify without a live request
Use a fake Ktor engine or a local HTTP server to inspect the outgoing method, URL, authorization shape, content type, and JSON body. Return a synthetic chat.completion document and assert that complete() returns its assistant text. Then exercise these negative cases:
| Fixture | Required result |
|---|---|
| Empty prompt or prompt over the local limit | Reject before the network call. |
| 401, 429, or 500 response | Throw a status-class exception; do not parse it as success. |
| Valid JSON with zero or two choices | Throw an explicit shape error. |
finish_reason: length or tool_calls | Do not return ordinary text; use a separate result type if needed. |
| Empty assistant content or refusal | Reject the application result. |
| Malformed JSON or wrong content type | Surface a response-contract failure. |
| Client close after a request | Release the engine and streams exactly once. |
A fake transport proves local behavior only. It does not prove account access, model availability, provider latency, billing, or a deployment’s proxy behavior. Make one separately authorized staging request with harmless text after local checks pass, and retain only status class, model ID, response-contract verdict, elapsed time, and redaction evidence.
Failure modes
A new client is created for every call. Connection pools and resources multiply. Own one client per service lifecycle and close it once.
The API key enters a data class or URL. It can leak through serialization, traces, or proxy logs. Keep it in a request header supplied at call time.
A 200 response is accepted without checking the choice. Refusals, tool calls, and length-limited output are different application outcomes. Validate role, finish reason, content, and any refusal field you model.
The provider adds a field and strict parsing fails unexpectedly. Decide whether additive fields are acceptable. ignoreUnknownKeys can preserve compatibility, but it does not replace required-field checks.
A timeout is retried without an attempt policy. The remote result may be uncertain. Carry a total deadline, maximum attempts, and replay policy; do not assume a local timeout means no upstream work occurred.
Streaming is treated as one JSON body. stream: true returns SSE chunks and a [DONE] marker. Build a separate incremental parser with event and aggregate limits, cancellation, and usage handling.
FAQ
Does Ktor’s HttpClient use coroutines?
Yes. Ktor request functions are suspending calls, so invoke them from a coroutine or another suspend function. The client’s lifetime still belongs to the application, not to an individual coroutine.
Why use kotlinx.serialization instead of parsing a map?
Serializable data classes make the accepted wire contract visible and testable. A map can be useful for exploratory fields, but it makes required fields, nullability, and response shape easier to overlook.
Should ignoreUnknownKeys be enabled?
It is a compatibility choice. Enable it when additive provider fields should not break a client, while validating every field the application relies on. Disable it when a closed schema is a deliberate security or change-control requirement.
Can this client handle tool calls?
Not as written. CometAPI documents tool_calls and a distinct finish_reason. Add serializable tool-call types, authorization, idempotency, and a side-effect policy before exposing tools. Do not coerce a tool call into assistant text.
Is this a streaming client?
No. The request sets stream = false and decodes one JSON document. SSE requires a stateful event parser and different timeout and backpressure rules.
Reader next step
Create a small Gradle project with the approved Ktor and Kotlin versions. Add the serializable request and response models, then run the fake transport matrix before setting any real credential. Inspect the serialized body and verify that the key never appears in logs.
After the local checks pass, make one harmless staging request with a current model ID and record only sanitized operational fields. Recheck the Ktor request and serialization references when upgrading Ktor, and compare the site’s typed TypeScript request guide if your team maintains clients in more than one language.
Sources checked
- CometAPI: Create a chat completion — fetched November 6, 2026 UTC; supports the endpoint, authentication, request fields, non-streaming response shape, finish reasons, usage, and streaming distinction.
- Ktor: Create a client application
— fetched November 6, 2026 UTC; supports the CIO engine, Gradle dependencies, suspending calls, response status, and
close(). - Ktor: Making requests
— fetched November 6, 2026 UTC; supports HTTP methods, headers, JSON content type,
setBody, and response handling. - Ktor: Content negotiation and serialization in Ktor Client
— fetched November 6, 2026 UTC; supports
ContentNegotiation, kotlinx JSON dependencies,install, andjson(). - Ktor: Timeout
— fetched November 6, 2026 UTC; supports request, connection, and socket timeout meanings and
HttpTimeoutconfiguration. - Kotlin: Serialization
— fetched November 6, 2026 UTC; supports the serialization plugin,
@Serializablemodels, and supported serialization formats.
These official references support an evergreen Kotlin integration task. No authenticated CometAPI call, deployment, production benchmark, or billing outcome is claimed.