Jev.Net
0.5.0
dotnet add package Jev.Net --version 0.5.0
NuGet\Install-Package Jev.Net -Version 0.5.0
<PackageReference Include="Jev.Net" Version="0.5.0" />
<PackageVersion Include="Jev.Net" Version="0.5.0" />
<PackageReference Include="Jev.Net" />
paket add Jev.Net --version 0.5.0
#r "nuget: Jev.Net, 0.5.0"
#:package Jev.Net@0.5.0
#addin nuget:?package=Jev.Net&version=0.5.0
#tool nuget:?package=Jev.Net&version=0.5.0
Jev.Net
A lightweight .NET client for the TypeSafe AI API — System One / Jev. Ask small typed questions about text or structured state and get calibrated probabilities back that your code can act on.
A community package. Not affiliated with or endorsed by TypeSafe AI.
dotnet add package Jev.Net
Why this one
One dependency. Microsoft.Extensions.Logging.Abstractions, and nothing else. Retries, backoff,
Retry-After and per-attempt timeouts are about a hundred lines of its own, so it drops into a library, a CLI,
a desktop app or a worker without changing what your dependency graph looks like. That promise is enforced:
CI reads the packed .nuspec and fails on a second dependency. Targets net8.0 and net10.0, and is
trim- and Native AOT-compatible — a smoke app is published as a native binary and run on every build.
The Python SDK, in C#. It is a faithful port of the official
typesafe-sdk (read at 0.7.4): the same request shape,
the same protected headers, the same exception hierarchy and the same message text, the same retry rules,
the same dotted field paths when a response is malformed. Its test suite is a mirror of upstream's, file by
file — so behaviour you read about in TypeSafe's docs, or debugged once in Python, is the behaviour you get
here. Where .NET forces a difference, it is listed at the bottom.
Looking for something else?
TypeSafeAI.Net by Hawxy is an excellent community SDK with a
different focus, and it got here first: dependency-injection and IHttpClientFactory integration, enum-typed
choices, a QuestionSet builder with typed handles, Native AOT support, and a companion package of
Microsoft.Extensions.AI middleware (guardrails, routing, evaluators). If that is the shape of your app, use
it — the two projects make different trade-offs on purpose, and both speak the same API.
Quick start
await using var client = new TypeSafeClient(); // key from TYPESAFE_API_KEY
var result = await client.SystemOneAsync(
"I was charged twice. Please help.",
new Dictionary<string, Question>
{
["billing"] = new Noul("Is this about billing?"),
["tone"] = new Choice(["calm", "frustrated", "angry"], "What is the customer's tone?"),
["urgency"] = new Score(["can wait", "this week", "today"], "How urgent is this ticket?"),
});
double billing = result.Nouls["billing"].Noul; // probability of YES — 0.5 is "unsure", not "medium"
string tone = result.Choices["tone"].Choice;
double urgency = result.Scores["urgency"].Score; // may fall between rubric levels
Questions are independent and answered in one round trip, so ask everything about a state together.
Typed answers by name
Derive from SystemOneResponse and declare answer-typed properties; each is filled from the answer of the same
name ([JsonPropertyName], else the property name — exact, case-insensitive, then snake_case). Every such
property is required — a missing answer, or one of the wrong kind, is a
TypeSafeApiResponseValidationException naming the field — unless you mark it [OptionalAnswer].
sealed class Ticket : SystemOneResponse
{
public NoulAnswer Billing { get; set; } = null!;
public ChoiceAnswer Tone { get; set; } = null!;
[OptionalAnswer] public ScoreAnswer? Urgency { get; set; }
}
var ticket = await client.SystemOneAsync<Ticket>(state, questions);
To read the body into a type that is entirely yours, pass JSON metadata — source-generated for trimmed and AOT apps, or reflection-based when that doesn't matter:
var viaSourceGen = await client.SystemOneAsync(state, questions, options: null, MyJsonContext.Default.MyEnvelope); // AOT-safe
var viaReflection = await client.SystemOneAsync<MyEnvelope>(state, questions, options: null, ResponseJson.SnakeCase);
State, instructions and criteria: JsonContent
Everywhere the API takes "text, an object, or an array", the SDK takes a JsonContent. It converts implicitly
from string and from JsonNode; JsonContent.From(value) serializes anything else (pass a JsonTypeInfo<T>
as the second argument in a trimmed or AOT app). Nodes are deep-cloned in
and out, so one JsonObject can appear in several questions and a question can be sent any number of times —
System.Text.Json nodes have a single parent, and encoding by reference would throw on the second use.
null means omit for an optional field; JsonContent.Null means send an explicit null. A choice label
mapped to null is sent as null (undescribed — interpreted by its name). A NaN or infinite number anywhere in
content is sent as the string "NaN", "Infinity" or "-Infinity", as upstream sends it — JSON has no token for them.
Limits. The API documents at most 255 choice options and 2–10 score levels. Jev.Net, like the Python SDK,
does not enforce these client-side — an out-of-range question comes back as a
TypeSafeUnprocessableEntityException naming the field. What IS rejected before the network is structural, not
schema: a null or missing state, an empty question set, a null question, an empty score rubric or choice, a noul
with neither instructions nor a described outcome, and a raw question without a type (or a raw choice/score
without nonempty criteria, or a raw noul whose criteria is present, not null, and not an object).
Raw questions
A JsonObject converts implicitly to a Question and is sent exactly as given — the escape hatch for fields or
question types the API adds before this SDK models them. Only its structure is checked (type present; choice
and score have nonempty criteria; a noul's criteria, unless missing or null, is an object, and it has
instructions or a described outcome); its schema is left to the API.
Dependency injection
There is no AddTypeSafeClient() and no DI package — it would cost the one-dependency promise, and registering
the client is a few lines you can read. The client is thread-safe and cheap to construct.
A singleton, on the SDK's own handler. This is the one to reach for. The handler the SDK builds for itself recycles pooled connections every two minutes, so a client that lives as long as your app still follows DNS changes — no factory needed.
services.AddSingleton<ITypeSafeClient>(sp => new TypeSafeClient(new TypeSafeClientOptions
{
ApiKey = sp.GetRequiredService<IConfiguration>()["TypeSafe:ApiKey"],
LoggerFactory = sp.GetService<ILoggerFactory>(),
}));
Through IHttpClientFactory, when you want your host's handlers — a proxy, extra resilience, outbound logging.
Register it transient, not singleton: a singleton would hold the one HttpClient it was built with forever,
and the factory could never rotate the handler underneath it — quietly undoing the DNS refresh the factory exists
to provide.
services.AddHttpClient("typesafe"); // your host's handlers, proxy and outbound logging apply
services.AddTransient<ITypeSafeClient>(sp => new TypeSafeClient(new TypeSafeClientOptions
{
ApiKey = sp.GetRequiredService<IConfiguration>()["TypeSafe:ApiKey"],
HttpClient = sp.GetRequiredService<IHttpClientFactory>().CreateClient("typesafe"),
DisposeHttpClient = false, // the factory owns the handler underneath
LoggerFactory = sp.GetService<ILoggerFactory>(),
}));
Inject that one into scoped or transient services (a controller, a handler), not into a singleton — which would
capture it and bring the problem back. A supplied HttpClient also keeps its own redirect policy and its own
Timeout, which caps every attempt (see SECURITY.md). If you would rather have this done for
you, TypeSafeAI.Net ships an AddTypeSafeClient() that registers a typed
client, binds its options from a configuration section, and hands back the IHttpClientBuilder to extend.
Testing code that uses the client
TypeSafeClient implements ITypeSafeClient, so your code can take the interface and your tests can hand it a
fake — and the fake can return genuine responses, decoded and validated exactly as the client would:
var raw = new RawHttpResponse(200, headers: null, Encoding.UTF8.GetBytes(recordedJson));
var response = SystemOneResponse.FromHttpResponse(raw); // or FromHttpResponse<Ticket>(raw)
The same call turns a cached or replayed body back into a response; a non-2xx snapshot throws the matching
exception. To fake at the HTTP level instead, give the real client a Handler.
Samples
samples/Jev.Net.Samples has three small programs in the shape of TypeSafe's
cookbooks — each keeps the workflow in code and asks the model only for the judgment:
| Sample | The idea |
|---|---|
IntentRouting |
Pick a handler, ask for each branch's argument speculatively in the same round trip, and send low-confidence or no-match cases to a person. |
ValueSelection |
Select, don't generate: code finds every candidate amount, the model only points at the total, code parses it — so it cannot invent a number. |
CompositeScoring |
Score narrow dimensions once, then re-rank under different weights with no further calls. |
dotnet run --project samples/Jev.Net.Samples -f net10.0 -- routing # needs TYPESAFE_API_KEY; the tests run all three offline
The C# blocks in this README are regions of ReadmeSnippets.cs in
that project: they are compiled on every build, and a test fails if the two drift apart.
Errors
| Exception | When |
|---|---|
TypeSafeException |
Base. Also: no API key, invalid timeout, empty questions, empty score rubric. |
TypeSafeApiException |
Any non-2xx. Status, Body, Headers, Endpoint, RequestId. |
…BadRequest / Authentication / PermissionDenied / NotFound / UnprocessableEntity |
400 / 401 / 403 / 404 / 422 |
TypeSafeRateLimitException |
429. RetryAfter is the server's requested wait. |
TypeSafeInternalServerException |
5xx, including the API's 529 "service overloaded" — retried by default |
TypeSafeApiConnectionException |
No HTTP response at all. |
TypeSafeApiTimeoutException |
The per-attempt timeout elapsed (derives from the connection exception). |
TypeSafeApiResponseValidationException |
A 2xx whose body is structurally wrong. FieldPath names the first bad field: answers.tone.confidence, models[1].name. |
Message reads POST https://api.typesafe.ai/v1/systemone: 429 <server's explanation> (request_id=…). The
endpoint never carries credentials, a query string, or a fragment.
Caller cancellation is not a timeout: cancelling your token surfaces as OperationCanceledException, is never
retried, and also interrupts a wait between retries.
Retries
RetryPolicy defaults: 2 retries; 408, 429 and 5xx; connection and timeout failures; exponential backoff from
0.5s to 5s with up to 25% subtracted as jitter; Retry-After / retry-after-ms honored (however long); and a
30s total budget per call that stops before a wait that would exceed it, rethrowing the last error. Set it on
the client, or per call through RequestOptions.Retry. RetryPolicy.None disables retrying.
Configuration
| Option | Environment | Default |
|---|---|---|
ApiKey |
TYPESAFE_API_KEY |
— (required) |
BaseUrl |
TYPESAFE_BASE_URL |
https://api.typesafe.ai |
Model |
TYPESAFE_DEFAULT_MODEL |
jev-latest |
Timeout |
10s per attempt; Timeout.InfiniteTimeSpan for none |
|
LoggerFactory |
TYPESAFE_LOG_LEVEL (a floor) |
no logging |
Explicit options win; blank environment values count as unset. Authorization, Accept, User-Agent,
X-TypeSafe-SDK and X-TypeSafe-Runtime are protected — neither default nor per-call headers can replace them.
Logging (category Jev.Net): one Information line per attempt; headers and bodies at Debug.
Credential-bearing headers — and any header whose name contains token or secret — are redacted. Bodies are
not: they are whatever state you sent. Don't enable Debug where that matters.
Pin a model (jev-1.13.0, not jev-latest) wherever you have tuned thresholds against its probabilities.
Observability
Traces and metrics come through ActivitySource and Meter, which ship in the .NET runtime — so
OpenTelemetry support costs no dependency — and with nobody listening, the SDK skips the bookkeeping entirely.
services.AddOpenTelemetry()
.WithTracing(tracing => tracing.AddSource(TypeSafeTelemetry.ActivitySourceName))
.WithMetrics(metrics => metrics.AddMeter(TypeSafeTelemetry.MeterName));
One client span covers one SDK call including its retries (http.request.resend_count). The individual HTTP
attempts are not duplicated here: they come from .NET's own HTTP instrumentation, and appear beneath this span
only if you turn that on as well — .AddHttpClientInstrumentation() from OpenTelemetry.Instrumentation.Http,
or .AddSource("System.Net.Http"). The snippet above subscribes to Jev.Net alone. It carries the operation, status,
requested and answering model, token counts and the x-typesafe-request-id.
| Instrument | |
|---|---|
jev_net.client.request.duration |
Histogram, seconds — the whole call, waits included. Tagged with operation, status, error.type. |
jev_net.client.retries |
Counter — attempts after the first. |
jev_net.client.token.usage |
Counter — tagged jev_net.token.type = input | output, and the answering model. |
None of your content is recorded — no state, questions, answers or headers. Two things you configure
are: the model name you asked for (jev_net.request.model), and your base URL's host and path (server.address,
url.full — never its credentials or query). A failed span's description is fixed text — HTTP <status>,
invalid response body, cancelled, or the exception's type — never the server's message, which could echo
your request. The per-attempt child spans are .NET's, not ours. To keep those clean as well, the SDK never
sends a base URL's user:password@ on the wire and never adds a query string.
Python SDK → Jev.Net
If you know typesafe-sdk, you already know this. Everything in the left column behaves the same on the right.
| Python | Jev.Net |
|---|---|
AsyncTypeSafeClient(api_key=…, model=…, base_url=…, timeout=…, headers=…, retry=…) |
new TypeSafeClient(new TypeSafeClientOptions { ApiKey, Model, BaseUrl, Timeout, Headers, Retry }) |
transport= / http_client= |
Handler / HttpClient (mutually exclusive, as upstream) |
TYPESAFE_API_KEY, TYPESAFE_BASE_URL, TYPESAFE_DEFAULT_MODEL, TYPESAFE_LOG_LEVEL |
the same four variables, same precedence, blank = unset |
await client.system_one(state, questions, model=…, retry=…, timeout=…, extra_headers=…, extra_body=…) |
await client.SystemOneAsync(state, questions, new SystemOneOptions { Model, Retry, Timeout, ExtraHeaders, ExtraBody }) |
await client.models.list() |
await client.Models.ListAsync() |
Noul(instructions=…, criteria={"true": …, "false": …}) |
new Noul(instructions, new NoulCriteria { True = …, False = … }) |
Choice(instructions=…, criteria={"calm": None, "angry": "…"}) |
new Choice(["calm", "angry"], instructions) or a Dictionary<string, JsonContent?> with descriptions |
Score(instructions=…, criteria=["low", "high"]) |
new Score(["low", "high"], instructions) |
a raw {"type": "noul", …} dict |
a JsonObject (converts implicitly to Question) |
JSONContent (str, mapping, sequence) |
JsonContent (string, JsonObject, JsonArray, or JsonContent.From(…)) |
result.nouls["q"].noul, .choices["q"].choice / .confidence / .probabilities, .scores["q"].score / .legend |
result.Nouls["q"].Noul, .Choices["q"].Choice / .Confidence / .Probabilities, .Scores["q"].Score / .Legend |
result.answers, .model, .usage.input_tokens |
result.Answers, .Model, .Usage.InputTokens |
result.request_id, result.raw_http_response |
result.RequestId, result.RawHttpResponse |
SystemOneResponse.from_http_response(response) |
SystemOneResponse.FromHttpResponse(raw), with RawHttpResponse.FromAsync(httpResponseMessage) or its public constructor |
response_model=MyResponse (a SystemOneResponse subclass with answer fields) |
SystemOneAsync<MyResponse>(…) |
response_model=AnyPydanticModel |
SystemOneAsync(state, questions, options, JsonTypeInfo<T>) or SystemOneAsync<T>(state, questions, options, JsonSerializerOptions) |
RetryPolicy(max_retries, backoff_initial, backoff_max, backoff_jitter, http_statuses, respect_retry_after, api_connection_error, api_timeout_error, exceptions, predicate, timeout) |
RetryPolicy { MaxRetries, BackoffInitial, BackoffMax, BackoffJitter, HttpStatuses, RespectRetryAfter, ApiConnectionError, ApiTimeoutError, Exceptions, Predicate, Timeout } — same defaults |
TypeSafeError → TypeSafeAPIError → …BadRequestError, …RateLimitError, … |
TypeSafeException → TypeSafeApiException → …BadRequestException, …RateLimitException, … |
error.status, .body, .headers, .endpoint, .request_id, .retry_after_ms, .field_path |
error.Status, .Body, .Headers, .Endpoint, .RequestId, .RetryAfter, .FieldPath |
str(error) |
error.Message — the same text |
| unknown answer types skipped with a warning; unknown fields ignored | the same — and the skipped ones are kept in result.UnmodeledAnswers |
async with client: |
await using var client = … |
Differences from the Python SDK
- Async only. No synchronous client; that is the .NET convention for HTTP.
- Durations are
TimeSpan, so the NaN/infinite-seconds cases upstream validates cannot be written. There is one per-attempt timeout rather than httpx's connect/read/write/pool split. HttpMessageHandler/HttpClientstand in for httpx's transport / client. A suppliedHttpClientis disposed with the SDK client, as upstream — setDisposeHttpClient = falsefor one fromIHttpClientFactory. When you supply one, its ownTimeoutstill caps every attempt.- Questions are a closed class hierarchy, so "a question that is not a question" cannot be constructed; typed
questions validate at construction with
ArgumentExceptions rather than pydantic errors. - No covariant question maps —
IReadOnlyDictionaryis invariant in its value; declare the dictionary asDictionary<string, Question>. - Optional answers are marked, not inferred. Python reads
Optional[...]; here a property is required unless it carries[OptionalAnswer]. Nullability is metadata the trimmer removes, so inferring from it would make the same class validate differently in a Native AOT build. - Traces and metrics (
ActivitySource/Meter) — upstream has neither; this is what a .NET service expects. TimeProvideris accepted for the retry clock — a .NET addition. So areITypeSafeClient,result.UnmodeledAnswers(the receiving half of raw questions),ScoreAnswer.MostLikely(the mode, beside the averagedScore) andTypeSafeDefaults.SdkVersion.- It identifies itself as
jev-net/<version>, not as the official SDK.
Tests
dotnet test --solution Jev.Net.slnx
Offline and sub-second: a stub HttpMessageHandler, an injected environment reader, an injected delay, and a
manual clock. LiveIntegrationTests is the one class that calls the real API, and it is opt-in — it needs
TYPESAFE_LIVE_TESTS=1 and TYPESAFE_API_KEY, and reports Inconclusive otherwise.
The suite was mutation-checked: the client was broken on purpose in nine places (protected headers, the retry
budget, header redaction, strict number decoding, node cloning, jitter, retry-after-ms precedence,
cancellation-vs-timeout, unknown answer types) and every break is caught.
tests/Jev.Net.AotSmoke is not a test project but a console app: CI publishes it with PublishAot and runs the
native binary on Linux and Windows, exercising request encoding, retries, answers-by-property-name,
source-generated JSON, and error mapping after the trimmer and AOT compiler have been through them.
License
MIT. See NOTICE for the upstream attribution.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.0)
-
net8.0
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.0)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Jev.Net:
| Package | Downloads |
|---|---|
|
PinkRooster.ProviderConfig.Jev
The jev provider type for PinkRooster.ProviderConfig: the Jev System One API of TypeSafe, through Jev.Net. |
GitHub repositories
This package is not used by any popular GitHub repositories.