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 models your account can use, with the call's request id

evaluate and models also take per-call options.

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

On the JVM, 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 })
}

Any other JevApi, such as a fake or a per-call options view, has the same blocking calls through blocking():

// Code handed a JevApi, such as a fake in a test, gets the same blocking calls from blocking().
val result = api.blocking().ask(Triage, state = "Our dashboard has been down for an hour.")

Blocking calls block the calling thread. Don't use them from inside a coroutine. An interrupted thread gets an InterruptedException, which every blocking call declares, so Java code can catch it. They exist on the JVM only; on every other platform BlockingJev has no members. Kotlin/JS and Kotlin/Wasm can't block a thread at all, and Kotlin/Native code that needs to can wrap the suspend calls in runBlocking itself.

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.14.0")
println("answered by ${pinned.model}")

// List the models this account can use. The list also carries the call's request id.
val models = jev.models()
models.forEach { println("${it.name} (${it.releaseDate}): ${it.description}") }
println("request ${models.requestId}")

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.14.0) and move to a new one deliberately.

Per-call options

The client's timeout, retry policy and headers suit most calls. JevCallOptions changes them for the calls that need something else, as both official SDKs allow, without a second client:

// A tight budget for the interactive path, on the same client as everything else.
val interactive =
    JevCallOptions {
        timeout = 2.seconds             // per attempt, instead of the client's
        retry = RetryPolicy.NONE        // replaces the client's whole policy
        headers["X-Trace-Id"] = traceId // over the client's and the built-in headers
    }
val urgent = jev.withOptions(interactive).ask(Triage, state = "Checkout fails for every customer.")

// Or for one evaluate call, adding a top-level body field that jev4k doesn't model.
val tuned = JevCallOptions { extraBody["beam_width"] = JsonPrimitive(4) }
val result = jev.evaluate(JsonPrimitive("Refund me now!"), Triage.questions, model = null, options = tuned)
Option Effect
timeout the timeout for each attempt, instead of the client's
retry a RetryPolicy that replaces the client's as a whole
headers headers that replace the client's and the built-in ones of the same name, Authorization included
extraBody top-level fields added to the evaluate request body, other than state, model and questions

A setting left unset keeps the client's value. jev.withOptions(options) returns a JevApi that applies them to every call, query, ask and blocking() included; options passed to a call on it are merged over its own. Options are checked when they're built, and a problem is a JevConfigException. Headers follow the client's rules: names are HTTP tokens and values ASCII, and Content-Length, Content-Type, Transfer-Encoding and Upgrade can't be set. Each extraBody field may nest at most 64 levels. extraBody is an escape hatch for API parameters jev4k doesn't model yet; whether the service accepts a field is up to TypeSafe.

Long context

A request has to fit two context limits: 64k tokens for the state plus every question, and 32k for the state plus any one question. TypeSafe's TypeSafe-Danger-Zone-Long-Context: true header raises both to 262k tokens, on jev-1.13.0 and jev-1.14.0, at the same price per token. A long-context request is slower and less accurate, so send the header only on the calls that need it:

// Both context limits rise to 262k tokens for the calls made through longContext.
val longContext = JevCallOptions { headers["TypeSafe-Danger-Zone-Long-Context"] = "true" }
val result = jev.withOptions(longContext).ask(Triage, state = transcript)

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, returning a ModelList. The members that take JevCallOptions have default implementations that ignore the options and call the plain ones, so a fake needn't implement them. A MockK mock does need them stubbed if the code under test passes options or uses withOptions: coEvery { jev.evaluate(any(), any(), any(), any()) }. 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 (a leading byte-order mark included), and jevApiException(status) for the error path, which reads the retry hint and the x-typesafe-request-id from its headers as the client does. Recording a real response and replaying it keeps a fixture honest, and a malformed one, or text that isn't a JSON object at all, still raises JevResponseValidationException. A QuestionSet prints its ids, so a mock's "no answer found" message shows which set it received.

A result answers only the handles of the question set it was built for. Code that builds its questions on every call (inline handles, or a JevQuery class) sends a new set each time, so a MockK mock should build the result from the set it received, answers { jevResult(body, secondArg()) }, rather than return one built in advance.