Skip to content

Making Calls

Suspending calls

JevClient implements the JevApi interface. Its calls are suspend functions, so call them from a coroutine:

// JevApi calls are suspend functions: call them from a coroutine.
val result = jev.ask(Triage, state = "Our dashboard has been down for an hour.")
Call Does
evaluate(state, questions, model?) sends a QuestionSet about a JSON state; everything else builds on it
query(state, model?) { ... } builds questions inline, then evaluates them
ask(query, state, model?) evaluates a JevQuery's questions
models() lists the model names your account can use

query and ask are extension functions on JevApi. Import them with import com.pambrose.jev4k.query and import com.pambrose.jev4k.ask; IDEs add these automatically.

// evaluate is the one network call everything else builds on.
val result = jev.evaluate(JsonPrimitive("Refund me now!"), Triage.questions, model = null)

Blocking calls

jev.blocking mirrors every call without coroutines, for scripts, main, tests and Java callers:

// Outside coroutines (scripts, main, Java callers), use the blocking mirror.
JevClient().use { jev ->
    val result = jev.blocking.ask(Triage, state = "Our dashboard has been down for an hour.")
    println(result[Triage.team].choice)
    println(jev.blocking.models().map { it.name })
}

Blocking calls block the calling thread. Don't use them from inside a coroutine.

Choosing a model

Every call takes an optional model, which overrides the client's defaultModel for that request:

// Override the model per call. Pin a versioned model once you've tuned thresholds against it,
// because an alias such as jev-latest can move to a newer release.
val pinned = jev.ask(Triage, state = "The API returns 500s.", model = "jev-1.13.0")
println("answered by ${pinned.model}")

// List the model names this account can use.
jev.models().forEach { println("${it.name} (${it.releaseDate}): ${it.description}") }

jev-latest always points at the newest stable model, so its answers can shift when TypeSafe ships a release. Once you've tuned thresholds against a model, pin its version (for example jev-1.13.0) and move to a new one deliberately.

Concurrency

Suspending calls make it easy to process many states at once. Bound the parallelism, though: TypeSafe's examples found that about 8 concurrent requests on a shared key already hit rate limits.

// Run many requests concurrently, but bound the fan-out: shared keys hit rate limits at around 8 at once.
suspend fun triageBatch(
    jev: JevApi,
    tickets: List<String>,
    parallelism: Int = 4,
): List<Team> {
    val permits = Semaphore(parallelism)
    return coroutineScope {
        tickets
            .map { ticket -> async { permits.withPermit { jev.ask(Triage, state = ticket)[Triage.team].choice } } }
            .awaitAll()
    }
}

Rate-limited requests are retried automatically (see Retries & Errors), but staying under the limit is faster than being retried.

Before reaching for concurrency, check whether the questions could go in one request instead. Questions about the same state are answered in parallel for the cost of one request; see Speculative Fan-Out.

Designing for testability

Depend on the JevApi interface rather than JevClient, and pass it in:

// Depend on the JevApi interface, not JevClient, and pass it in.
class TicketRouter(
    private val jev: JevApi,
) {
    suspend fun queueFor(ticket: String): String =
        when (jev.ask(Triage, state = ticket)[Triage.team].choice) {
            Team.BILLING -> "billing"
            Team.TECHNICAL -> "engineering"
            Team.SALES -> "sales"
        }
}

// In production, wire in the real client; elsewhere, any JevApi implementation will do.
fun productionRouter(): TicketRouter = TicketRouter(JevClient())

query and ask both go through JevApi.evaluate, so a fake or mock that implements evaluate covers all three, without a network. models() is the interface's other network call; stub it too if your code lists models. To exercise the real client without a network, give it a Ktor MockEngine through the engine setting.

Build what the fake returns with jevResult(body, questions), which maps a response body exactly as the client does, and jevApiException(status) for the error path. Recording a real response and replaying it keeps a fixture honest, and a malformed one still raises JevResponseValidationException.