feat(base)!: one budget per person for all their assistants, the effort each takes, the operator's lists by page

A grant now says only which assistant a person may use, and keeps their
agreement; the limits are the person's (assistant_budget): one per UTC
day and month in money and in tokens, each optional, money counting the
assistants with prices, tokens every one (Halkyon Learn's ADR 0021, D58,
D59). assistantOperator.grant gives several people several assistants
with one budget, change sets one person's exactly, revoke takes back
everything; grantees and granteesOf list people with their assistants,
budget and use, 50 at a time. The migration carries each person's grant
limits into a budget: per unit, their sum, a grant without a limit making
it none.

assistants.env gives each assistant a default and a highest effort
(_EFFORT, _MAX_EFFORT; refused at start where the provider takes none);
ask takes one within that range, refusing another (invalidEffort), and
caps the output at its ceiling (D63).

instanceOperator.waiting and accounts come 50 at a time from an offset,
and counts gives the rows of the operator's screen (D62).

BREAKING CHANGE: the offer, the grants and the operator's lists changed
shape; Learn's server now asks for build 25.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-26 15:07:04 +01:00
commit 24ed63585e
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
41 changed files with 50672 additions and 1371 deletions

View file

@ -0,0 +1,23 @@
### A person's budget for all their assistants together (Halkyon Learn's ADR
### 0021, D58 and D59), as `AssistantLimits` holds it; none: no limits. Kept
### by the register alone, deleted with the person or their last grant.
class: AssistantBudget
serverOnly: true
table: assistant_budget
fields:
### The person: their ID (deleted with them, `deletePerson`).
authUserId: UuidValue
### The most a UTC day may cost, in millionths of the currency; null for
### none.
dailyCostMicros: int?
### The most a UTC month may cost, in millionths of the currency.
monthlyCostMicros: int?
### The most tokens a UTC day may use, input and output together.
dailyTokens: int?
### The most tokens a UTC month may use.
monthlyTokens: int?
indexes:
### One budget per person.
assistant_budget_person_idx:
fields: authUserId
unique: true

View file

@ -13,7 +13,7 @@ import 'package:halkyon_llm/halkyon_llm.dart';
/// The most characters a question holds, all its text together (the system
/// prompt, the turns, the tools' results and calls, the tools' schemas):
/// about 100,000 tokens, which bounds what one question costs, as the
/// output's ceiling bounds the answer (D38).
/// output's ceiling bounds the answer (D38, `outputCeilingAt`).
const assistantQuestionMaxCharacters = 400000;
/// The most bytes of one image: 5 MiB once in base64, the most Anthropic
@ -35,14 +35,22 @@ Never _refused(AssistantRefusal refusal) =>
/// The request [question] stands for, to [assistant], with [personId] as the
/// provider's opaque ID of the person (D30). Refused
/// ([AssistantRefusedException]) for a turn out of place or a part without
/// what its kind needs ([AssistantRefusal.malformed]), an image to an
/// assistant that reads none ([AssistantRefusal.imagesNotRead]) and a
/// what its kind needs ([AssistantRefusal.malformed]), an effort the
/// assistant doesn't offer ([AssistantRefusal.invalidEffort], D63), an image
/// to an assistant that reads none ([AssistantRefusal.imagesNotRead]) and a
/// question past the limits above ([AssistantRefusal.tooLarge]).
LlmRequest requestOf(
AssistantQuestion question,
AssistantSetting assistant, {
required String personId,
}) {
final asked = switch (question.effort) {
null => null,
final effort => LlmEffort.named(effort.name),
};
if (question.effort != null && !assistant.efforts.contains(asked)) {
_refused(AssistantRefusal.invalidEffort);
}
var characters = question.system.length;
var images = 0;
final messages = <LlmMessage>[];
@ -99,7 +107,11 @@ LlmRequest requestOf(
system: question.system,
messages: messages,
tools: tools,
maxOutputTokens: question.maxOutputTokens.clamp(1, assistantOutputCeiling),
maxOutputTokens: question.maxOutputTokens.clamp(
1,
assistant.outputCeilingAt(asked),
),
effort: asked ?? assistant.effort,
personId: personId,
);
}

View file

@ -0,0 +1,11 @@
### How much an assistant thinks before it answers (Halkyon Learn's ADR 0021,
### D63), from the least to the most: Anthropic's levels, each provider's
### own field sent in their place.
enum: AssistantEffort
serialized: byName
values:
- low
- medium
- high
- xhigh
- max

View file

@ -14,8 +14,8 @@ class AssistantEndpoint extends Endpoint {
Set<Scope> get requiredScopes => {identified};
/// The assistants granted to the caller that are set, with what goes
/// where and what is left of their limits, and whether they may add their
/// own keys; none on a member (D43).
/// where and their effort, the caller's budget and what is used of it,
/// and whether they may add their own keys; none on a member (D43).
Future<AssistantsOffered> offered(Session session) =>
assistantsOffered(session, _caller(session));
@ -28,9 +28,10 @@ class AssistantEndpoint extends Endpoint {
/// Forwards [question] to its assistant and streams the answer: the text
/// as the model writes it, then the whole turn with what was counted.
/// Refused before anything goes without a grant, an agreement or room
/// within the limits, for a question that can't be sent, and with more
/// than `maxQuestionsAtOnce` on their way; then for the provider's
/// failure. Cancelled, the answer is still read to its end and counted.
/// within the budget, for an effort not offered or a question that can't
/// be sent, and with more than `maxQuestionsAtOnce` on their way; then for
/// the provider's failure. Cancelled, the answer is still read to its end
/// and counted.
Stream<AssistantAnswer> ask(
Session session, {
required AssistantQuestion question,

View file

@ -1,6 +1,7 @@
### An assistant an operator granted a person (Halkyon Learn's ADR 0021, D2,
### D4 and point 6), with its limits, and whether they agreed to what goes
### where (D40). Kept by the register alone.
### D4 and point 6), and whether they agreed to what goes where (D40); their
### budget is their own (`AssistantBudget`, D58). Kept by the register
### alone.
class: AssistantGrant
serverOnly: true
table: assistant_grant
@ -10,18 +11,8 @@ fields:
### The assistant's ID, as the operator set it (`ASSISTANTS`). A grant of
### one no longer set stays, unused, until it is set again (D44).
assistant: String
### When it was granted, or its limits last changed.
### When it was granted.
grantedAt: DateTime
### What the limits count: the cost where the assistant had prices when
### granted, else tokens. Should its prices come or go, the grant holds
### no more until the operator sets its limits again.
limitUnit: AssistantLimitUnit
### The most a UTC day may use (D42), in [limitUnit]; null for no limit
### per day.
dailyLimit: int?
### The most a UTC month may use, in [limitUnit]; null for no limit per
### month.
monthlyLimit: int?
### When the person agreed to what goes where; null until they have.
agreedAt: DateTime?
### What they agreed to: the protocol and the provider's host

View file

@ -1,25 +0,0 @@
### A grant as the operator sees it (Halkyon Learn's ADR 0021, point 6).
class: AssistantGrantOverview
fields:
### The person's account.
account: UuidValue
### Their handle; null for an account without one.
handle: String?
### The assistant's ID.
assistant: String
### Whether the instance still sets that assistant (D44).
assistantSet: bool
### When it was granted, or its limits last changed.
grantedAt: DateTime
### What its limits count.
limitUnit: AssistantLimitUnit
### The most a UTC day may use; null for none.
dailyLimit: int?
### The most a UTC month may use; null for none.
monthlyLimit: int?
### When the person agreed to the assistant's provider as it is now;
### null until they have.
agreedAt: DateTime?
### Whether the limits count what the assistant counts no more, as its
### prices came or went.
outdated: bool

View file

@ -0,0 +1,13 @@
### An assistant granted to a person, as the operator sees it (Halkyon
### Learn's ADR 0021, point 6).
class: AssistantGranted
fields:
### The assistant's ID.
assistant: String
### Whether the instance still sets that assistant (D44).
assistantSet: bool
### When it was granted.
grantedAt: DateTime
### When the person agreed to the assistant's provider as it is now;
### null until they have.
agreedAt: DateTime?

View file

@ -0,0 +1,16 @@
### A person granted assistants, as the operator sees them (Halkyon Learn's
### ADR 0021, D58): which, their budget, and what they used of it.
class: AssistantGrantee
fields:
### The person's account.
account: UuidValue
### Their handle; null for an account without one.
handle: String?
### Their assistants, in the operator's order (those set no more last).
assistants: List<AssistantGranted>
### Their budget; every limit null for none.
budget: AssistantLimits
### What this UTC day used so far.
usedToday: AssistantUse
### What this UTC month used so far.
usedThisMonth: AssistantUse

View file

@ -1,32 +1,33 @@
import 'dart:math';
import 'package:clock/clock.dart';
import 'package:halkyon_base_server/src/assistant/assistants_config.dart';
import 'package:halkyon_base_server/src/auth/membership.dart';
import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:halkyon_llm/halkyon_llm.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
// The operator's assistants granted to people, their limits, and what each
// person used (Halkyon Learn's ADR 0021, points 6 and 8, D38 to D44): on the
// register alone, as accounts are; a member offers none (D43). The figures
// are counted per person, assistant and UTC day, never what was asked.
// The operator's assistants granted to people, each person's budget for
// them together, and what each person used (Halkyon Learn's ADR 0021, points
// 6 and 8, D38 to D44, D58 and D59): on the register alone, as accounts
// are; a member offers none (D43). The figures are counted per person,
// assistant and UTC day, never what was asked.
/// How many UTC months of figures are kept (D41): this one and the two
/// before it, so last month's are there all this month.
const assistantFiguresMonths = 3;
/// At most this many grantees found at once ([assistantGrantees]), as
/// accounts are (`maxAccountsFound`).
const maxGranteesFound = 50;
Never _refused(AssistantRefusal refusal, {DateTime? resetsAt}) =>
throw AssistantRefusedException(refusal: refusal, resetsAt: resetsAt);
Never _refusedForOperator(OperatorRefusal reason) =>
throw OperatorRefusedException(reason: reason);
/// What [assistant]'s limits count as it is set now: its cost where it has
/// prices, else tokens.
AssistantLimitUnit limitUnitOf(AssistantSetting assistant) =>
assistant.prices == null
? AssistantLimitUnit.tokens
: AssistantLimitUnit.costMicros;
/// What a person agrees to of [assistant] (D40): its protocol and the
/// provider's host (`anthropic api.anthropic.com`), not its model.
String providerOf(AssistantSetting assistant) =>
@ -64,42 +65,80 @@ Future<AssistantGrant?> _grantOf(
transaction: transaction,
);
/// What [person] used of [assistant] on [now]'s UTC day and in its month,
/// in [unit].
Future<({int today, int thisMonth})> _usedOf(
/// [person]'s budget; null for none, so no limits.
Future<AssistantBudget?> _budgetOf(
Session session,
UuidValue person,
String assistant,
AssistantLimitUnit unit,
UuidValue person, {
Transaction? transaction,
}) =>
// SELECT … FROM "assistant_budget" WHERE "authUserId" = $1
AssistantBudget.db.findFirstRow(
session,
where: (t) => t.authUserId.equals(person),
transaction: transaction,
);
/// [budget]'s limits; every one null for none.
AssistantLimits _limitsOf(AssistantBudget? budget) => AssistantLimits(
dailyCostMicros: budget?.dailyCostMicros,
monthlyCostMicros: budget?.monthlyCostMicros,
dailyTokens: budget?.dailyTokens,
monthlyTokens: budget?.monthlyTokens,
);
/// What one UTC day and month used, of every assistant together.
typedef _Used = ({AssistantUse today, AssistantUse thisMonth});
final _noneUsed = (
today: AssistantUse(costMicros: 0, tokens: 0),
thisMonth: AssistantUse(costMicros: 0, tokens: 0),
);
/// What each of [people] used on [now]'s UTC day and in its month, of all
/// their assistants together, those set no more too: what they cost was
/// spent all the same.
Future<Map<UuidValue, _Used>> _usedBy(
Session session,
Set<UuidValue> people,
DateTime now,
) async {
if (people.isEmpty) {
return const {};
}
final day = utcDayOf(now);
// SELECT … FROM "assistant_figures" WHERE "authUserId" = $1
// AND "assistant" = $2 AND "day" >= $3
// SELECT … FROM "assistant_figures" WHERE "authUserId" = ANY($1)
// AND "day" >= $2
final days = await AssistantFigures.db.find(
session,
where: (t) =>
t.authUserId.equals(person) &
t.assistant.equals(assistant) &
(t.day >= utcMonthOf(now)),
where: (t) => t.authUserId.inSet(people) & (t.day >= utcMonthOf(now)),
);
var today = 0;
var thisMonth = 0;
final sums =
<UuidValue, ({int cost, int tokens, int costToday, int tokensToday})>{};
for (final figures in days) {
final used = switch (unit) {
AssistantLimitUnit.costMicros => figures.costMicros,
AssistantLimitUnit.tokens => figures.inputTokens + figures.outputTokens,
};
thisMonth += used;
if (!figures.day.isBefore(day)) {
today += used;
}
final sum =
sums[figures.authUserId] ??
(cost: 0, tokens: 0, costToday: 0, tokensToday: 0);
final tokens = figures.inputTokens + figures.outputTokens;
final today = !figures.day.isBefore(day);
sums[figures.authUserId] = (
cost: sum.cost + figures.costMicros,
tokens: sum.tokens + tokens,
costToday: sum.costToday + (today ? figures.costMicros : 0),
tokensToday: sum.tokensToday + (today ? tokens : 0),
);
}
return (today: today, thisMonth: thisMonth);
return {
for (final MapEntry(key: person, value: sum) in sums.entries)
person: (
today: AssistantUse(costMicros: sum.costToday, tokens: sum.tokensToday),
thisMonth: AssistantUse(costMicros: sum.cost, tokens: sum.tokens),
),
};
}
/// What [person] is offered of assistants (point 6): those granted to them
/// that are set, in the operator's order, with what is left of each.
/// that are set, in the operator's order, with their budget and what is
/// used of it.
Future<AssistantsOffered> assistantsOffered(
Session session,
UuidValue person,
@ -111,6 +150,9 @@ Future<AssistantsOffered> assistantsOffered(
assistants: const [],
ownKeys: false,
currency: assistants.currency,
budget: _limitsOf(null),
usedToday: _noneUsed.today,
usedThisMonth: _noneUsed.thisMonth,
);
}
// SELECT … FROM "assistant_grant" WHERE "authUserId" = $1
@ -121,47 +163,37 @@ Future<AssistantsOffered> assistantsOffered(
))
grant.assistant: grant,
};
final now = clock.now();
final used =
(await _usedBy(session, {person}, clock.now()))[person] ?? _noneUsed;
return AssistantsOffered(
assistants: [
for (final assistant in assistants.all)
if (grants[assistant.id] case final grant?)
await _offerOf(session, assistant, grant, now),
if (grants[assistant.id] case final grant?) _offerOf(assistant, grant),
],
ownKeys: assistants.ownKeys,
currency: assistants.currency,
budget: _limitsOf(await _budgetOf(session, person)),
usedToday: used.today,
usedThisMonth: used.thisMonth,
);
}
Future<AssistantOffer> _offerOf(
Session session,
AssistantSetting assistant,
AssistantGrant grant,
DateTime now,
) async {
final used = await _usedOf(
session,
grant.authUserId,
assistant.id,
grant.limitUnit,
now,
);
return AssistantOffer(
id: assistant.id,
name: assistant.name,
protocol: assistant.endpoint.protocol.key,
host: assistant.endpoint.host,
model: assistant.endpoint.model,
images: assistant.images,
agreedAt: grant.agreedTo == providerOf(assistant) ? grant.agreedAt : null,
limitUnit: grant.limitUnit,
dailyLimit: grant.dailyLimit,
monthlyLimit: grant.monthlyLimit,
usedToday: used.today,
usedThisMonth: used.thisMonth,
outdated: grant.limitUnit != limitUnitOf(assistant),
);
}
AssistantOffer _offerOf(AssistantSetting assistant, AssistantGrant grant) =>
AssistantOffer(
id: assistant.id,
name: assistant.name,
protocol: assistant.endpoint.protocol.key,
host: assistant.endpoint.host,
model: assistant.endpoint.model,
images: assistant.images,
agreedAt: grant.agreedTo == providerOf(assistant) ? grant.agreedAt : null,
effort: _effortOf(assistant.effort),
efforts: [for (final effort in assistant.efforts) _effortOf(effort)!],
);
/// [effort] as the protocol names it.
AssistantEffort? _effortOf(LlmEffort? effort) =>
effort == null ? null : AssistantEffort.fromJson(effort.key);
/// [person] agrees to what goes where with [assistant] (D40), its provider
/// as it is now. Refused ([AssistantRefusal.notGranted]) without a grant of
@ -187,10 +219,11 @@ Future<void> agreeToAssistant(
/// The assistant [assistant] if [person] may ask it now: granted and set
/// (else [AssistantRefusal.notGranted]), agreed to as its provider is now
/// ([AssistantRefusal.notAgreed]), its limits counting what it counts
/// ([AssistantRefusal.limitOutdated]), and neither this UTC month's limit
/// nor this day's reached ([AssistantRefusal.limitReached], with when the
/// first to lift resets). Checked against what was counted before (D38).
/// ([AssistantRefusal.notAgreed]), and neither this UTC month's limit of
/// their budget nor this day's reached ([AssistantRefusal.limitReached],
/// with when the first to lift resets): the money's where [assistant] has
/// prices, the tokens' always (D59), all their assistants counted together
/// (D58). Checked against what was counted before (D38).
Future<AssistantSetting> admittedAssistant(
Session session,
UuidValue person,
@ -205,24 +238,27 @@ Future<AssistantSetting> admittedAssistant(
if (grant.agreedAt == null || grant.agreedTo != providerOf(setting)) {
_refused(AssistantRefusal.notAgreed);
}
if (grant.limitUnit != limitUnitOf(setting)) {
_refused(AssistantRefusal.limitOutdated);
}
if (grant.dailyLimit == null && grant.monthlyLimit == null) {
final budget = await _budgetOf(session, person);
if (budget == null) {
return setting;
}
final now = clock.now();
final used = await _usedOf(session, person, assistant, grant.limitUnit, now);
final used = (await _usedBy(session, {person}, now))[person] ?? _noneUsed;
final priced = setting.prices != null;
bool reached(int? limit, int used) => limit != null && used >= limit;
// The month's first: a day's reset lifts nothing while the month's limit
// holds.
if (grant.monthlyLimit case final limit? when used.thisMonth >= limit) {
if ((priced &&
reached(budget.monthlyCostMicros, used.thisMonth.costMicros)) ||
reached(budget.monthlyTokens, used.thisMonth.tokens)) {
final month = utcMonthOf(now);
_refused(
AssistantRefusal.limitReached,
resetsAt: DateTime.utc(month.year, month.month + 1),
);
}
if (grant.dailyLimit case final limit? when used.today >= limit) {
if ((priced && reached(budget.dailyCostMicros, used.today.costMicros)) ||
reached(budget.dailyTokens, used.today.tokens)) {
_refused(
AssistantRefusal.limitReached,
resetsAt: utcDayOf(now).add(const Duration(days: 1)),
@ -268,9 +304,16 @@ Future<void> countAssistantRequest(
}),
);
/// The assistants the operator set, without their keys, to grant from.
AssistantsSetUp assistantsSetUp(Serverpod pod) {
final assistants = assistantsOf(pod);
/// The people with an assistant granted.
Future<Set<UuidValue>> _granted(Session session) async => {
// SELECT … FROM "assistant_grant"
for (final grant in await AssistantGrant.db.find(session)) grant.authUserId,
};
/// The assistants the operator set, without their keys, to grant from, and
/// how many people have any.
Future<AssistantsSetUp> assistantsSetUp(Session session) async {
final assistants = assistantsOf(session.serverpod);
return AssistantsSetUp(
assistants: [
for (final assistant in assistants.all)
@ -283,133 +326,304 @@ AssistantsSetUp assistantsSetUp(Serverpod pod) {
images: assistant.images,
priceInput: assistant.prices?.input,
priceOutput: assistant.prices?.output,
effort: _effortOf(assistant.effort),
maxEffort: _effortOf(assistant.maxEffort),
),
],
currency: assistants.currency,
ownKeys: assistants.ownKeys,
people: (await _granted(session)).length,
);
}
/// Grants [account] [assistant] with [dailyLimit] and [monthlyLimit] (null:
/// none), counted in the unit of its pricing now; granted already, the
/// limits change and its agreement stays. Refused for an assistant not set
/// ([OperatorRefusal.assistantNotSet], D44), a limit below 0
/// ([OperatorRefusal.invalidLimit]) and an account unknown
/// ([OperatorRefusal.notFound]).
Future<void> grantAssistant(
Session session, {
required UuidValue account,
required String assistant,
required int? dailyLimit,
required int? monthlyLimit,
}) async {
final setting =
_offered(session.serverpod, assistant) ??
/// [assistants] checked as set ([OperatorRefusal.assistantNotSet], D44),
/// and [budget]'s limits as 0 or more ([OperatorRefusal.invalidLimit]).
Set<String> _checked(
Session session,
List<String> assistants,
AssistantLimits budget,
) {
for (final assistant in assistants) {
if (_offered(session.serverpod, assistant) == null) {
_refusedForOperator(OperatorRefusal.assistantNotSet);
if ((dailyLimit ?? 0) < 0 || (monthlyLimit ?? 0) < 0) {
}
}
final limits = [
budget.dailyCostMicros,
budget.monthlyCostMicros,
budget.dailyTokens,
budget.monthlyTokens,
];
if (limits.any((limit) => (limit ?? 0) < 0)) {
_refusedForOperator(OperatorRefusal.invalidLimit);
}
await session.db.transaction((transaction) async {
// SELECT … FROM "serverpod_auth_core_user" WHERE "id" = $1
if (await AuthUser.db.findById(
session,
account,
transaction: transaction,
) ==
null) {
_refusedForOperator(OperatorRefusal.notFound);
}
final grant = AssistantGrant(
authUserId: account,
assistant: assistant,
grantedAt: clock.now(),
limitUnit: limitUnitOf(setting),
dailyLimit: dailyLimit,
monthlyLimit: monthlyLimit,
);
switch (await _grantOf(
session,
account,
assistant,
transaction: transaction,
)) {
case final granted?:
// UPDATE "assistant_grant" SET … WHERE "id" = $1
await AssistantGrant.db.updateRow(
session,
grant.copyWith(
id: granted.id,
agreedAt: granted.agreedAt,
agreedTo: granted.agreedTo,
),
transaction: transaction,
);
case null:
// INSERT INTO "assistant_grant" (…) VALUES (…)
await AssistantGrant.db.insertRow(
session,
grant,
transaction: transaction,
);
}
});
return assistants.toSet();
}
/// Takes [assistant] back from [account]; their figures stay until their
/// time. Refused ([OperatorRefusal.notFound]) where it isn't granted.
Future<void> revokeAssistant(
Session session, {
required UuidValue account,
required String assistant,
}) async {
// DELETE FROM "assistant_grant" WHERE "authUserId" = $1 AND "assistant" = $2
final revoked = await AssistantGrant.db.deleteWhere(
/// Refused ([OperatorRefusal.notFound]) unless every one of [accounts] is
/// known.
Future<void> _knownOrRefused(
Session session,
Set<UuidValue> accounts,
Transaction transaction,
) async {
// SELECT … FROM "serverpod_auth_core_user" WHERE "id" = ANY($1)
final known = await AuthUser.db.count(
session,
where: (t) => t.authUserId.equals(account) & t.assistant.equals(assistant),
where: (t) => t.id.inSet(accounts),
transaction: transaction,
);
if (revoked.isEmpty) {
if (known != accounts.length) {
_refusedForOperator(OperatorRefusal.notFound);
}
}
/// Every grant, by handle, then in the operator's order of assistants
/// (those set no more last).
Future<List<AssistantGrantOverview>> assistantGrants(Session session) async {
final assistants = assistantsOf(session.serverpod);
// SELECT … FROM "assistant_grant"
final grants = await AssistantGrant.db.find(session);
final handles = await _handlesOf(session, {
for (final grant in grants) grant.authUserId,
});
final overviews = [
for (final grant in grants)
AssistantGrantOverview(
account: grant.authUserId,
handle: handles[grant.authUserId],
assistant: grant.assistant,
assistantSet: assistants.named(grant.assistant) != null,
grantedAt: grant.grantedAt,
limitUnit: grant.limitUnit,
dailyLimit: grant.dailyLimit,
monthlyLimit: grant.monthlyLimit,
agreedAt: switch (assistants.named(grant.assistant)) {
final setting? when grant.agreedTo == providerOf(setting) =>
grant.agreedAt,
_ => null,
},
outdated: switch (assistants.named(grant.assistant)) {
final setting? => grant.limitUnit != limitUnitOf(setting),
null => false,
},
),
/// Grants [person] each of [assistants] they don't have yet, the others'
/// agreements kept.
Future<void> _addGrants(
Session session,
UuidValue person,
Set<String> assistants,
Transaction transaction,
) async {
// SELECT … FROM "assistant_grant" WHERE "authUserId" = $1
final granted = {
for (final grant in await AssistantGrant.db.find(
session,
where: (t) => t.authUserId.equals(person),
transaction: transaction,
))
grant.assistant,
};
final now = clock.now();
final added = [
for (final assistant in assistants.difference(granted))
AssistantGrant(authUserId: person, assistant: assistant, grantedAt: now),
];
return overviews..sort(
(a, b) => _byPersonAndAssistant(
assistants,
(a.handle, a.account, a.assistant),
(b.handle, b.account, b.assistant),
),
if (added.isNotEmpty) {
// INSERT INTO "assistant_grant" (…) VALUES (…), …
await AssistantGrant.db.insert(session, added, transaction: transaction);
}
}
/// Sets [person]'s budget to [limits]; with none, they have none.
Future<void> _setBudget(
Session session,
UuidValue person,
AssistantLimits limits,
Transaction transaction,
) async {
final budget = AssistantBudget(
authUserId: person,
dailyCostMicros: limits.dailyCostMicros,
monthlyCostMicros: limits.monthlyCostMicros,
dailyTokens: limits.dailyTokens,
monthlyTokens: limits.monthlyTokens,
);
final none = [
budget.dailyCostMicros,
budget.monthlyCostMicros,
budget.dailyTokens,
budget.monthlyTokens,
].every((limit) => limit == null);
switch ((
await _budgetOf(session, person, transaction: transaction),
none,
)) {
case (final set?, true):
// DELETE FROM "assistant_budget" WHERE "id" = $1
await AssistantBudget.db.deleteRow(
session,
set,
transaction: transaction,
);
case (final set?, false):
// UPDATE "assistant_budget" SET … WHERE "id" = $1
await AssistantBudget.db.updateRow(
session,
budget.copyWith(id: set.id),
transaction: transaction,
);
case (null, false):
// INSERT INTO "assistant_budget" (…) VALUES (…)
await AssistantBudget.db.insertRow(
session,
budget,
transaction: transaction,
);
case (null, true):
break;
}
}
/// Grants each of [accounts] each of [assistants] they don't have yet, the
/// agreements of those they have kept, and sets each one's budget to
/// [budget] (D58), all or nothing. Refused for an assistant not set
/// ([OperatorRefusal.assistantNotSet], D44), a limit below 0
/// ([OperatorRefusal.invalidLimit]) and an account unknown
/// ([OperatorRefusal.notFound]).
Future<void> grantAssistants(
Session session, {
required List<UuidValue> accounts,
required List<String> assistants,
required AssistantLimits budget,
}) async {
final granted = _checked(session, assistants, budget);
final people = accounts.toSet();
await session.db.transaction((transaction) async {
await _knownOrRefused(session, people, transaction);
for (final person in people) {
await _addGrants(session, person, granted, transaction);
await _setBudget(session, person, budget, transaction);
}
});
}
/// Gives [account] exactly [assistants], taking back the others, the
/// agreements of those kept kept, and [budget]; with no assistants, the
/// budget goes too. Refused as [grantAssistants] is.
Future<void> changeAssistants(
Session session, {
required UuidValue account,
required List<String> assistants,
required AssistantLimits budget,
}) async {
final kept = _checked(session, assistants, budget);
await session.db.transaction((transaction) async {
await _knownOrRefused(session, {account}, transaction);
// DELETE FROM "assistant_grant" WHERE "authUserId" = $1
// AND NOT ("assistant" = ANY($2))
await AssistantGrant.db.deleteWhere(
session,
where: (t) => kept.isEmpty
? t.authUserId.equals(account)
: t.authUserId.equals(account) & t.assistant.notInSet(kept),
transaction: transaction,
);
await _addGrants(session, account, kept, transaction);
await _setBudget(
session,
account,
kept.isEmpty ? _limitsOf(null) : budget,
transaction,
);
});
}
/// Takes every assistant back from [account], and their budget; their
/// figures stay until their time. Refused ([OperatorRefusal.notFound]) for
/// one with neither.
Future<void> revokeAssistants(Session session, UuidValue account) async {
final revoked = await session.db.transaction((transaction) async {
// DELETE FROM "assistant_grant" WHERE "authUserId" = $1
final grants = await AssistantGrant.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(account),
transaction: transaction,
);
// DELETE FROM "assistant_budget" WHERE "authUserId" = $1
final budgets = await AssistantBudget.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(account),
transaction: transaction,
);
return grants.length + budgets.length;
});
if (revoked == 0) {
_refusedForOperator(OperatorRefusal.notFound);
}
}
/// The people with an assistant granted whose handle starts with [start],
/// whatever its case, by handle, [maxGranteesFound] from [offset] on:
/// those without a handle aren't found.
Future<List<AssistantGrantee>> assistantGrantees(
Session session, {
required String start,
required int offset,
}) async {
final key = start.trim().toLowerCase();
final granted = await _granted(session);
if (!RegExp(r'^[a-z0-9_]*$').hasMatch(key) || granted.isEmpty) {
return const [];
}
// SELECT "authUserId" FROM "public_profile" WHERE "handleKey" LIKE $1
// AND "authUserId" = ANY($2) ORDER BY "handleKey" LIMIT 50 OFFSET $3
final profiles = await PublicProfile.db.find(
session,
where: (t) =>
t.handleKey.like('${key.replaceAll('_', r'\_')}%') &
t.authUserId.inSet(granted),
orderBy: (t) => t.handleKey,
limit: maxGranteesFound,
offset: max(offset, 0),
);
return await _granteesOf(session, [
for (final profile in profiles) profile.authUserId,
]);
}
/// Those of [accounts] with an assistant granted or a budget, in their
/// order.
Future<List<AssistantGrantee>> assistantGranteesOf(
Session session,
List<UuidValue> accounts,
) => _granteesOf(session, accounts.toSet().toList());
Future<List<AssistantGrantee>> _granteesOf(
Session session,
List<UuidValue> accounts,
) async {
if (accounts.isEmpty) {
return const [];
}
final people = accounts.toSet();
final assistants = assistantsOf(session.serverpod);
// SELECT … FROM "assistant_grant" WHERE "authUserId" = ANY($1)
final grants = await AssistantGrant.db.find(
session,
where: (t) => t.authUserId.inSet(people),
);
// SELECT … FROM "assistant_budget" WHERE "authUserId" = ANY($1)
final budgets = {
for (final budget in await AssistantBudget.db.find(
session,
where: (t) => t.authUserId.inSet(people),
))
budget.authUserId: budget,
};
final handles = await _handlesOf(session, people);
final used = await _usedBy(session, people, clock.now());
return [
for (final person in accounts)
if (grants.any((grant) => grant.authUserId == person) ||
budgets.containsKey(person))
AssistantGrantee(
account: person,
handle: handles[person],
assistants:
[
for (final grant in grants)
if (grant.authUserId == person)
AssistantGranted(
assistant: grant.assistant,
assistantSet: assistants.named(grant.assistant) != null,
grantedAt: grant.grantedAt,
agreedAt: switch (assistants.named(grant.assistant)) {
final setting?
when grant.agreedTo == providerOf(setting) =>
grant.agreedAt,
_ => null,
},
),
]..sort(
(a, b) => _assistantOrder(assistants, a.assistant, b.assistant),
),
budget: _limitsOf(budgets[person]),
usedToday: (used[person] ?? _noneUsed).today,
usedThisMonth: (used[person] ?? _noneUsed).thisMonth,
),
];
}
/// Everyone's figures of [month]'s UTC month, per person and assistant, by
@ -549,7 +763,7 @@ Future<int> forgetOldAssistantFigures(
return forgotten.length;
}
/// Deletes [person]'s grants and figures, with the account
/// Deletes [person]'s grants, budget and figures, with the account
/// (`deletePerson`).
Future<void> deleteAssistantDataOf(
Session session,
@ -562,6 +776,12 @@ Future<void> deleteAssistantDataOf(
where: (t) => t.authUserId.equals(person),
transaction: transaction,
);
// DELETE FROM "assistant_budget" WHERE "authUserId" = $1
await AssistantBudget.db.deleteWhere(
session,
where: (t) => t.authUserId.equals(person),
transaction: transaction,
);
// DELETE FROM "assistant_figures" WHERE "authUserId" = $1
await AssistantFigures.db.deleteWhere(
session,

View file

@ -14,8 +14,13 @@ fields:
model: String
### Whether it reads images.
images: bool
### Its price per million input tokens; null where it has none, so its
### limits count tokens.
### Its price per million input tokens; null where it has none, so it
### counts against budgets' tokens alone.
priceInput: double?
### Its price per million output tokens.
priceOutput: double?
### The effort a question takes unless it names one (D63); null: the
### model's own.
effort: AssistantEffort?
### The highest effort a question may name; null: none.
maxEffort: AssistantEffort?

View file

@ -1,9 +0,0 @@
### What a grant's limits count (Halkyon Learn's ADR 0021, point 8).
enum: AssistantLimitUnit
serialized: byName
values:
### The cost, in millionths of the instance's currency, at the prices
### the operator set.
- costMicros
### Input and output tokens together, where the operator set no prices.
- tokens

View file

@ -0,0 +1,14 @@
### A person's budget for all their assistants together (Halkyon Learn's ADR
### 0021, D58 and D59): a limit per UTC day and per UTC month, in money and
### in tokens, each null for none. Whichever runs out first stops them: the
### money the assistants with prices, the tokens every assistant.
class: AssistantLimits
fields:
### The most a UTC day may cost, in millionths of the instance's currency.
dailyCostMicros: int?
### The most a UTC month may cost, in millionths of the currency.
monthlyCostMicros: int?
### The most tokens a UTC day may use, input and output together.
dailyTokens: int?
### The most tokens a UTC month may use.
monthlyTokens: int?

View file

@ -1,5 +1,5 @@
### An assistant granted to the person who asks (Halkyon Learn's ADR 0021,
### point 6 and D27): what it is, what goes where, and how much is left.
### point 6 and D27): what it is, what goes where, and how much it thinks.
class: AssistantOffer
fields:
### Its ID, which a question names.
@ -17,16 +17,8 @@ fields:
### When the person agreed; null until they have, or since it moved to
### another provider: then they are asked first (D40).
agreedAt: DateTime?
### What its limits count.
limitUnit: AssistantLimitUnit
### The most a UTC day may use; null for none.
dailyLimit: int?
### The most a UTC month may use; null for none.
monthlyLimit: int?
### What this UTC day used so far, in [limitUnit].
usedToday: int
### What this UTC month used so far, in [limitUnit].
usedThisMonth: int
### Whether the grant holds no more until the operator sets its limits
### again, as the assistant's prices came or went.
outdated: bool
### The effort a question takes unless it names one (D63); null: the
### model's own.
effort: AssistantEffort?
### The efforts a question may name, from the least; empty: none.
efforts: List<AssistantEffort>

View file

@ -4,49 +4,71 @@ import 'package:halkyon_base_server/src/generated/protocol.dart';
import 'package:serverpod/serverpod.dart';
/// The operator's assistants as the operator grants them (Halkyon Learn's
/// ADR 0021, points 6 and 8): the assistants set, the grants with their
/// limits, and everyone's figures, never what was asked. The operator's
/// scope only the register's sessions carry. Refusals are
/// `OperatorRefusedException`s.
/// ADR 0021, points 6 and 8, D58): the assistants set, who has which with
/// their budget, and everyone's figures, never what was asked. The
/// operator's scope only the register's sessions carry. Refusals are
/// `OperatorRefusedException`s; nothing changes then.
class AssistantOperatorEndpoint extends Endpoint {
@override
Set<Scope> get requiredScopes => {identified, instanceOperator};
/// The assistants set in `deploy/assistants.env`, without their keys.
Future<AssistantsSetUp> setUp(Session session) =>
Future.value(assistantsSetUp(session.serverpod));
/// The assistants set in `deploy/assistants.env`, without their keys, and
/// how many people have any.
Future<AssistantsSetUp> setUp(Session session) => assistantsSetUp(session);
/// Every grant, by handle.
Future<List<AssistantGrantOverview>> grants(Session session) =>
assistantGrants(session);
/// The people with an assistant granted whose handle starts with
/// [startingWith], whatever its case, by handle, 50 from [offset] on,
/// each with their assistants, budget and what they used.
Future<List<AssistantGrantee>> grantees(
Session session, {
required String startingWith,
required int offset,
}) => assistantGrantees(session, start: startingWith, offset: offset);
/// Grants [account] [assistant] with [dailyLimit] and [monthlyLimit] (null:
/// none), in the unit of its pricing; granted already, the limits change
/// and the agreement stays. Refused for an assistant not set
/// Those of [accounts] with an assistant or a budget, as [grantees] has
/// them.
Future<List<AssistantGrantee>> granteesOf(
Session session, {
required List<UuidValue> accounts,
}) => assistantGranteesOf(session, accounts);
/// Grants each of [accounts] each of [assistants] they don't have yet,
/// keeping the agreements of those they have, and sets each one's budget
/// to [budget]. Refused for an assistant not set
/// (`OperatorRefusal.assistantNotSet`), a limit below 0
/// (`OperatorRefusal.invalidLimit`) and an account unknown
/// (`OperatorRefusal.notFound`).
Future<void> grant(
Session session, {
required UuidValue account,
required String assistant,
required int? dailyLimit,
required int? monthlyLimit,
}) => grantAssistant(
required List<UuidValue> accounts,
required List<String> assistants,
required AssistantLimits budget,
}) => grantAssistants(
session,
account: account,
assistant: assistant,
dailyLimit: dailyLimit,
monthlyLimit: monthlyLimit,
accounts: accounts,
assistants: assistants,
budget: budget,
);
/// Takes [assistant] back from [account]; refused where it isn't granted
/// (`OperatorRefusal.notFound`).
Future<void> revoke(
/// Gives [account] exactly [assistants] and [budget], taking back the
/// assistants left out; with none, the budget goes too. Refused as
/// [grant] is.
Future<void> change(
Session session, {
required UuidValue account,
required String assistant,
}) => revokeAssistant(session, account: account, assistant: assistant);
required List<String> assistants,
required AssistantLimits budget,
}) => changeAssistants(
session,
account: account,
assistants: assistants,
budget: budget,
);
/// Takes every assistant and the budget back from [account]; refused for
/// one with neither (`OperatorRefusal.notFound`).
Future<void> revoke(Session session, {required UuidValue account}) =>
revokeAssistants(session, account);
/// Everyone's figures of the UTC month [month] is in, per person and
/// assistant.

View file

@ -10,6 +10,9 @@ fields:
messages: List<AssistantMessage>
### The tools it may call.
tools: List<AssistantTool>
### The ceiling on the answer's tokens, the instance's own ceiling at
### most.
### The ceiling on the answer's tokens, the effort's at most
### (`LlmEffort.outputCeiling`).
maxOutputTokens: int
### How much the assistant thinks first, among those it offers
### (`AssistantOffer.efforts`, D63); null: the operator's default for it.
effort: AssistantEffort?

View file

@ -8,12 +8,12 @@ values:
### The person hasn't agreed to what goes where, or it moved to another
### provider since (D40).
- notAgreed
### A day's or a month's limit is reached;
### A day's or a month's limit of the person's budget is reached (D58);
### [AssistantRefusedException.resetsAt] says when it resets.
- limitReached
### The grant's limits count what the assistant counts no more, as its
### prices came or went: the operator sets them again.
- limitOutdated
### The question names an effort the assistant doesn't offer: above the
### operator's highest, or where it has none to choose (D63).
- invalidEffort
### The question holds an image, and the assistant reads none.
- imagesNotRead
### The question can't be sent: a turn out of place, or a tool call's

View file

@ -0,0 +1,9 @@
### What a person used of all their assistants together in a UTC day or
### month (Halkyon Learn's ADR 0021, D58), as their budget counts it.
class: AssistantUse
fields:
### What it cost, in millionths of the instance's currency; nothing for
### the assistants without prices.
costMicros: int
### The tokens, input and output together, of every assistant.
tokens: int

View file

@ -9,11 +9,6 @@ import 'package:serverpod/serverpod.dart';
/// shorter, it could be guessed, and every person's ID with it.
const assistantIdKeyMinLength = 32;
/// The most output tokens one request may ask for: an answer, with the
/// model's thinking, which counts towards it. It also bounds what the last
/// request before a limit may cost (Halkyon Learn's ADR 0021, D38).
const assistantOutputCeiling = 16384;
/// The assistants an instance's operator sets in `deploy/assistants.env`
/// (Halkyon Learn's ADR 0021, point 4 and D37), whom they grant to people.
final class Assistants {
@ -78,6 +73,8 @@ final class AssistantSetting {
required this.endpoint,
required this.images,
required this.prices,
required this.effort,
required this.maxEffort,
});
/// Its ID (`claude`), in lower case: in `ASSISTANTS`, and in upper case
@ -93,9 +90,33 @@ final class AssistantSetting {
/// Whether it reads images (D12).
final bool images;
/// Its prices; null where the operator set none, so its figures and
/// limits are in tokens.
/// Its prices; null where the operator set none, so it counts against
/// budgets' tokens alone (D59).
final AssistantPrices? prices;
/// The effort a question takes unless it names one (`_EFFORT`, D63);
/// null: the model's own.
final LlmEffort? effort;
/// The highest effort a question may name (`_MAX_EFFORT`, else
/// [effort]); null: none.
final LlmEffort? maxEffort;
/// The efforts a question may name, from the least to [maxEffort], each
/// one its provider takes; empty: none.
List<LlmEffort> get efforts => [
if (maxEffort case final highest?)
for (final effort in LlmEffort.values)
if (effort.index <= highest.index && endpoint.efforts.contains(effort))
effort,
];
/// The ceiling on output tokens of a question at [asked], or at [effort]
/// where it names none: the effort's, as its thinking counts towards it,
/// which also bounds what the last request within a budget may cost
/// (D38).
int outputCeilingAt(LlmEffort? asked) =>
(asked ?? effort)?.outputCeiling ?? LlmEffort.defaultOutputCeiling;
}
/// What a model costs, per million tokens, in [Assistants.currency] (D38).
@ -138,8 +159,8 @@ final _assistantsOf = Expando<({Assistants? assistants})>('Assistants');
/// The assistants [variable] (the environment, by name) sets, empty
/// counting as unset, as Compose passes an unset variable on: `ASSISTANTS`
/// lists their IDs, by commas or spaces, and each has `ASSISTANT_<ID>_NAME`,
/// `_PROTOCOL`, `_BASE_URL`, `_MODEL`, `_KEY`, `_IMAGES`, `_PRICE_INPUT` and
/// `_PRICE_OUTPUT`; then `ASSISTANT_CURRENCY`, `ASSISTANT_OWN_KEYS` and
/// `_PROTOCOL`, `_BASE_URL`, `_MODEL`, `_KEY`, `_IMAGES`, `_PRICE_INPUT`,
/// `_PRICE_OUTPUT`, `_EFFORT` and `_MAX_EFFORT`; then `ASSISTANT_CURRENCY`, `ASSISTANT_OWN_KEYS` and
/// `ASSISTANT_ID_KEY`. Throws a [StateError] naming the variable for
/// anything it can't use, so the server stops rather than fail later.
Assistants assistantsFrom(String? Function(String name) variable) {
@ -224,6 +245,20 @@ AssistantSetting _assistantFrom(String id, String? Function(String) read) {
'unset: an assistant has both prices or none.',
);
}
final effort = _effort(read, '${prefix}EFFORT', endpoint);
final maxEffort = _effort(read, '${prefix}MAX_EFFORT', endpoint);
if (maxEffort != null && effort == null) {
throw StateError(
'${prefix}EFFORT is unset: with ${prefix}MAX_EFFORT, it says the effort '
'a question takes unless the person picks one.',
);
}
if (maxEffort != null && maxEffort.index < effort!.index) {
throw StateError(
'${prefix}MAX_EFFORT is "${maxEffort.key}": the highest effort, it is '
'${prefix}EFFORT ("${effort.key}") or above.',
);
}
return AssistantSetting._(
id: id,
name: read('${prefix}NAME') ?? id,
@ -235,9 +270,38 @@ AssistantSetting _assistantFrom(String id, String? Function(String) read) {
_price(input, '${prefix}PRICE_INPUT'),
_price(output!, '${prefix}PRICE_OUTPUT'),
),
effort: effort,
maxEffort: maxEffort ?? effort,
);
}
/// The effort [name] sets, whatever its case, one [endpoint] takes.
LlmEffort? _effort(
String? Function(String) read,
String name,
LlmEndpoint endpoint,
) {
final text = read(name);
if (text == null) {
return null;
}
final effort =
LlmEffort.named(text.toLowerCase()) ??
(throw StateError(
'$name is "$text": it is '
'${LlmEffort.values.map((effort) => effort.key).join(', ')}.',
));
if (!endpoint.efforts.contains(effort)) {
throw StateError(
endpoint.efforts.isEmpty
? '$name is "$text": ${endpoint.host} takes no effort.'
: '$name is "$text": ${endpoint.host} takes '
'${endpoint.efforts.map((effort) => effort.key).join(', ')}.',
);
}
return effort;
}
double _price(String text, String name) {
final price = RegExp(r'^\d+(\.\d+)?$').hasMatch(text)
? double.parse(text)

View file

@ -1,6 +1,6 @@
### What an instance offers the person who asks, of assistants (Halkyon
### Learn's ADR 0021): those granted to them, and whether they may add their
### own keys.
### Learn's ADR 0021): those granted to them, their budget and what is used
### of it, and whether they may add their own keys.
class: AssistantsOffered
fields:
### The assistants granted to them that are set, in the operator's order.
@ -9,3 +9,10 @@ fields:
ownKeys: bool
### The currency costs are in (ISO 4217).
currency: String
### Their budget for all of them together (D58); every limit null for
### none.
budget: AssistantLimits
### What this UTC day used so far.
usedToday: AssistantUse
### What this UTC month used so far.
usedThisMonth: AssistantUse

View file

@ -8,3 +8,5 @@ fields:
currency: String
### Whether people may add their own keys.
ownKeys: bool
### How many people have an assistant granted.
people: int

Some files were not shown because too many files have changed in this diff Show more