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:
parent
0590a4b43d
commit
24ed63585e
53 changed files with 50672 additions and 1371 deletions
|
|
@ -23,10 +23,10 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
|
|||
| `emailCode` | `sendCode`: a code by mail, which proves the address and signs nobody in | anyone |
|
||||
| `account` | `delete` (with the password's proof where the account has one, `keysProof` for one that signs in with SimpleLogin), `signInWays`, `operating`: how many accounts wait, for an operator (Halkyon Learn's ADR 0021, D34; null for anyone else) | signed in |
|
||||
| `waiting` | with a waiting account's pass (Halkyon Learn's ADR 0021, D31): `look` (waiting, suspended, approved, which takes the pass, or unknown), `deleteAccount` (a new account never approved), `forget` | without a session |
|
||||
| `instanceOperator` | `waiting`, `approve`, `refuse` (a new account, deleted), `accounts` (by the start of a handle, at most 50), `suspend` (every session ending, the account waiting anew; never an operator), `operators`, `appoint` (by handle, approving one that waits), `takeBack` (never the last operator) | `identified` and `operator`, on the register |
|
||||
| `instanceOperator` | `counts` (who waits, operates, has an account: D62), `waiting` (50 from an offset), `approve`, `refuse` (a new account, deleted), `accounts` (by the start of a handle, 50 from an offset), `suspend` (every session ending, the account waiting anew; never an operator), `operators`, `appoint` (by handle, approving one that waits), `takeBack` (never the last operator) | `identified` and `operator`, on the register |
|
||||
| `instance` | `basics`: the app it serves, whether codes by mail, password sign-in and SimpleLogin are on, who may sign up (`SIGN_UP`), the source link, the oldest app build, where data and backups are, whether people may add assistants with their own keys | anyone |
|
||||
| `assistant` | Halkyon Learn's ADR 0021, step 2: `offered`, the assistants granted to the caller that are set, with what goes where, what is left of their limits and whether they agreed (none on a member, D43); `agree(assistant)`, to what goes where, its provider as it is now (D40); `ask(question)`, a stream: the answer's text as the model writes it, then the whole turn with why it stopped and its tokens, once counted (D35, D38); `figures`, the caller's own, month by month | `identified` |
|
||||
| `assistantOperator` | `setUp`: the assistants set, without their keys; `grants`, by handle; `grant(account, assistant, dailyLimit, monthlyLimit)`, in money with prices, else tokens, the agreement kept when the limits change; `revoke`; `figures(month)`: everyone's of a UTC month, per person and assistant | `identified` and `operator`, on the register |
|
||||
| `assistant` | Halkyon Learn's ADR 0021, step 2: `offered`, the assistants granted to the caller that are set, with what goes where, their effort and whether they agreed, and the caller's budget with what is used of it (none on a member, D43); `agree(assistant)`, to what goes where, its provider as it is now (D40); `ask(question)`, at an effort offered or the operator's default (D63), a stream: the answer's text as the model writes it, then the whole turn with why it stopped and its tokens, once counted (D35, D38); `figures`, the caller's own, month by month | `identified` |
|
||||
| `assistantOperator` | `setUp`: the assistants set, without their keys, with their effort, and how many people have any; `grantees` (by the start of a handle, 50 from an offset) and `granteesOf(accounts)`: each person's assistants, budget and what they used; `grant(accounts, assistants, budget)`: each person the assistants they lack, the agreements of the others kept, and the budget (D58); `change(account, assistants, budget)`: exactly those; `revoke(account)`: every assistant and the budget; `figures(month)`: everyone's of a UTC month, per person and assistant | `identified` and `operator`, on the register |
|
||||
| `profile` | `mine`, `save`, `checkHandle` (with every answer about a new handle, at most 30 an hour per person and 60 per network), `checkLinks` (at most 10 an hour), `setAvatar`, `removeAvatar`, `remove` (what it shows goes, the handle stays, private again: Halkyon Learn's ADR 0020, D27): the caller's own public profile | `identified` |
|
||||
| `profiles` | `byHandle`: anyone's public profile once they went public, whatever the handle's case | signed in |
|
||||
|
||||
|
|
@ -128,14 +128,19 @@ Endpoints, under the nickname `base` in a host's client (`client.modules.base.em
|
|||
- **Assistants** (Halkyon Learn's ADR 0021, points 4 to 8, D27 to D46): the operator's
|
||||
language models, set in `deploy/assistants.env` (`assistantsFrom`, read as the server
|
||||
starts, anything it can't use stopping it), each through halkyon_llm with the
|
||||
operator's key; nobody has one until an operator grants it, with limits per UTC day
|
||||
and month (`assistant_grant`). `ask` checks, in order: at most `maxQuestionsAtOnce`
|
||||
(2) on their way per person (`tooManyAtOnce`), the grant (`notGranted`), the
|
||||
agreement (`notAgreed`), limits set in the unit the assistant's pricing has now
|
||||
(`limitOutdated`), the month's limit, the day's (`limitReached`, with when it
|
||||
resets), then the question itself (`requestOf`: `malformed`, `imagesNotRead`,
|
||||
`tooLarge` past `assistantQuestionMaxCharacters`, `assistantImageMaxBytes` or
|
||||
`assistantQuestionMaxImages`); the output is capped at `assistantOutputCeiling`. Each
|
||||
operator's key; nobody has one until an operator grants it (`assistant_grant`), and
|
||||
each person has one budget for all their assistants together (`assistant_budget`,
|
||||
D58, D59): a limit per UTC day and month in money and in tokens, each optional,
|
||||
money counting the assistants with prices, tokens every one. `ask` checks, in order:
|
||||
at most `maxQuestionsAtOnce` (2) on their way per person (`tooManyAtOnce`), the grant
|
||||
(`notGranted`), the agreement (`notAgreed`), the budget's month, then its day
|
||||
(`limitReached`, with when it resets), then the question itself (`requestOf`:
|
||||
`invalidEffort` for an effort above the operator's highest or where there is none to
|
||||
choose, `malformed`, `imagesNotRead`, `tooLarge` past
|
||||
`assistantQuestionMaxCharacters`, `assistantImageMaxBytes` or
|
||||
`assistantQuestionMaxImages`). The effort is the question's or the operator's default
|
||||
(`_EFFORT`, `_MAX_EFFORT`, D63), and the output is capped at that effort's ceiling
|
||||
(halkyon_llm's `LlmEffort.outputCeiling`, 16,384 without one). Each
|
||||
request carries the person's opaque ID (HMAC-SHA256 under `ASSISTANT_ID_KEY`,
|
||||
`personIdOf`, D30), never their account. Once sent, the provider's reply is read to
|
||||
its end whatever the app does and counted on a session of its own
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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?
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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?
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue