Skip to content

For agents: an index of these docs is at /docs/llms.txt, and this page as markdown is at /docs/ai.md.

AI

Structured LLM calls with budgets, caching and failover.

Provider-agnostic structured LLM calls with budget caps, caching and failover. Resolves signet#1.

Structured output only. Every call goes through a Zod schema and returns a typed, validated object — never free text. That keeps the surface small and every call verifiable.

import { createExtractor, memoryCache, createBudgetLedger } from "@dapplelight/signet-ai"
// The AI SDK adapter is a SUBPATH, not on the barrel — see "Why the adapter is
// a separate import" below. Requires `ai` installed.
import { aiSdkProvider } from "@dapplelight/signet-ai/providers/ai-sdk"
import { createWorkersAI } from "workers-ai-provider"
const workersAi = createWorkersAI({ binding: env.AI })
const ai = createExtractor({
providers: [
aiSdkProvider({ name: "workers-ai", model: workersAi("@cf/meta/llama-3.3-70b-instruct-fp8-fast") }),
aiSdkProvider({ name: "backup", model: someOtherModel,
pricing: { inputPerMTokens: 0.15, outputPerMTokens: 0.6 } }),
],
cache: memoryCache(),
ledger: createBudgetLedger(5, 86_400), // $5/day, fails closed
})
const { value, meta } = await ai.extract({
schema: intentSchema,
input: "8000 se kam ka snowboard",
instructions: "Pull the budget, size and category from this shopping request.",
budget: { maxCostUsd: 0.002 },
})
// meta: { provider, model, usage, costUsd, cacheHit, attempts, durationMs }

Model choice matters more than anything in this package

Benchmarked through this extractor on real inputs by the first production consumer, five free Workers AI models, same work:

Model Latency
@cf/meta/llama-3.3-70b-instruct-fp8-fast 1.1s
@cf/zai-org/glm-4.7-flash 17.7s / 30.0s / 21.0s, one ALL_PROVIDERS_FAILED
@cf/meta/llama-3.1-8b-instruct fails instantly — no structured output on this path

glm-4.7-flash was this README's example and is unusable inline. If you copied it before, change it. End-to-end latency in production went from 17–30s to 1.6–3.0s including a Shopify call.

A model with no structured-output support fails closed in ~0.1s, which is the correct behaviour — but a provider array containing only that model will always fail.

Schema validity is not truth

This package guarantees shape, not correctness. "Typed and validated or it is an error" is easy to read as "correct". It is not the same claim, and the difference matters.

Observed in production, all schema-valid and all wrong:

  • maxPrice: 8000 for "snowboards for a beginner" — no price in the input
  • a field meant to hold "ski jacket" returned "The user asked for a warm skiing item with a maximum price of $900…"
  • every model returned inStock: true for requests that never mentioned availability

Re-validation passes all of these, correctly — they are the right shape with invented content. If a hallucinated value would cause harm, verify each claim against the input text and discard what the text does not support. That layer is application-specific and deliberately not in this package.

Decisions that are load-bearing

Providers are an array, tried in order. Adding or reordering is config, not code. Exhausting Workers AI's free allocation (error 3036, delivered as a 429) fails over to the next entry with no product change.

Budget fails closed. Once a window is exhausted, calls are refused. A fallback value does not mask a budget refusal — running out of money is a configuration problem the caller must see, not a soft failure.

Only transient errors retry. A schema violation or a bad key fails identically every time; retrying spends budget and latency for a guaranteed failure. Unknown error shapes are treated as permanent — "retry on doubt" is how a cap gets spent on something that was never going to work.

The SDK's own retries are disabled (maxRetries: 0). Retrying in both places multiplies spend silently underneath a cap.

Output is re-validated against the schema even though the provider claims to have done it. A provider returning an unvalidated object is the exact failure this package exists to prevent.

The schema is part of the cache key. The same prompt against a changed schema is a different question; reusing the old answer returns data that no longer validates.

Why the adapter is a separate import

aiSdkProvider lives at @dapplelight/signet-ai/providers/ai-sdk and is deliberately not re-exported from the barrel.

export * resolves eagerly in ESM, so barrelling it would make importing createExtractor alone pull in ai — turning an optional peer into a hard requirement. Anyone using only fakeProvider in tests, or writing their own Provider, would need a dependency they never asked for, and would only find out at runtime.

The core package is transport-agnostic and depends on nothing but @dapplelight/signet-lib. Same reasoning as @dapplelight/signet-server/http needing hono only if you use it.

import { createExtractor } from "@dapplelight/signet-ai" // no `ai` needed
import { aiSdkProvider } from "@dapplelight/signet-ai/providers/ai-sdk" // needs `ai`

Testing without network or spend

import { createExtractor, fakeProvider } from "@dapplelight/signet-ai"
const ai = createExtractor({ providers: [fakeProvider({ budget: 8000, category: "snowboard" })] })

fakeProvider takes a fixed value, a list, or a function of the input, and can be told to fail N times first to exercise retry and failover paths.

Errors

AiError extends ApiError from @dapplelight/signet-lib/api, so an AI failure is handled like any other API failure. Codes: AI_BUDGET_EXCEEDED (429), AI_ALL_PROVIDERS_FAILED (502), AI_SCHEMA_INVALID (502), AI_NO_PROVIDERS (500).

Jev: @dapplelight/signet-ai/jev

A safe wrapper for Jev, TypeSafe's System One classifier: text in, typed decision out. app-a, app-b and app-d each had their own copy with the same model pin, 0.95 floor and 8 s timeout. One of them had no floor and threw.

import { createJev } from "@dapplelight/signet-ai/jev"
// Build it where env exists (a handler, a CLI main), never at module scope.
const jev = createJev({
apiKey: env.JEV_API_KEY, // missing: every call returns its fallback
// model = "jev-1.13.0", minConfidence = 0.95, timeoutMs = 8000
// allowSensitive = false // the explicit opt-in for private/financial text
onEvent: (e) => log.info("jev", e), // redacted: no key, state or question text
})
const d = await jev.choice({
question: "Which spending category fits this bank transaction?",
options: { food: "Restaurants, delivery", travel: "Flights, cabs", unsure: null },
state: `Money paid out. Narration: "${narration}"`,
fallback: "unsure",
sensitive: true, // required: bank text is financial
})
// { value, confident, source: "jev" | "fallback", confidence?, reason?, answer? }
if (d.confident) apply(d.value)
else review.push({ best: d.answer, confidence: d.confidence })
Method Jev primitive value
choice({ question, options, fallback, ... }) choice, up to 255 labels, as a list or label -> description one of the labels
score({ question, levels, fallback, ... }) score, 2-10 ordered levels a number, can fall between levels
noul({ question, fallback, ... }) noul a boolean; probability carries the raw yes-probability
batch({ state, questions, ... }) several named questions about one state, in one request a decision per name

Every call takes state, sensitive, fallback, and optionally minConfidence and signal. jev.enabled is false without a key, so an app can skip preparing work it will not send.

It never throws for the failures that matter. Every call resolves to a decision the app can act on. The fallback is used, with a reason, when:

reason When
sensitive_not_allowed sensitive: true and the instance lacks allowSensitive: true. Checked first; fetch is never called
no_key apiKey missing or blank. Fetch is never called
low_confidence below the floor. answer and confidence keep what Jev said, for a review queue
timeout over timeoutMs, even if the fetch ignores its abort signal
http_error a non-2xx response, such as 401 or 429. The event carries status
network_error fetch rejected
invalid_response not JSON, a missing answer, or a label outside the offered set
invalid_request no questions, an out-of-range floor, score levels outside 2-10, or a fallback not among the options
aborted the caller's signal fired

Decisions that are load-bearing:

  • The 0.95 floor is on by default. On app-a's first real run, 0.85-0.94 let wrong guesses through and 0.95 let none through. Pass minConfidence per call where a wrong answer is cheap, as app-b's loop does.
  • sensitive is required by the type, and a JS caller that omits it is treated as sensitive. The opt-in belongs to the instance, so the owner decides it once in config, not each caller in code. Deciding what is safe to send is still the app's job: app-a strips digit runs from narrations.
  • The noul confidence is max(p, 1 - p). Jev reports none for noul, so a 0.7 "yes" falls back at the default floor. Rank by probability if you need to.
  • Fetch only, no SDK. The API is a single POST /v1/systemone with Bearer auth. @typesafe-ai/sdk retries twice by default with a per-attempt timeout, so its "8 s" could reach about 27 s. It also falls back to TYPESAFE_API_KEY from env and logs request bodies at debug level. Here timeoutMs is the total, with no retries, because the fallback is the retry.
  • The key goes into the Authorization header and nowhere else. Upstream error messages and response bodies are never copied into a result or event, because a transport can echo headers. A test drives every failure path through a transport that tries to leak the key.
  • Not on the barrel. Import it from @dapplelight/signet-ai/jev. It has no dependencies.

Migrating the existing copies

  • app-a (packages/mcp/src/lib/jev-categorize.ts): drop JEV_MODEL, TIMEOUT_MS, makeJevClassifier, the TypeSafeClient import and the @typesafe-ai/sdk dependency. Build createJev({ apiKey: env.JEV_API_KEY, allowSensitive: env.JEV_ALLOW_PRIVATE_MODE === "1" }), then call jev.choice({ ..., fallback: "unsure", sensitive: Boolean(crypto) }). That replaces assertPrivateModeAllowed inside jevCategorize, and a try/catch counting errors becomes reason not being low_confidence. The confidence comparison moves into the wrapper; low_confidence rows go to review using answer and confidence. Keep sanitizeNarration, the unsure label and the no-auto-apply rule for transfers: those are product rules.
  • app-b (scripts/jev-client.mjs): delete the file. decide() in loop/jev-steps.mjs becomes jev.choice({ ..., fallback: safe, minConfidence, sensitive: false }). Behaviour fix: jev-pr-risk.mjs, jev-triage.mjs and jev-stop-gate.mjs call classify with no floor, so a 0.3 guess labelled a PR, and any error or missing key threw and killed the script. They now get the 0.95 floor and a fallback: medium for PR risk, structural for the stop gate so E2E still runs, and no tier for triage. assessCompleteness becomes jev.noul({ ..., fallback: false }).
  • app-d (src/lib/jev.ts): delete JEV_MODEL, MIN_CONFIDENCE, TIMEOUT_MS, getClient, and the try/catch returning null in jevClassify. jevEnabled() becomes jev.enabled. The null-or-low-confidence check in match.ts confident() and in route.ts becomes d.confident. jevScore becomes a jev.batch of noul questions per chunk of 25, reading probability with a fallback of true so an unscored video is kept. All app-d calls send public content, so they use sensitive: false.

Out of scope

Streaming, chat state, agents and tool loops, embeddings, RAG. Add when a second product needs them, not before.

Test

bun run test runs 42 extractor assertions (failover, retry classification, budget fail-closed, cache keying and schema enforcement) and 59 Jev assertions (the confident path, every fallback reason, the privacy gate, batch, and the key never leaking), all against a fake fetch with no network.