Jev.Net 0.4.0

dotnet add package Jev.Net --version 0.4.0
                    
NuGet\Install-Package Jev.Net -Version 0.4.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Jev.Net" Version="0.4.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Jev.Net" Version="0.4.0" />
                    
Directory.Packages.props
<PackageReference Include="Jev.Net" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Jev.Net --version 0.4.0
                    
#r "nuget: Jev.Net, 0.4.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Jev.Net@0.4.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Jev.Net&version=0.4.0
                    
Install as a Cake Addin
#tool nuget:?package=Jev.Net&version=0.4.0
                    
Install as a Cake Tool

Jev.Net

CI NuGet MIT

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.0): 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).

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, and a raw question without a type (or a raw choice/score without criteria).

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 criteria; a score rubric is nonempty); its schema is left to the API.

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
TypeSafeErrorTypeSafeAPIError…BadRequestError, …RateLimitError, … TypeSafeExceptionTypeSafeApiException…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 / HttpClient stand in for httpx's transport / client. A supplied HttpClient is disposed with the SDK client, as upstream — set DisposeHttpClient = false for one from IHttpClientFactory. When you supply one, its own Timeout still 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 mapsIReadOnlyDictionary is invariant in its value; declare the dictionary as Dictionary<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.
  • TimeProvider is accepted for the retry clock — a .NET addition. So are ITypeSafeClient, result.UnmodeledAnswers (the receiving half of raw questions), ScoreAnswer.MostLikely (the mode, beside the averaged Score) and TypeSafeDefaults.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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.4.0 58 9/20/2026
0.2.0 99 9/20/2026
0.1.0 36 9/20/2026