// AIAsistent .NET client: copy this single file into your backend (BCL only, net8.0+). // // var ai = new AiAsistentClient(new Uri("https://ai.example.com"), apiKey); // var r = await ai.InterpretAsync(new("Tent under 3000 CZK?", [Field.Integer("maxPrice", "Max price in CZK.")])); // if (r.IsReady) { var max = r.Get("maxPrice"); /* search your own data, then AnswerAsync */ } using System.Globalization; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; namespace AIAsistent.Client; /// Declares one value the model should extract from the question. /// Identifier: letter first, then letters, digits or underscore, max 50 chars. /// One of string, integer, boolean, date, stringArray. Prefer the factory helpers. /// What the value means (max 500 chars). /// Whether the model must produce a value. /// Closed set of values (string and stringArray only). public sealed record Field(string Name, string Type, string Description, bool Required = false, string[]? AllowedValues = null) { /// Text value, optionally limited to . public static Field String(string name, string description, bool required = false, params string[] allowedValues) => Make(name, "string", description, required, allowedValues); /// 32-bit integer value. public static Field Integer(string name, string description, bool required = false) => Make(name, "integer", description, required, []); /// true/false value. public static Field Boolean(string name, string description, bool required = false) => Make(name, "boolean", description, required, []); /// Date returned as yyyy-MM-dd; read it with Get<string> or Get<DateOnly>. public static Field Date(string name, string description, bool required = false) => Make(name, "date", description, required, []); /// List of text values, optionally limited to . public static Field StringArray(string name, string description, bool required = false, params string[] allowedValues) => Make(name, "stringArray", description, required, allowedValues); private static Field Make(string name, string type, string description, bool required, string[] allowed) => new(name, type, description, required, allowed is { Length: > 0 } ? allowed : null); } /// One prior chat turn. Role is "user" or "assistant". public sealed record HistoryMessage(string Role, string Content); /// A document the answer may be based on. Url must be an absolute HTTPS URL. public sealed record Source(string Id, string Title, string Text, string Url, DateTimeOffset? UpdatedAt = null); /// Request for . Context is any object serialized as a JSON object. public sealed record InterpretationRequest(string Question, IReadOnlyList Fields, string? Locale = null, IReadOnlyList? History = null, object? Context = null); /// Request for . An empty Sources list is valid and yields insufficient_sources. public sealed record AnswerRequest(string Question, IReadOnlyList Sources, string? Locale = null, IReadOnlyList? History = null, object? Context = null); /// Provider token counts; Known is false when the provider reported none. public sealed record TokenUsage(long? InputTokens, long? OutputTokens, bool Known); /// Monthly quota from the X-Quota-* headers. Reset is the start of the next UTC month. public sealed record QuotaInfo(int Limit, int Remaining, DateTimeOffset Reset); /// A source the answer cites. public sealed record SourceReference(string Id, string Title, string? Url, DateTimeOffset? UpdatedAt); /// Fields common to every successful response. public abstract record ApiResult { /// X-Request-Id (falls back to the body value). Quote it in support requests. public string? RequestId { get; init; } /// Null when the response carried no quota headers (test keys, empty-source answers). public QuotaInfo? Quota { get; init; } /// True when a test key served the call (deterministic sample data, no quota used). public bool IsTestMode { get; init; } } /// Result of an interpretation. Check before reading . public sealed record InterpretationResult : ApiResult { /// "ready" or "needs_clarification". public string Status { get; init; } = ""; /// Extracted values keyed by field name (empty object when clarification is needed). public JsonElement Parameters { get; init; } /// Question to show the user when . public string? Clarification { get; init; } /// Token usage of the call. public TokenUsage? Usage { get; init; } /// The parameters are usable. public bool IsReady => Status == "ready"; /// Ask the user and call again with the reply in History. public bool NeedsClarification => Status == "needs_clarification"; /// Reads a parameter as (string, int, bool, string[], DateOnly...). Returns default when missing or null. public T? Get(string name) => Parameters.ValueKind == JsonValueKind.Object && Parameters.TryGetProperty(name, out var v) && v.ValueKind != JsonValueKind.Null ? v.Deserialize(AiAsistentClient.Json) : default; } /// Result of an answer call. public sealed record AnswerResult : ApiResult { /// "answered" or "insufficient_sources". public string Status { get; init; } = ""; /// Plain text; render it escaped. public string Answer { get; init; } = ""; /// The supplied sources the answer relies on. public IReadOnlyList Sources { get; init; } = []; /// Token usage of the call. public TokenUsage? Usage { get; init; } /// The answer is grounded in . public bool IsAnswered => Status == "answered"; /// The sources did not contain enough information; show a fallback. public bool IsInsufficient => Status == "insufficient_sources"; } /// Current UTC month counters from GET /v1/usage. public sealed record UsageResult : ApiResult { /// First day of the counted month. public DateOnly Month { get; init; } /// Requests counted against the quota. public int ReservedRequests { get; init; } /// The tenant's monthly request limit. public int MonthlyRequestLimit { get; init; } /// Input tokens consumed this month. public long InputTokens { get; init; } /// Output tokens consumed this month. public long OutputTokens { get; init; } /// Requests whose token usage the provider did not report. public int UnknownUsageRequests { get; init; } } /// Thrown for every non-2xx response. Never contains the API key. public sealed class AiAsistentException : Exception { /// Creates the exception from parsed ProblemDetails. public AiAsistentException(int statusCode, string code, string? detail, string? field, string? traceId, TimeSpan? retryAfter) : base($"AIAsistent API error {statusCode} {code}" + (detail is null ? "" : $": {detail}")) { StatusCode = statusCode; Code = code; Detail = detail; Field = field; TraceId = traceId; RetryAfter = retryAfter; } /// HTTP status code. public int StatusCode { get; } /// Stable machine code, e.g. validation_error, unauthorized, quota_exceeded; "http_error" when the body was not ProblemDetails. public string Code { get; } /// Human-readable English message (validation errors). public string? Detail { get; } /// JSON path of the offending input, e.g. sources[0].url (validation errors). public string? Field { get; } /// Correlation id; log it, quote it in support requests. public string? TraceId { get; } /// From Retry-After (429); null when absent. public TimeSpan? RetryAfter { get; } } /// /// Thin client for the AIAsistent HTTP API. Thread-safe; reuse one instance. /// There are no automatic retries. A timeout () or network failure on a paid call is ambiguous /// (the provider may already have processed and billed it), so do not retry it blindly. Network exceptions propagate unchanged. /// public sealed class AiAsistentClient { internal static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; private readonly HttpClient _http; private readonly string _apiKey; // Slash-terminated copy of BaseAddress: a relative path like "v1/usage" would otherwise replace the last // segment of "https://host/api" instead of extending it. The injected HttpClient itself is never mutated. private readonly Uri _requestBase; /// Uses a caller-owned HttpClient (e.g. from IHttpClientFactory) whose BaseAddress is set. Keep its Timeout above 30 s. public AiAsistentClient(HttpClient httpClient, string apiKey) { _http = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); _requestBase = WithTrailingSlash(httpClient.BaseAddress ?? throw new ArgumentException("HttpClient.BaseAddress must be set.", nameof(httpClient))); _apiKey = string.IsNullOrWhiteSpace(apiKey) ? throw new ArgumentException("API key is required.", nameof(apiKey)) : apiKey; } /// Creates its own HttpClient with a 40 s timeout (the server's model timeout is 30 s). public AiAsistentClient(Uri baseAddress, string apiKey) // PooledConnectionLifetime lets a long-lived instance pick up DNS changes; the default handler keeps connections forever. : this(new HttpClient(new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) }) { BaseAddress = WithTrailingSlash(baseAddress ?? throw new ArgumentNullException(nameof(baseAddress))), Timeout = TimeSpan.FromSeconds(40) }, apiKey) { } private static Uri WithTrailingSlash(Uri uri) => uri.AbsolutePath.EndsWith('/') ? uri : new UriBuilder(uri) { Path = uri.AbsolutePath + "/" }.Uri; /// Turns a natural-language question into typed parameters for your own search. Consumes one quota unit (live keys). public async Task InterpretAsync(InterpretationRequest request, CancellationToken ct = default) { var (body, meta) = await SendAsync(HttpMethod.Post, "v1/interpretations", request, ct).ConfigureAwait(false); return new InterpretationResult { RequestId = meta.RequestId ?? body.RequestId, Quota = meta.Quota, IsTestMode = meta.IsTestMode, Status = body.Status ?? "", Parameters = body.Parameters, Clarification = body.Clarification, Usage = body.Usage, }; } /// Answers a question strictly from the supplied sources. Consumes one quota unit unless sources is empty. public async Task AnswerAsync(AnswerRequest request, CancellationToken ct = default) { var (body, meta) = await SendAsync(HttpMethod.Post, "v1/answers", request, ct).ConfigureAwait(false); return new AnswerResult { RequestId = meta.RequestId ?? body.RequestId, Quota = meta.Quota, IsTestMode = meta.IsTestMode, Status = body.Status ?? "", Answer = body.Answer ?? "", Sources = body.Sources ?? [], Usage = body.Usage, }; } /// Returns the tenant's current UTC month counters. Does not consume quota. public async Task GetUsageAsync(CancellationToken ct = default) { var (b, meta) = await SendAsync(HttpMethod.Get, "v1/usage", null, ct).ConfigureAwait(false); return new UsageResult { RequestId = meta.RequestId, Quota = meta.Quota, IsTestMode = meta.IsTestMode, Month = b.Month, ReservedRequests = b.ReservedRequests, MonthlyRequestLimit = b.MonthlyRequestLimit, InputTokens = b.InputTokens, OutputTokens = b.OutputTokens, UnknownUsageRequests = b.UnknownUsageRequests, }; } private async Task<(T Body, Meta Meta)> SendAsync(HttpMethod method, string path, object? payload, CancellationToken ct) where T : new() { using var req = new HttpRequestMessage(method, new Uri(_requestBase, path)); // Per-request header: never mutates a shared HttpClient's DefaultRequestHeaders. req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _apiKey); if (payload is not null) req.Content = JsonContent.Create(payload, payload.GetType(), options: Json); using var res = await _http.SendAsync(req, ct).ConfigureAwait(false); var text = await res.Content.ReadAsStringAsync(ct).ConfigureAwait(false); var meta = ReadMeta(res); if (!res.IsSuccessStatusCode) throw ToException(res, text, meta); try { return (JsonSerializer.Deserialize(text, Json) ?? new T(), meta); } catch (JsonException) { throw new AiAsistentException((int)res.StatusCode, "invalid_response", "Response body was not valid JSON.", null, meta.RequestId, null); } } private static Meta ReadMeta(HttpResponseMessage res) { string? One(string name) => res.Headers.TryGetValues(name, out var v) ? v.FirstOrDefault() : null; QuotaInfo? quota = null; if (int.TryParse(One("X-Quota-Limit"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var limit) && int.TryParse(One("X-Quota-Remaining"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var remaining) && DateTimeOffset.TryParse(One("X-Quota-Reset"), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var reset)) quota = new QuotaInfo(limit, remaining, reset); return new Meta(One("X-Request-Id"), quota, string.Equals(One("X-Test-Mode"), "true", StringComparison.OrdinalIgnoreCase)); } private static AiAsistentException ToException(HttpResponseMessage res, string text, Meta meta) { var code = "http_error"; string? detail = null, field = null, traceId = meta.RequestId; try { using var doc = JsonDocument.Parse(text); if (doc.RootElement.ValueKind == JsonValueKind.Object) { string? S(string n) => doc.RootElement.TryGetProperty(n, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; code = S("code") ?? code; detail = S("detail"); field = S("field"); traceId = S("traceId") ?? traceId; } } catch (JsonException) { /* proxy or HTML error page: keep http_error */ } var ra = res.Headers.RetryAfter; // An HTTP-date in the past (clock skew) would give a negative wait; clamp it. TimeSpan? retryAfter = ra?.Delta ?? (ra?.Date is { } d ? TimeSpan.FromTicks(Math.Max(0, (d - DateTimeOffset.UtcNow).Ticks)) : null); return new AiAsistentException((int)res.StatusCode, code, detail, field, traceId, retryAfter); } private sealed record Meta(string? RequestId, QuotaInfo? Quota, bool IsTestMode); private sealed class InterpretationBody { public string? RequestId { get; set; } public string? Status { get; set; } public JsonElement Parameters { get; set; } public string? Clarification { get; set; } public TokenUsage? Usage { get; set; } } private sealed class AnswerBody { public string? RequestId { get; set; } public string? Status { get; set; } public string? Answer { get; set; } public List? Sources { get; set; } public TokenUsage? Usage { get; set; } } private sealed class UsageBody { public DateOnly Month { get; set; } public int ReservedRequests { get; set; } public int MonthlyRequestLimit { get; set; } public long InputTokens { get; set; } public long OutputTokens { get; set; } public int UnknownUsageRequests { get; set; } } }