DEV Community

A. Sakashita
A. Sakashita

Posted on

I wanted `if` statements for fuzzy conditions, so I built askif

πŸ‡―πŸ‡΅ ζ—₯本θͺžη‰ˆ / Japanese version: https://zenn.dev/saks/articles/214917996ad932

TL;DR: askif lets you branch on fuzzy judgments (ask.if, ask.switch, ask.score) with typed results. It runs on Jev (fast, about $0.013 per 1,000 calls) or on OpenAI-compatible models. β†’ GitHub

Ever wanted to write an if for a condition that's hard to spell out in code?

Say you're triaging support tickets and want to know whether the customer is asking for a human. Here's the hand-written version.

const wantsHuman =
  /(human|person|agent|representative)/i.test(text) &&
  !/(human resources|secret agent|humans are)/i.test(text) &&
  !/(don't need|no thanks|not necessary)/i.test(text);
// "I've asked three times and nobody has helped. Get me someone who can fix this." β†’ false ❌
// "Humans are amazing" β†’ true ❌
Enter fullscreen mode Exit fullscreen mode

Painful. Every keyword you add brings new exceptions, and you can't keep up with rephrasing.

Let's try ask.if. One line:

await ask.if(text, "the customer is asking for a human agent", routeToHuman);
Enter fullscreen mode Exit fullscreen mode

This isn't magic. It's an AI call.

"Calling an AI for a single branch? That's absurdly slow and expensive." I thought so too, so I measured it:

  • Cost: about $0.013 per 1,000 calls. That's roughly 300 input tokens per call, at $0.042 per million input tokens. Output tokens are free.
  • Latency: 72 ms inference + 175 ms network β‰ˆ 250 ms. Measured from Japan in TypeSafe's official playground.

The 175 ms is mostly distance. Jev currently has no Japan or Asia region, and the model's own work is 72 ms.

That's possible because it runs on Jev, a model from TypeSafe AI. Jev is what TypeSafe calls a "System One" model: instead of generating text, it returns probabilities directly. Skipping text generation is what makes it fast and cheap, and you get back numbers like 0.93 instead of prose you'd have to parse.

You don't need to know how Jev works, though. I wrapped it in a small syntax, so you can just use that.

Get started

npm install askif @askif/jev
export TYPESAFE_API_KEY=...
Enter fullscreen mode Exit fullscreen mode

Node 22+, server-side only. TypeSafe's SDK refuses to run in a browser, since it would expose your API key. Browser use, through a server-proxy backend, is planned.

Three verbs

ask.if: yes or no

The model returns a probability, and askif runs the branch that matches. That's why "not sure" can get its own branch:

const ticket = "I've asked three times and nobody has helped. Get me a real person.";

await ask
  .if(ticket, "the customer is asking for a human agent", () => console.log("routed to a human agent"))
  .else(() => console.log("routed to the bot"))
  .unsure(() => console.log("sent for review"));
Enter fullscreen mode Exit fullscreen mode

Without .unsure(), the then handler runs when the probability is above threshold (default 0.5). With .unsure(), anything inside unsureBand (default 0.2–0.8) goes there instead. 0.55 and 0.98 don't have to be the same "yes".

Need a plain boolean or the raw number?

if (await ask.is("penguin", "can fly")) console.log("penguin can fly");
else console.log("penguin can't fly");

const p = await ask.probability("penguin", "can fly"); // 0..1
Enter fullscreen mode Exit fullscreen mode

ask.switch: one of several options

const team = await ask
  .switch(ticket, "Which team should handle this?")
  .case("returns", "Exchanges, wrong or damaged items", () => console.log("β†’ returns team"))
  .case("shipping", "Delivery status, delays, lost packages", () => console.log("β†’ shipping team"))
  .case("billing", "Charges, invoices, payment problems", () => console.log("β†’ billing team"));

team.choice;  // "returns" | "shipping" | "billing"  (narrowed to your keys)
team.ranking; // every option, most likely first
Enter fullscreen mode Exit fullscreen mode

Both the option key and its description are sent to the model. If the key explains itself, skip the description: .case("calm").

ask.score: a position on a scale

const COSMETIC = "Cosmetic; no impact to functionality";
const WORKAROUND = "Broken or degraded feature, but workaround exists";
const BLOCKING = "Blocking issue; no workaround exists";

const result = await ask
  .score("The export button crashes the settings page in Safari. Works in Chrome.", "How severe?")
  .level(COSMETIC, () => console.log("β†’ backlog"))
  .level(WORKAROUND, () => console.log("β†’ this sprint"))
  .level(BLOCKING, () => console.log("β†’ page on-call"));
Enter fullscreen mode Exit fullscreen mode

The handler that runs is the one for the most likely level, not a rounded score. The result also carries score (the probability-weighted position, which can fall between levels) and normalized (0..1), for ranking.

The practical bits

Every chain is awaitable. It resolves with what happened: r.level, r.branch, r.confidence. Handlers receive the same result, so they can look at the runner-up.

Batching. Questions about the same state in the same tick go out as one request:

await Promise.all([
  ask.if(order, "looks fraudulent", flag),
  ask.if(order, "ships internationally", addCustoms),
  ask.score(order, "How urgent is delivery?").level("standard").level("express"),
]); // one request
Enter fullscreen mode Exit fullscreen mode

Measured: those three questions as one request took a median of 166 ms. Sent one after another, the same three took 519 ms. As three parallel requests, 177 ms. (Median of 10 runs each, round trip included, measured from outside Japan, so these numbers are separate from the 72 + 175 ms above.) Extra questions barely add time.

Tests run offline. mock answers locally and records every call:

import { createAsk, mock } from "askif";

const backend = mock(() => ({ kind: "yesno", probability: 0.9 }));
const ask = createAsk({ backend });
await ask.is("cat", "is animal");
console.log(backend.calls); // what was asked
Enter fullscreen mode Exit fullscreen mode

Thresholds are configurable globally (ask.configure({ threshold: 0.6 })) or per call.

"We can't use Jev in production"

Jev is fast and cheap, but not everyone can use it. Reasons vary:

  • No Japan or Asia region, so latency or data-location requirements aren't met (at the moment)
  • Adding a new AI vendor means going through procurement or a security review
  • You already have a contract with Azure or AWS and want to stay inside it
  • You want to run it on your own infrastructure, or locally

So askif is split in two:

  • askif: the chain logic, the Backend contract, and mock. It doesn't know Jev exists.
  • @askif/jev and @askif/openai: thin adapters, each implementing Backend.

Right now there are two ways out: @askif/openai for general-purpose LLMs, and OpenJev, an open Jev-compatible server, through @askif/jev.

Switching to @askif/openai

It's one import:

// before
import { ask } from "@askif/jev";
// after
import { ask } from "@askif/openai";
Enter fullscreen mode Exit fullscreen mode

Your ask.if / switch / score code doesn't change. @askif/openai speaks Chat Completions, so a baseURL reaches most of the ecosystem:

import { openai } from "@askif/openai";

// OpenRouter
const viaOpenRouter = openai({
  apiKey: process.env.OPENROUTER_API_KEY,
  baseURL: "https://openrouter.ai/api/v1",
  model: "openai/gpt-6-luna",
});

// Local: Ollama (vLLM, LM Studio and llama.cpp work the same way)
const viaOllama = openai({
  apiKey: "ollama",
  baseURL: "http://localhost:11434/v1",
  model: "llama3.3",
});
Enter fullscreen mode Exit fullscreen mode

Azure OpenAI and Amazon Bedrock need their own client classes. They're re-exported, so you don't need a separate npm install openai:

import { AzureOpenAI, openai } from "@askif/openai";

const viaAzure = openai({
  client: new AzureOpenAI({
    apiKey: process.env.AZURE_OPENAI_API_KEY,
    endpoint: process.env.AZURE_OPENAI_ENDPOINT,
    deployment: "gpt-6-luna",
    apiVersion: "2025-04-01-preview",
  }),
});
Enter fullscreen mode Exit fullscreen mode

Bedrock uses BedrockOpenAI. Google's OpenAI-compatible Gemini API and Vertex AI work through baseURL too. See the @askif/openai README for the details.

Tradeoffs, honestly

A general-purpose LLM isn't a System One model, and the adapter doesn't pretend it is:

  • Probabilities are the model's own JSON estimates (via Structured Outputs), not logprobs.
  • confidence is computed by askif from how spread out the probabilities are. The adapter never self-reports one, because models are poor at that.
  • With Jev, same-state questions are one request. With OpenAI, they're N parallel requests.
  • Local servers must honor response_format: json_schema, or you'll see BAD_RESPONSE errors. The README's server table reflects each project's docs only; none of these has been tested live.
  • Thresholds tuned on one backend may not carry over to another. Test on your own data.

If none of these fit, the Backend contract is small: implement it for whatever model you have.

An open, Jev-compatible server: OpenJev

There's another option: OpenJev, an open server that implements the same wire APIas Jev. It's a separate project, not from TypeSafe AI, and you can run it on your own GPU or Apple silicon. There's a hosted version on Codiv, too. With @askif/jev, only baseURL and model change:

import { jev } from "@askif/jev";

const backend = jev({
  apiKey: process.env.OPENJEV_API_KEY, // not needed for a self-hosted server without auth
  baseURL: "https://api.codiv.ai",     // or your own server, e.g. "http://127.0.0.1:8080"
  model: "openjev-latest",
});
Enter fullscreen mode Exit fullscreen mode

OpenJev uses its own model IDs (openjev-latest). Pinned Jev IDs like jev-1.13.0 return a 400.

Writing good questions

From TypeSafe's docs, and they hold for any backend:

  • One judgment per question. "is angry and wants a refund" works worse than two questions combined in your code.
  • Phrase it so yes is the interesting answer: "contains personal data," not "is free of personal data."
  • Describe situations, not degrees. "Broken, but a workaround exists" works; "moderately severe" doesn't.
  • Test against your own data, and tune thresholds in code.

What's next

Offline examples live in packages/askif/examples/ in the repo.

More adapters are in progress: Anthropic, plus lighter setups such as Laya on OpenJev and an MLX-based configuration. A server-proxy backend for browser use, a form for declaring several questions up front, and per-call AbortSignal with usage reporting are planned too.

Packages: askif Β· @askif/jev Β· @askif/openai

askif is an unofficial library and is not affiliated with TypeSafe AI.

GitHub logo asakaxgit / askif

Let Jev decide. Typed decisions for your code: ask.if, ask.switch, ask.score.

askif

Let Jev decide.

Typed decisions for your code: ask.if, ask.switch, ask.score.

askif is the provider-neutral toolkit: the chain logic, the Backend contract, and a mock backend for tests. @askif/jev is the batteries-included bundle for TypeSafe's Jev, with a ready-to-use ask:

import { ask } from "@askif/jev";
Enter fullscreen mode Exit fullscreen mode
// packages/jev/examples/basic.ts#L5-L7

await ask.if("cat", "is animal", () => {
  console.log("cat is animal");
});
Enter fullscreen mode Exit fullscreen mode

Each call asks Jev, TypeSafe's System One model, a question about some state. Jev returns probabilities instead of text, and askif runs the branch that matches.

Unofficial, not affiliated with TypeSafe AI.

Install

npm install askif @askif/jev
export TYPESAFE_API_KEY=...
Enter fullscreen mode Exit fullscreen mode

Node 22 or newer. Run it on a server: the TypeSafe SDK refuses to run in a browser, where your API key would be exposed. Building your own backend instead…

Top comments (0)