Installation¶
Requirements¶
- Kotlin 2.4 or later, on any of the platforms below.
- JDK 17 or newer to run on the JVM. jev4k is compiled to Java 17 bytecode, so it embeds in applications on 17, 21, 25, or anything later. Building jev4k itself uses JDK 25; Gradle's toolchain support downloads it if it's missing.
- A TypeSafe API key.
jev4k is built on the Ktor client, kotlinx.serialization, kotlinx.coroutines and kotlinx-io. Ktor core,
kotlinx.serialization-json, kotlinx.coroutines and kotlinx-io-core are exposed as api dependencies, because their
types (JsonElement, HttpClientEngine, suspend functions, and the Path that JevImage.read takes) appear in
jev4k's public API.
Platforms¶
jev4k is a Kotlin Multiplatform library. The whole API is common code, so the same queries, handles and answers work everywhere; only the HTTP engine underneath changes.
| Platform | Targets | Default engine |
|---|---|---|
| JVM | jvm (Java 17 bytecode) |
CIO |
| Apple | macOS, iOS, tvOS and watchOS, with the simulators | Darwin (NSURLSession) |
| Linux | linuxX64, linuxArm64 |
Curl |
| Windows | mingwX64 |
WinHttp |
| Node.js | js, wasmJs |
Js (fetch) |
The Apple targets are macosArm64, iosArm64, iosX64, iosSimulatorArm64, tvosArm64, tvosSimulatorArm64,
watchosArm32, watchosArm64, watchosSimulatorArm64 and watchosDeviceArm64.
A few things differ by platform:
- Blocking calls are JVM-only.
jev.blockinghas its methods on the JVM alone; everywhere else, call the suspend API. - Node.js, not the browser. The
jsandwasmJsartifacts are built and tested for Node.js. Nothing stops them loading in a browser, but a page would hand the API key to every visitor, so a browser app should call a backend of your own instead. - Linux needs CA certificates. The Curl engine checks the server's certificate against the system's CA bundle
(the
ca-certificatespackage on Debian and Ubuntu). Without one, every request fails with aJevConnectionException. - iOS refuses plain
http://. App Transport Security blocks anhttp://baseUrl, such as a local Ollaya server, unless the app'sInfo.plistallows it (NSAllowsLocalNetworkingcovers local addresses). - Environment variables (
TYPESAFE_API_KEYand the rest) are read on the JVM, on native platforms, and on Node.js.
Adding the jev4k dependency¶
Upgrading from 0.3.0? A few things are now refused
0.4.0 refuses some things 0.3.0 accepted: changing, from Java, a collection a built object hands out; a framing header, or a non-ASCII key or header, in the configuration; a number, boolean or null criteria value; an image reference inside another, or an image over 16 MiB; and an undeclared option read through a broader enum. The release notes list each one, and when it fails.
Upgrading from 0.1.0? The Maven coordinates have changed
From 0.2.0 the group is com.pambrose.jev4k (0.1.0 was com.pambrose:jev4k), so update the dependency as
shown below. Most code written for 0.1.0 then works unchanged. The
release notes list the few API changes that
came with the move, and how to exclude 0.1.0 if another library still brings it in.
jev4k is published to Maven Central. com.pambrose.jev4k:jev4k is the multiplatform module, and Gradle resolves it to
the right artifact for each target, so a JVM project and a Kotlin Multiplatform project use the same coordinates.
Maven doesn't read Gradle's module metadata, so a Maven build names the JVM artifact, com.pambrose.jev4k:jev4k-jvm.
That single dependency brings the Ktor client, kotlinx.serialization, kotlinx.coroutines and kotlinx-io with it; see what it puts on your classpath for the full set, and using a different Ktor engine if you'd rather not ship CIO on the JVM.
TypeSafe API key¶
The client reads the key from the TYPESAFE_API_KEY environment variable:
You can also set it in code; see Configuration. Keep API keys server-side: don't
ship them in a browser or mobile app. An app on a phone or watch should send its requests through a backend of your
own: point baseUrl at it and have it replace the Authorization header with the real key. The client still
requires an apiKey, so give it a placeholder.
Checking your setup¶
The repository includes two runnable examples against the live API: one uses both DSL styles, and the other sends two bundled photos:
export TYPESAFE_API_KEY=ts-...
make example # runs src/jvmTest/kotlin/com/pambrose/jev4k/examples/TriageExample.kt
make hotdog-example # runs HotDogExample.kt, next to it, which sends two photos (experimental images)
Embedding jev4k in an application¶
jev4k is a library meant to be embedded in an application, so it keeps out of the host's way.
What it puts on your classpath¶
On the JVM, five compile dependencies (ktor-client-core, kotlinx-serialization-json, kotlinx-coroutines-core,
kotlinx-io-core, kotlin-stdlib) and one runtime one, the CIO engine (ktor-client-cio). Nothing else: no test
framework, no logging backend, and no content-negotiation plugin, since jev4k encodes its request body itself.
Logging¶
jev4k never logs: it writes nothing to stdout or stderr itself, installs no Ktor Logging plugin, and ships no
SLF4J binding, so it can't interfere with your logging setup. Ktor does use SLF4J on the JVM, though, and slf4j-api
reaches the classpath through it. With no binding, SLF4J prints a three-line "No SLF4J providers were found" warning
to stderr when the first JevClient is built. Add your application's binding, or slf4j-nop to silence it.
Using a different Ktor engine¶
Pass one through engine and jev4k uses
it instead of the platform's default engine. Closing a JevClient never closes an engine you supplied, so several
clients can share one. If you do supply an engine on the JVM, CIO can be dropped:
dependencies {
implementation("com.pambrose.jev4k:jev4k:0.4.0") {
// Only if you pass your own engine: drops ktor-client-cio and ktor-network-tls. ktor-network
// itself stays, because ktor-client-core depends on it through ktor-http-cio.
exclude(group = "io.ktor", module = "ktor-client-cio-jvm")
}
implementation("io.ktor:ktor-client-okhttp:3.6.0")
}
Calling jev4k from Java¶
jev4k is a Kotlin library, but the inline builder DSL works from Java through jev.getBlocking():
JevClient jev = new JevClient(builder -> {
builder.setApiKey(System.getenv("TYPESAFE_API_KEY"));
return Unit.INSTANCE;
});
JevResult r = jev.getBlocking().query("The payout failed again and I need this fixed today.", null, qb -> {
qb.noul("urgent", "Does this message convey urgency?");
return Unit.INSTANCE;
});
double urgency = r.noul("urgent").getNoul();
Kotlin's Duration doesn't cross to Java either, so each setting of that type has a counterpart in milliseconds:
setTimeoutMillis on the builder, JevDefaults.TIMEOUT_MILLIS, and with… methods on RetryPolicy, whose full
constructor Java can't call (new RetryPolicy(), with every default, works). withBackoffMillis moves both backoff
limits at once, which two separate calls can't always do:
return new JevClient(builder -> {
builder.setTimeoutMillis(2 * JevDefaults.TIMEOUT_MILLIS);
builder.setRetry(new RetryPolicy().withMaxRetries(4).withBackoffMillis(250, 2_000));
return Unit.INSTANCE;
});
The collections a built object hands out are read-only from Java too: a config's or a call's headers, the retry
statuses, a QuestionSet's ids and images, a Choice's options, a Score's levels and a result's answers. Their mutators
throw UnsupportedOperationException, so set headers on the builder, before it builds.
Per-call options work the same way, and BlockingJevKt.blocking(api) gives
any JevApi the blocking calls. Every blocking call declares InterruptedException, and a rate-limit error's hint
is getRetryAfterMillis():
JevCallOptions fast = new JevCallOptions(options -> {
options.setTimeoutMillis(2_000L);
options.getHeaders().put("X-Trace-Id", "ticket-4711");
return Unit.INSTANCE;
});
BlockingJev blocking = BlockingJevKt.blocking(JevCallOptionsKt.withOptions(api, fast));
try {
ModelList models = blocking.models();
JevResult r = blocking.query("The payout failed again.", qb -> {
qb.noul("urgent", "Does this message convey urgency?");
return Unit.INSTANCE;
});
System.out.println(models.size() + " models, request " + models.getRequestId());
return r.noul("urgent").getNoul();
} catch (JevRateLimitException e) {
Long waitMillis = e.getRetryAfterMillis();
System.out.println("Rate limited; retry after " + waitMillis + " ms");
return Double.NaN;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return Double.NaN;
}
Two Kotlin features don't cross to Java: property delegates, which a typed JevQuery is built from, and
inline reified functions, which the Kotlin compiler emits as synthetic members that javac can't resolve. So
four things are out of reach from Java:
- Typed
JevQueryobjects can't be declared. One declared in Kotlin can still be passed toask. @Serializablestates. A state must be aStringor aJsonElement; the reifiedquery,askandjsonEntryoverloads are hidden rather than compiling into a runtime failure.- Enum Choices through the DSL.
QueryBuilder.choice<E>()is reified. Java can see the functions it calls (enumChoiceRef,QueryBuilder.add,JevResult.enumChoiceOf), because inline code needs them public in the bytecode, but they're internal to jev4k and can change without notice. Build aChoiceQuestionwith the option keys you want and add it withQueryBuilder.question(id, question)instead. JevResult.enumChoice<E>(id)is reified too. Read that answer withresult.choice(id), which is keyed by option string.
Everything else is callable: evaluate, models, the inline noul, choice and score builders, the experimental
box builder and attach, the other result accessors, and enums implementing JevOption. Only the getters that return
a Duration, such as JevConfig.timeout and RetryPolicy.initialBackoff, stay Kotlin-only.
Module name¶
The jar declares Automatic-Module-Name: com.pambrose.jev4k for JPMS builds.
Java version¶
The class files are Java 17 (org.gradle.jvm.version = 17 in the published metadata), and the
compiler is held to the Java 17 API, so nothing newer can slip in.
Threads¶
A JevClient is immutable once built and safe to share across coroutines. On the JVM, jev.blocking wraps the
suspend calls in runBlocking, so call it from ordinary threads, never from inside a coroutine.
Building from source¶
Contributors, and anyone who wants a build before the next release reaches Central, can build jev4k from a checkout:
# From a checkout of the jev4k repository (builds with JDK 25; Gradle downloads it if missing)
./gradlew build
Using an unreleased build from another project¶
The simplest way to depend on a checkout rather than a published artifact is a Gradle composite build. Include the jev4k checkout in your project's settings:
// settings.gradle.kts of your project: build jev4k from a sibling checkout
includeBuild("../jev4k")
Then depend on it by its coordinates, which Gradle substitutes with the included build:
// build.gradle.kts of your project
plugins {
kotlin("jvm") version "2.4.21"
kotlin("plugin.serialization") version "2.4.21" // only needed for @Serializable state
}
dependencies {
implementation("com.pambrose.jev4k:jev4k:0.4.0") // the version in the checkout's gradle.properties
}
kotlin {
jvmToolchain(17) // jev4k targets Java 17, so anything 17 or newer works
}
The plugin.serialization line is needed only if you pass your own @Serializable classes as
state.
Next: the Quick Start.