A .NET client library for the TypeSafe AI System One API
(https://api.typesafe.ai).
Ask typed questions about a body of content and get structured, probability-backed answers back. Every question is declared with a kind and answered within the constraints you supplied, so nothing has to be parsed out of prose.
OpenJEV support: Jev is built by TypeSafe. This fork keeps TypeSafe as the default and adds optional support for OpenJEV, a free community gateway to the same Jev model — set
OPENJEV_API_KEY(orJEV_PROVIDER=openjev) to use it. Original project: https://github.com/nothingmn/Jev.Sdk by @nothingmn.
This is an independent client library. It is not affiliated with, sponsored by, or endorsed by TypeSafe AI. "TypeSafe" and "System One" are used only to describe the API this library talks to. For the API, the service, the models, or anything about accounts, billing, or uptime, contact TypeSafe AI directly.
dotnet add package Jev.Sdk
For Microsoft.Extensions.DependencyInjection and configuration integration, also install:
dotnet add package Jev.Sdk.DependencyInjection
Neither package is published to nuget.org. The build workflow produces the .nupkg files as
downloadable artifacts on every push to main; see Building.
Set TYPESAFE_API_KEY in your environment, then:
using Jev.Sdk;
using var client = new JevClient();
SystemOneResponse response = await client.SystemOneAsync(
state: "Hi, I've been trying to connect my Stripe account for 3 days and it keeps failing.",
questions: new Dictionary<string, Question>
{
["is_urgent"] = Question.Noul("Does this convey urgency?"),
["department"] = Question.Choice(
"Which team should handle this?",
new Dictionary<string, string?>
{
["billing"] = "Payments, invoicing, refunds",
["technical"] = "Bugs, outages, integrations",
["sales"] = "Pricing, upgrades, new accounts",
}),
["frustration"] = Question.Score(
"How frustrated is the customer?",
"Calm", "Frustrated", "Very angry"),
},
model: null, // null uses "jev-latest"
cancellationToken: cancellationToken);
double urgency = response.Noul("is_urgent"); // 0.92
string department = response.Choice("department"); // "technical"
double frustration = response.Score("frustration"); // 1.6Three question kinds, each with the answer shape that kind produces:
| Question | Answers with |
|---|---|
noul |
a probability, 0 to 1 |
choice |
the selected option, every option's probability, and a confidence |
score |
a position on your scale, the rubric, probabilities, and a confidence |
A Noul answer deliberately carries no confidence: the probability already expresses the model's certainty, and a second number beside it would invite treating the two as interchangeable.
Questions in one request are evaluated against the same state, independently and in parallel. Adding a speculative question whose answer only matters for some inputs costs almost nothing, so put the whole decision tree in one call and let your code pick what it needs:
if (response.Choice("department") == "billing"
&& response.Noul("refund_requested") > 0.7)
{
RouteToBilling(ticketId, flagForReview: true);
}choice and score answers report a confidence derived from their probability distribution:
concentrated means certain, flat means uncertain. Threshold on it to decide whether to act or to
ask a human. The value is reported exactly as received and is never recomputed.
var answer = response["department"].AsChoice();
if (answer.Confidence < 0.6)
{
EscalateForReview(ticketId);
}
else
{
Route(answer.Choice);
}The API accepts JSON structure wherever it accepts prose. Pass an object as the state, or structure inside a question:
// A caller-owned type, serialized through the caller's own source-generated context, so the
// library never reflects over your types.
await client.SystemOneAsync(
state,
CallerStateContext.Default.CallerState,
questions,
model: null,
cancellationToken);// Structured instructions: the question itself can be a document.
["invoice_number_is_correct"] = Question.Noul(StructuredValue.FromJson(
JsonDocument.Parse("""
{ "field": { "name": "invoice_number", "type": "string" },
"expect": "matches the invoice header" }
""").RootElement))GET /v1/models is defined in the OpenAPI specification but absent from the prose reference. It is
the only way to learn which models your account can use, so the client implements it.
The model set is open — the API can add models at any time — so there is deliberately no enum.
Both of the vendor's SDKs type the model as a plain string for the same reason. The default is
jev-latest, which is their alias for the current flagship and resolves server-side, so a caller
gets a sensible model without naming one and it never goes stale.
// Always a fresh request.
IReadOnlyList<ModelMetadata> models = await client.GetModelsAsync(cancellationToken);Asking "is model X available?" or "pick the best model" rarely needs a live answer, so there is a lazily populated per-client cache:
// First call fetches; later calls are served from the cache.
IReadOnlyList<ModelMetadata> models = await client.GetAvailableModelsAsync(cancellationToken);
if (await client.IsModelAvailableAsync("jev-2026-08", cancellationToken)) { /* ... */ }
string model = await client.ResolveModelAsync(preferred: null, cancellationToken);Three properties are deliberate:
- Nothing is fetched at construction.
new JevClient()performs no I/O and cannot fail on a network problem, a bad key, or a rate limit — in a place where you have no way to handle it. - The cache is per client, not process-wide.
/v1/modelsreturns the models available to your account, so a shared cache would serve one account's list to another. - Concurrent first reads share one request. Twenty callers asking at once produce one call, not twenty.
The cache lives for an hour by default; tune or disable it with ModelCacheDuration:
new JevClientOptions { ModelCacheDuration = TimeSpan.FromMinutes(15) }; // shorter TTL
new JevClientOptions { ModelCacheDuration = TimeSpan.Zero }; // no cachingInvalidate it yourself when you know the server-side list changed:
client.InvalidateModelCache();If your first request matters more than startup latency — a service whose first user should not pay for a discovery call — warm it explicitly, where you can await and handle failure:
using Jev.Sdk.DependencyInjection;
bool warmed = await JevClientWarmup.WarmAsync(client, logger, cancellationToken);Or straight on the client, which reports failure as false rather than throwing, so best-effort
warmup needs no try/catch:
if (!await client.WarmModelsAsync(cancellationToken))
{
// Not fatal: the client will fetch on first use.
}Every error this library raises derives from JevException, so one catch covers them all:
| Exception | Raised when |
|---|---|
JevConfigurationException |
no API key could be resolved, or an option value is invalid |
JevRequestValidationException |
the request is locally invalid; no network call is made |
JevBadRequestException |
HTTP 400 — the request was malformed |
JevAuthenticationException |
HTTP 401 — the key is missing or invalid |
JevPermissionDeniedException |
HTTP 403 — the key is valid but not permitted |
JevNotFoundException |
HTTP 404 |
JevValidationException |
HTTP 422 — the API rejected the body; carries per-field details |
JevRateLimitException |
HTTP 429, after retries are exhausted; carries Retry-After |
JevServerException |
any 5xx, after retries are exhausted |
JevOverloadedException |
HTTP 529 specifically; derives from JevServerException |
JevApiException |
any other status |
JevConnectionException |
the exchange failed, or the response was unreadable |
JevOverloadedException derives from JevServerException, so one catch covers every server-side
condition. JevRateLimitException deliberately does not: a rate limit is the server declining to
serve rather than failing, and collapsing the two would make you report an outage when you should
slow down.
Every API exception carries RequestId (from the x-typesafe-request-id response header) and
Endpoint. Success responses carry RequestId too, via JevResponse.RequestId — logging a
successful call needs the same handle as reporting a failed one. Quote it when escalating to
TypeSafe support, since it is how they find the call.
try
{
var response = await client.SystemOneAsync(state, questions, null, cancellationToken);
}
catch (JevValidationException validation)
{
foreach (ErrorDetails detail in validation.Details)
{
Console.WriteLine(detail); // body.questions.urgency.score.criteria: Field required (missing)
}
}
catch (JevRateLimitException rateLimited)
{
Console.WriteLine($"Still limited; the server asked for {rateLimited.RetryAfter}");
}Local validation runs before any network call and reports every problem at once, so a mistake costs one round of feedback rather than several. A Score question with a single level is rejected even though the API accepts it, because a one-level scale returns a constant.
The defaults match TypeSafe's own SDKs, so a machine running the Python or JavaScript client throttles the same way this one does:
| Setting | Default | Notes |
|---|---|---|
MaxRetries |
2 | after the initial attempt; 0 disables retrying |
InitialRetryDelay |
500 ms | doubled each attempt |
MaxRetryDelay |
5 s | ceiling on the computed backoff |
MaxRetryAfter |
60 s | ceiling on a server-supplied delay |
RetryJitterFraction |
0.25 | subtractive jitter |
Timeout |
10 s | per attempt, not a whole-call deadline |
The timeout is enforced by the client with its own deadline rather than by mutating your
HttpClient. That matters if you pass a pooled, singleton, or factory-managed client:
HttpClient.Timeout cannot be assigned once the client has served a request, so a library that
sets it would fail on exactly the clients you are most likely to hand it. Yours is left untouched,
and an already-used client is accepted.
The body read is bounded too, so a server that returns headers promptly and then stalls the body
cannot hold a call open past the timeout. Cancelling your own CancellationToken still surfaces as
a cancellation, not as a timeout, and vice versa.
Retryable statuses are 408, 429, and every 5xx. TypeSafe's documented 529 Overloaded is
covered by the 5xx range — retrying only 429 and 529 would leave a 500, 502, or 503 un-retried,
and that is how a transient outage becomes your user's outage. Nothing in the 4xx range is
retried: 400, 401, 403, 404, and 422 cannot be improved by repetition.
Jitter here is subtractive: each computed delay is reduced by up to a quarter, so a 1-second backoff lands between 750 ms and 1 s. It is deliberately not "full jitter", which replaces the delay with a uniform random value from zero — that would shorten waits far more than the vendor's SDKs do.
A server-supplied Retry-After or retry-after-ms takes precedence over the computed backoff.
The millisecond form wins when both are present, being more precise. A server delay beyond
MaxRetryAfter is discarded and the computed backoff used instead: waiting minutes inside one
call is worse than failing and letting you retry at your own level.
using var client = new JevClient(new JevClientOptions
{
MaxRetries = 5,
InitialRetryDelay = TimeSpan.FromMilliseconds(250),
MaxRetryDelay = TimeSpan.FromSeconds(10),
MaxRetryAfter = TimeSpan.FromMinutes(2),
});using Jev.Sdk.DependencyInjection;
builder.Services.AddJevClient(builder.Configuration);The API key resolves from the highest-priority source that supplies one:
| Priority | Source |
|---|---|
| 1 | a key passed to the client or set on JevClientOptions |
| 2 | the TYPESAFE_API_KEY environment variable |
| 3 | appSettings.{MACHINE_NAME}.json |
| 4 | appSettings.json |
Environment beats every file; a machine-specific file overrides the generic one. The machine
token comes from the MACHINE_NAME environment variable when set, falling back to
Environment.MachineName — the override matters in containers, where the machine name is a
random id that changes when the container is recreated. Files are read once, at host startup, and
never watched, because the core library performs no file I/O.
Two further variables are read, matching the vendor's SDKs:
| Variable | Effect |
|---|---|
TYPESAFE_BASE_URL |
overrides the default base address |
TYPESAFE_DEFAULT_MODEL |
overrides the default model |
These supply defaults only. A value set explicitly in code is never overridden by the environment.
Every seam is an interface with a working default, and registration uses TryAdd, so anything
you register first wins:
builder.Services.AddSingleton<ITypeSafeTransport, MySyntheticTransport>();
builder.Services.AddJevClient(builder.Configuration);The client can also be built directly with substituted collaborators, with no DI package at all:
var client = new JevClient(
options: new JevClientOptions { ApiKey = "test" },
transport: new StubTransport(),
apiKeyProvider: new StaticApiKeyProvider("test"),
logger: NullLogger.Instance);The library emits Base Class Library telemetry only — ILogger, ActivitySource, and Meter.
It takes no dependency on OpenTelemetry or any exporter, because that would make it unusable to
consumers who chose a different one. Wire up whatever you already use:
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing.AddSource("Jev.Sdk"))
.WithMetrics(metrics => metrics.AddMeter("Jev.Sdk"));| Signal | Name |
|---|---|
ActivitySource and Meter |
Jev.Sdk |
| Spans | systemone.call, models.list |
| Counters | jev.calls, jev.retries, jev.tokens.input, jev.tokens.output |
| Histogram | jev.call.duration |
The caller's state is never logged, never a metric tag, and never attached to a span, at any
level. Question ids never become metric tags either: they are caller-supplied and unbounded, so
one would blow up a metrics backend.
System.Text.Json with source generation. Trimming and Native AOT work, and a missing type is a
build error rather than a runtime surprise.
Question ids are dictionary keys and are passed through byte-identical — no naming policy is
applied to them. This matters more than it sounds: a snake_case dictionary-key policy would
rewrite a caller's isUrgent to is_urgent, the API would answer under the new name, and the
caller would look for an answer that was never returned. The client refuses that configuration
rather than letting it corrupt a response.
Customisation is available and off by default:
new JevClientOptions
{
ConfigureJson = options => options.Converters.Add(new MyConverter()),
}The API will gain question and answer kinds. When it does, a working integration should not
break. An unmodelled kind arrives as RawQuestion or UnknownAnswer with its original JSON
intact, and the answers this library does understand are unaffected:
if (response["sentiment"] is UnknownAnswer unknown)
{
Console.WriteLine($"Unmodelled kind '{unknown.Type}': {unknown.RawJson}");
}One client instance is safe to share across any number of concurrent callers, and that is the intended usage.
// Register once; share everywhere.
builder.Services.AddJevClient(builder.Configuration);| Concern | How it is handled |
|---|---|
| Model cache | an immutable snapshot, read atomically, with no lock on the hit path |
| Cache refresh | a semaphore, so 20 concurrent first reads issue one request |
| Disposal | an Interlocked transition, so only one thread disposes an owned client |
| Serialization metadata | resolution cached per caller type, not rebuilt per call |
| Options | snapshotted and frozen at construction; no mutable state is shared |
Measured in-process against a stub transport, one instance: 21.7k calls/s sequentially at 46 µs
per call, scaling to 200k calls/s at 64-way concurrency. A bare HttpClient doing the same
exchange costs ~1.1 KB per call; a full SystemOneAsync adds ~4.6 KB on top, dominated by the
serialized request and the parsed response rather than by client overhead.
A note on honesty: two of the threading changes are defensive, not fixes for demonstrated defects, and the requirements say so. Reintroducing the races does not fail the tests, because their failure modes are not observable — a torn cache read costs at most one extra fetch. They are kept because they are correct by construction. One change is a measured win: type-information resolution used to build serializer options on every typed call.
- No synchronous API. Every I/O call is asynchronous, takes a required
CancellationToken, and honours it through every await including retry delays. - No streams,
IAsyncEnumerable, or lazy sequences returned to callers. Every call materialises its result, so nothing disposable escapes. - No file I/O in the core library. File-based configuration is loaded by the DI package at host startup, never inside a client constructor.
- No public event or subscription surface. Telemetry covers in-process observation.
- No network I/O at construction. Nothing is fetched until you call something, so building a client is infallible and instant.
| Path | Contents |
|---|---|
src/Jev.Sdk/ |
The client library |
src/Jev.Sdk.DependencyInjection/ |
DI and configuration integration |
tests/Jev.Sdk.Tests/ |
439 tests, no network required |
samples/Jev.Sdk.Sample/ |
A minimal console client |
samples/Game/ |
Escape the Room: a demo game showing Choice, Score and Noul in one call, with its own tests |
samples/World/ |
A schema-driven world engine: the same pattern as data. Loads a world from markdown, JSON, or a zip package, and plays it |
samples/HomeAssistant/ |
A CLI sample that uses Jev to form a bounded command plan, then invokes a user-confirmed raw Home Assistant REST service call |
requirements/ |
Requirements and locked design decisions |
docs/ |
Verified API notes |
The client is split across partial classes by function, each file holding one concern:
JevClient.cs (construction), .SystemOne.cs (evaluation), .Models.cs (discovery),
.Validation.cs (local validation), .Pipeline.cs (send, retry, error mapping),
.Conversions.cs (typed readers). HttpTypeSafeTransport is split the same way.
dotnet build Jev.Sdk.slnx
dotnet test Jev.Sdk.slnx
# Pack with the version the build resolves, the same way the workflow does.
VERSION="$(python3 eng/package_version.py)"
dotnet pack Jev.Sdk.slnx --configuration Release --output ./artifacts/packages -p:Version="$VERSION"Requires the .NET 10 SDK. The project targets net10.0 only.
One place declares the version — VersionPrefix in Directory.Build.props — and everything else
is derived from it, so a workflow cannot disagree with the package about what was produced:
HEAD tagged v0.1.0 -> 0.1.0 a release
HEAD past that tag -> 0.1.0-00de9391c a candidate for the next release
The final segment is the first nine characters of the commit hash, which makes every build identifiable and traceable to a commit without a lookup.
The increment is declared, not computed. A commit graph cannot tell you whether the next
release is a patch, a minor, or a breaking change — that is a judgement about the API. So
VersionPrefix is the version the next release will carry, and bumping it is the release
decision. 0.x minor bumps are treated as breaking, per SemVer's rule that anything may change
before 1.0.
The pre-release identifier form is used rather than a fourth version segment because that is the
only shape NuGet accepts for a hash. dotnet pack was probed to establish this rather than
assuming it:
| Form | Result |
|---|---|
0.1.0.00de9391c |
rejected — "not a valid version string". The fourth segment of a four-part version must be numeric, and a hex hash contains letters |
0.1.0+00de9391c |
accepted, but the artifact is VProbe.0.1.0.nupkg — NuGet strips build metadata from the package identity, so the hash would vanish |
0.1.0-00de9391c |
accepted, artifact VProbe.0.1.0-00de9391c.nupkg — the hash is the final segment and visible in the name |
A pre-release sorts before the release it precedes, which is the right order: an untagged build
of 0.1.0 is a candidate for 0.1.0, not a replacement for it. Building a commit that is v0.1.0
exactly tagged drops the hash and produces the bare 0.1.0, because the tag is the identity.
The resolver refuses two mistakes rather than producing a package that is wrong quietly: a
VersionPrefix that is semver-shaped but malformed, and a VersionPrefix below a version already
tagged (NuGet would treat that package as older than what exists, and silently ignore it).
- requirements/requirements.md — requirements and locked decisions
- docs/api-notes.md — verified API facts, including four places where the vendor's prose docs and the OpenAPI specification disagree
- CHANGELOG.md — what has changed
- SECURITY.md — credential handling and what this library treats as sensitive
Authored by deepseek-v4.1-flash (via Hermes Agent), under deep human review.
The model drafted the requirements, the decision ledger, the API analysis, and the implementation. Every decision recorded in requirements/requirements.md was reviewed, challenged, and explicitly approved by a human before being locked. Where the model made a recommendation the human disagreed with, the human's call is what the document records.
MIT. See LICENSE.