AI gateway and assistant
Blouppy.Ai is a module: one gateway that every call to a model goes through, and two
features built on it — a workspace description drafted from the workspace's name, and
an assistant that answers questions about the organization's data and proposes changes.
The model provider sits behind a driven port, ILlmClient, with Anthropic as the one
adapter. The Starter builds and runs without a provider key.
What it does
For the people using the app
- Built-in AI is an organization setting, on by default, that owners and administrators change under Organization → AI. It is checked on the server before every call, so turning it off holds for every way in — a button, the assistant, a feature added later.
- A monthly allowance per person. Each member has their own allowance for the calendar month (UTC), under a ceiling for the whole organization. One member's use never takes from another's. Members never see a number: when their allowance is used, the app says when it comes back.
- What owners and administrators see, on the billing page: this month's use against the ceiling, a breakdown per feature, and the five members who used the most — never what anyone asked.
- The organization's own key. On a plan that allows it, an administrator saves the organization's own provider key. Its calls are used instead of the server's key, recorded, and counted against nobody, since the organization pays the provider directly. The key is stored encrypted; only its last four characters are shown again.
- The two features: Draft with AI on a workspace's general settings, which suggests a description the person edits before saving; and the assistant, a panel over the whole shell (the app bar's button, or Cmd+K / Ctrl+K).
Plans
The allowances are Billing's entitlements (BillingCatalogOptions), measured in
credits:
| Entitlement | Free | Pro | Business |
|---|---|---|---|
MonthlyAiCreditsPerMember | 30 | 300 | 1,000 |
MonthlyAiCreditsPerSeat (the ceiling) | 100 | 300 per seat | 1,000 per seat |
MonthlyAutomationAiCredits (the pool) | 0 | 500 | 2,000 |
AllowsOwnAiKey | no | no | yes |
One credit is US$0.01 of provider cost at list price (AiCreditPricing.CreditInUsd).
A call's cost is its four token counts — input, output, cache read, cache write — at the
served model's rate, rounded up, at least one credit; a call that consumed no token
costs nothing. The pool is for automatic work nobody asked for at that moment; both
features shipped are interactive, so nothing draws on it yet. See
Billing for where entitlements come from.
For the developer
- One gateway. A feature names an
AiFeature, builds a prompt and callsIAiGateway. The switch, the key, the allowance and the ledger are the gateway's. - A ledger of every call:
ai_usage_recordsholds the organization, the user, the feature, the provider, the model, the tokens, the credits and the outcome — never what was sent or answered. Rows are purged after 13 months (AiRules.UsageRetentionInMonths) byAiUsageRecordPurgeJob, a daily Quartz.NET job. - Refusals with a code.
GlobalExceptionHandler(Blouppy.Web/ExceptionHandlers/) turns each refusal or failure into problem details with acode, so the front can say what the person can do about it.
| Code | When | Status |
|---|---|---|
ai.unavailable | No configured client for the organization | 503 |
ai.disabled | Built-in AI is off | 403 |
ai.paused | The organization's ceiling is reached | 403 |
ai.personalLimitReached | The person's own allowance is used | 403 |
ai.ownKeyRejected / ai.ownKeyUnreadable | The organization's key was refused, or can no longer be decrypted | 403 |
ai.declined | The model declined to answer | 422 |
ai.unusableAnswer | The answer did not fit the feature's schema | 502 |
ai.busy / ai.providerFailed | The provider is overloaded, or failed | 503 / 502 |
How it works
| Project | Holds |
|---|---|
Blouppy.Infrastructure.Llm.Abstractions | The driven port ILlmClient, LlmRequest, LlmResponse, LlmStreamEvent, ILlmKeyVerifier, ILlmClientFactory |
Blouppy.Infrastructure.Llm | The adapters: Anthropic/AnthropicLlmClient (the official Anthropic SDK) and NotConfiguredLlmClient; AddLlmClient |
Blouppy.Ai.Models | AiFeature, AiUsageRecord, the credit periods, OrganizationAiKey, the assistant's conversations and proposals |
Blouppy.Ai.Abstractions | What the Web calls: IAiAvailabilityService, IAiUsageService, IOrganizationAiKeyService, IWorkspaceDescriptionDraftService, IAssistantGateway |
Blouppy.Ai | AiGateway and its collaborators, the credits, the keys, the example feature; AddAiModule |
Blouppy.Assistant | The assistant's tool loop (AssistantTurnService), its decisions and its SSE stream |
The port is deliberately not in Blouppy.Ai.Abstractions: that is the contract the
Web consumes, and a controller that could inject ILlmClient would skip the switch, the
allowance and the ledger. LlmBoundaryTests (Blouppy.Architecture.UnitTests) fails the
build if any type other than Blouppy.Ai.Gateway.AiGateway injects it.
A call
- A refusal is not a call: nothing is written and nothing is charged.
- The model and the effort are configuration. An
AiPromptcarries the system prompt, the messages, an optional JSON schema and an output ceiling — no model. The gateway appliesAi:Features:{feature}, so no feature picks its own. - The ledger never fails a request. By the time the row is written the provider has
answered; an error writing it is logged. Only
CompletedandCancelledoutcomes are charged;Refused,FailedandUnusableare recorded and counted against nobody. A call that started with credits left is charged at most what was left. - Who asked is sent to the provider as an opaque hash (
EndUserIds), keyed byAi:EndUserIdSalt; none is sent when the salt is empty. - The front never offers a call the gateway would refuse.
GetAvailabilityAsyncruns the three checks as questions;GET /api/v1/organizations/{id}/aireturns them to the SPA'sAiAvailabilityService.
The organization's own key
OrganizationAiKeyService encrypts the key with ISecretProtector (purpose
Blouppy.Ai.OrganizationAiKey.v1) and stores the ciphertext and the last four
characters in ai_organization_keys. OrganizationLlmClientResolver hands the gateway
a client on that key when the plan has AllowsOwnAiKey and a key is saved, the server's
client otherwise; OrganizationLlmClients caches one client per organization. The
endpoints are under /api/v1/organizations/{id}/ai/key (GET, PUT, DELETE, and
POST test, which asks the provider through ILlmKeyVerifier whether the key works,
sending nothing of the organization's). Saving and removing the key, and turning
Built-in AI on or off, are recorded in the audit log.
The assistant
POST /api/v1/organizations/{id}/ai/assistant/turns answers with server-sent events
in the response to that POST: conversation, text, tool, proposal, usage
and done, with a keep-alive comment every 15 seconds. A refusal comes before the
stream opens, as problem details.
- Each round is one call through the gateway (
AiFeature.Assistant, viaIAssistantGateway), checked and charged like any other, and grouped on the ledger by the turn's id. A turn is bounded byAi:Assistant:MaxRounds(10) andAi:Assistant:MaxOutputTokensPerRound(16,000). - The tools are the same catalog as the MCP server, confined to the organization the panel is in. Anyone who may read the organization may ask; only someone who may contribute is offered the write tools.
- Browser only. A turn made with a token is refused (
403). - Writes are proposed, not run. A write tool call ends the turn on a proposal of up
to 10 changes (
AssistantProposal.MaxActions), kept for 30 minutes. Apply runs the chosen ones with the person's rights at that moment, stamped as made through the assistant; a proposal is decided once (409 ai.proposalDecided) and expires (410 ai.proposalExpired). Applying calls no model. - Conversations are kept per person and organization, the last 24 exchanges
replayed to the model on the next turn (
AssistantTurnService.MaxReplayedExchanges).
Configuration
Set on the API.
| Setting | Default | What it does |
|---|---|---|
Llm__Provider | Anthropic | The adapter; the only accepted value today |
Llm__Anthropic__ApiKey | empty | The server's key. A secret: user secrets or the environment |
Llm__Anthropic__MaxRetries | 2 | The SDK's retries (0 to 10) |
Llm__Anthropic__Timeout, …__StreamingTimeout | 10 min, 30 min | The SDK's timeouts |
Ai__Features__<Feature>__Model, …__Effort | claude-opus-5-5; Medium for Assistant, Low for WorkspaceDescriptionDraft | Each feature's model and effort (Low, Medium, High, XHigh, Max) |
Ai__CreditRates__<model>__InputPerMillion (and Output…, CacheRead…, CacheWrite…) | the list prices in AiOptions | US$ per million tokens, per model |
Ai__EndUserIdSalt | empty | Keys the end-user hash; empty sends none |
Ai__Assistant__MaxRounds, …__MaxOutputTokensPerRound | 10, 16,000 | A turn's ceilings (1–50; 1,000–128,000) |
AiOptionsValidator refuses to start on a feature without a model, or a model without
a credit rate. In development, give the AppHost the key, which it passes to the API:
dotnet user-secrets set "Parameters:anthropic-apiKey" "sk-ant-…" --project back/Blouppy.Aspire.AppHost
Extending it
The workspace description draft (back/Blouppy.Ai/Writing/) is the worked example.
- Name the feature. Add a value to
AiFeature(Blouppy.Ai.Models/AiFeature.cs) and itsAiUsageKindin theKindswitch beside it:Interactivewhen a person asks,Automaticwhen a job does. The ledger stores it by name, so keep it stable. - Configure its model. Add its entry to
AiOptions.Features— model and effort — and aCreditRatesrow if the model is new. The validator refuses either missing. - Declare the contract in
Blouppy.Ai.Abstractions/<Area>/: anIXxxService, its input record and its result, returningValidationResult<T>(Writing/IWorkspaceDescriptionDraftService.cs). - Write the prompt as an internal static class building an
AiPrompt: a fixed system prompt, the user's content escaped and fenced as data, a JSON schema and an output ceiling (Writing/WorkspaceDescriptionPrompt.cs). Send only what the feature needs. - Validate the input with FluentValidation, including the person's right to ask
(
Writing/WorkspaceDescriptionFromNameValidator.cs). - Find who pays. Ask the peer that owns the thing for its organization through its
.PublicData— the example usesIWorkspacesPublicDataService.TryGetOrganizationIdAsync. - Call the gateway, never
ILlmClient. Do not re-check the switch, the key or the allowance. - Register it in
AddAiModule(Blouppy.Ai/ServiceCollectionExtensions.cs). - Expose it with a controller in
Blouppy.Web, guarded by the policy the thing itself needs (the example:Policies.Manageon the workspace, inControllers/Api/Workspaces/Ai/WorkspaceAiController.cs). Refusals need nothing:GlobalExceptionHandlermaps them. - Add its row to
AuditCoverage: an AI call isNotAudited(AiCall). - Offer it in the front: a method on
AiApiServicepassingAI_ACTION_QUIET_STATUSES, and an entry point that readsAiAvailabilityService(workspace-description-with-ai.component.ts). Put the answer where the person can edit it; never save it on their behalf.
Step 7, in the example service (Writing/WorkspaceDescriptionDraftService.cs):
var draft = await gateway.CompleteAsAsync<WorkspaceDescriptionAnswer, WorkspaceDescriptionDraft>(
new AiCall(organizationId, request.UserId, AiFeature.WorkspaceDescriptionDraft),
WorkspaceDescriptionPrompt.For(request.Name),
ToDraft,
logger,
cancellationToken);
CompleteAsAsync (Answers/StructuredAnswers.cs) reads the JSON answer against the
schema and hands it to ToDraft, which checks what a schema cannot carry — here the
500-character limit — and throws AiAnswerException(AiAnswerFailure.Unusable) otherwise.
That call is then recorded Unusable and counted against nobody. Test the service
against FakeLlmClient (Blouppy.Shared.TestHelpers/Llm/).