feat(app): the operator's overview of screens, grants to several people at once with one budget each, assistants by provider
Halkyon Learn's ADR 0021, D57 to D63, on halkyon_base's budgets and effort: - The operator's screen is an overview (Waiting, Operators, Accounts, Assistants, each with its count) whose rows open screens of their own, read 50 at a time as they scroll (OperatorListCubit, OperatorPage), so hundreds of accounts push nothing else down (D62). - A handle is picked among the accounts there are (AccountSuggestionField): appointing an operator and granting take no other (D61). - AssistantGrantsScreen replaces AssistantGrantsCard: the assistants set up by provider, with their efforts and prices; the people granted, found by their handle's start; one sheet granting several assistants, a provider's at once, to several people, each with one budget in money and tokens a day and a month, or changing one person's (D58, D59). - Assistants are grouped by provider wherever they are listed, each under its name with a dot in its colour (ProviderLabel, D60). - AssistantsCard shows what is left of the person's budget for all their assistants together; the months of the figures run oldest first (D57). - AssistantChoice says the efforts a question may ask at and the ceiling on output each asks for (D63); a person's own key gets none and keeps 4,096 tokens. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
24ed63585e
commit
cc87120bc8
3 changed files with 5348 additions and 2709 deletions
|
|
@ -266,8 +266,8 @@ void main() {
|
|||
(protocol.AssistantRefusal.notAgreed, AssistantProblem.notAgreed),
|
||||
(protocol.AssistantRefusal.limitReached, AssistantProblem.limitReached),
|
||||
(
|
||||
protocol.AssistantRefusal.limitOutdated,
|
||||
AssistantProblem.limitOutdated,
|
||||
protocol.AssistantRefusal.invalidEffort,
|
||||
AssistantProblem.invalidEffort,
|
||||
),
|
||||
(
|
||||
protocol.AssistantRefusal.imagesNotRead,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import 'package:halkyon_llm/halkyon_llm.dart';
|
|||
|
||||
// Who answers a person's question (Halkyon Learn's ADR 0021, points 7 and
|
||||
// 10): an assistant granted, through the instance, or their own key,
|
||||
// directly; and the operator granting assistants by handle (points 2, 6).
|
||||
// directly; and the operator granting assistants to several people at once,
|
||||
// with one budget each (points 2, 6, D58), suggesting only handles there are
|
||||
// (D61).
|
||||
|
||||
const _claude = OfferedAssistant(
|
||||
id: 'claude',
|
||||
|
|
@ -30,6 +32,7 @@ LlmRequest _request() => LlmRequest(
|
|||
maxOutputTokens: 400,
|
||||
system: 'You help with flashcards.',
|
||||
personId: 'person-1',
|
||||
effort: LlmEffort.high,
|
||||
);
|
||||
|
||||
void main() {
|
||||
|
|
@ -61,6 +64,7 @@ void main() {
|
|||
|
||||
expect(events.last, same(finished));
|
||||
expect(gateway.asked.single.$1, 'claude');
|
||||
expect(gateway.asked.single.$2.effort, LlmEffort.high);
|
||||
expect(llm.sent, isEmpty);
|
||||
});
|
||||
|
||||
|
|
@ -79,6 +83,7 @@ void main() {
|
|||
expect(endpoint.model, 'gpt-5');
|
||||
expect(endpoint.apiKey, 'sk-own');
|
||||
expect(request.personId, isNull);
|
||||
expect(request.effort, isNull, reason: 'own keys choose no effort');
|
||||
expect(request.system, 'You help with flashcards.');
|
||||
expect(request.maxOutputTokens, 400);
|
||||
expect(gateway.calls, isEmpty);
|
||||
|
|
@ -140,13 +145,57 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('the effort a question may ask at, and its ceiling (D63)', () {
|
||||
const thinking = OfferedAssistant(
|
||||
id: 'opus',
|
||||
name: 'Opus',
|
||||
protocol: 'anthropic',
|
||||
host: 'api.anthropic.com',
|
||||
model: 'claude-opus-5-5',
|
||||
effort: LlmEffort.low,
|
||||
efforts: [LlmEffort.low, LlmEffort.medium, LlmEffort.high],
|
||||
);
|
||||
|
||||
test("a granted assistant offers the operator's range, its default "
|
||||
'first', () {
|
||||
const choice = GrantedAssistant(thinking);
|
||||
expect(choice.efforts, [LlmEffort.low, LlmEffort.medium, LlmEffort.high]);
|
||||
expect(choice.effort, LlmEffort.low);
|
||||
});
|
||||
|
||||
test('a question asks for the ceiling of its effort, else of the '
|
||||
"assistant's", () {
|
||||
const choice = GrantedAssistant(thinking);
|
||||
expect(choice.outputCeilingAt(LlmEffort.high), 16384);
|
||||
expect(choice.outputCeilingAt(null), LlmEffort.low.outputCeiling);
|
||||
});
|
||||
|
||||
test("one without an effort asks for the models' default ceiling", () {
|
||||
const choice = GrantedAssistant(_claude);
|
||||
expect(choice.efforts, isEmpty);
|
||||
expect(choice.effort, isNull);
|
||||
expect(choice.outputCeilingAt(null), LlmEffort.defaultOutputCeiling);
|
||||
});
|
||||
|
||||
test('an own key offers no effort and asks for the small ceiling it '
|
||||
'always had, whatever its model allows', () {
|
||||
const choice = PersonalAssistant(_own);
|
||||
expect(choice.efforts, isEmpty);
|
||||
expect(choice.effort, isNull);
|
||||
expect(choice.outputCeilingAt(LlmEffort.max), 4096);
|
||||
});
|
||||
});
|
||||
|
||||
group('AssistantOperatorRepository', () {
|
||||
const anna = 'account-anna';
|
||||
const annabel = 'account-annabel';
|
||||
const ben = 'account-ben';
|
||||
late FakeAssistantOperatorGateway gateway;
|
||||
late FakeInstanceOperatorGateway accounts;
|
||||
late AssistantOperatorRepository repository;
|
||||
|
||||
const budget = AssistantBudget(dailyCostMicros: 1000000);
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeAssistantOperatorGateway()
|
||||
..set = const AssistantsSetUp(
|
||||
|
|
@ -160,9 +209,16 @@ void main() {
|
|||
priceInput: 3,
|
||||
priceOutput: 15,
|
||||
),
|
||||
SetUpAssistant(
|
||||
id: 'opus',
|
||||
name: 'Opus',
|
||||
protocol: 'anthropic',
|
||||
host: 'api.anthropic.com',
|
||||
model: 'claude-opus-5-5',
|
||||
),
|
||||
],
|
||||
)
|
||||
..accounts.addAll({anna: 'Anna', annabel: 'annabel'});
|
||||
..accounts.addAll({anna: 'Anna', annabel: 'annabel', ben: 'ben'});
|
||||
accounts = FakeInstanceOperatorGateway()
|
||||
..operates = true
|
||||
..add(OperatedAccount(id: anna, handle: 'Anna', since: DateTime(2026)))
|
||||
|
|
@ -172,50 +228,84 @@ void main() {
|
|||
handle: 'annabel',
|
||||
since: DateTime(2026),
|
||||
),
|
||||
);
|
||||
)
|
||||
..add(
|
||||
OperatedAccount(
|
||||
id: 'account-anders',
|
||||
handle: 'anders',
|
||||
since: DateTime(2026),
|
||||
waitingSince: DateTime(2026),
|
||||
),
|
||||
)
|
||||
..add(OperatedAccount(id: 'account-none', since: DateTime(2026)));
|
||||
repository = AssistantOperatorRepository(gateway, accounts);
|
||||
addTearDown(repository.dispose);
|
||||
});
|
||||
|
||||
test('grants by the exact handle, an @ before it and its case left '
|
||||
'out, and tells the screen', () async {
|
||||
test('grants several people several assistants at once, each the same '
|
||||
'budget, and tells the screens', () async {
|
||||
final changed = expectLater(repository.changes, emits(null));
|
||||
|
||||
await repository.grant(
|
||||
handle: ' @anna ',
|
||||
assistant: 'claude',
|
||||
dailyLimit: 1000000,
|
||||
monthlyLimit: null,
|
||||
accounts: [anna, ben],
|
||||
assistants: ['claude', 'opus'],
|
||||
budget: budget,
|
||||
);
|
||||
|
||||
await changed;
|
||||
final grant = gateway.granted.single;
|
||||
expect(grant.account, anna);
|
||||
expect(grant.unit, AssistantLimitUnit.money);
|
||||
expect(grant.dailyLimit, 1000000);
|
||||
expect(grant.monthlyLimit, isNull);
|
||||
for (final account in [anna, ben]) {
|
||||
final grantee = gateway.people[account]!;
|
||||
expect(
|
||||
[for (final grant in grantee.grants) grant.assistant],
|
||||
['claude', 'opus'],
|
||||
);
|
||||
expect(grantee.budget, budget);
|
||||
}
|
||||
});
|
||||
|
||||
test('refuses a handle nobody has, or only the start of one, and grants '
|
||||
'nothing', () async {
|
||||
for (final handle in ['bob', 'ann', '@', '']) {
|
||||
await expectLater(
|
||||
repository.grant(
|
||||
handle: handle,
|
||||
assistant: 'claude',
|
||||
dailyLimit: null,
|
||||
monthlyLimit: null,
|
||||
),
|
||||
throwsA(const OperatorFailed(OperatorProblem.notFound)),
|
||||
reason: handle,
|
||||
);
|
||||
}
|
||||
expect(gateway.granted, isEmpty);
|
||||
test('suggests approved accounts whose handle starts as typed, an @ and '
|
||||
'its case left out, at most as many as asked', () async {
|
||||
expect(
|
||||
accounts.calls.where((call) => call == 'accounts '),
|
||||
isEmpty,
|
||||
reason: 'nothing typed is never looked up',
|
||||
[for (final account in await repository.grantable(' @AN ')) account.id],
|
||||
[anna, annabel],
|
||||
reason: 'anders waits, and an account without a handle is none',
|
||||
);
|
||||
expect(await repository.grantable('an', count: 1), hasLength(1));
|
||||
expect(await repository.grantable('bob'), isEmpty);
|
||||
});
|
||||
|
||||
test('suggests nothing for nothing typed, and asks nothing', () async {
|
||||
for (final typed in ['', ' ', '@']) {
|
||||
expect(await repository.grantable(typed), isEmpty, reason: typed);
|
||||
}
|
||||
expect(accounts.calls, isEmpty);
|
||||
});
|
||||
|
||||
test('reads the people a grant would replace the budget of; none asked, '
|
||||
'none read', () async {
|
||||
await repository.grant(
|
||||
accounts: [anna],
|
||||
assistants: ['claude'],
|
||||
budget: budget,
|
||||
);
|
||||
|
||||
final asked = gateway.calls.length;
|
||||
expect(await repository.granteesOf(const []), isEmpty);
|
||||
expect(gateway.calls, hasLength(asked));
|
||||
expect(
|
||||
[
|
||||
for (final grantee in await repository.granteesOf([anna, ben]))
|
||||
grantee.account,
|
||||
],
|
||||
[anna],
|
||||
);
|
||||
});
|
||||
|
||||
test('reads the people granted by the start of their handle, an @ left '
|
||||
'out, a page from where the last ended', () async {
|
||||
await repository.grantees(' @an', offset: 50);
|
||||
|
||||
expect(gateway.calls.single, 'grantees an 50');
|
||||
});
|
||||
|
||||
test("the instance's refusals pass through, and change nothing", () async {
|
||||
|
|
@ -223,57 +313,72 @@ void main() {
|
|||
repository.changes.listen((_) => changes++);
|
||||
|
||||
await expectLater(
|
||||
repository.changeLimits(
|
||||
account: anna,
|
||||
assistant: 'gpt',
|
||||
dailyLimit: null,
|
||||
monthlyLimit: null,
|
||||
repository.grant(
|
||||
accounts: [anna],
|
||||
assistants: ['gpt'],
|
||||
budget: AssistantBudget.none,
|
||||
),
|
||||
throwsA(const OperatorFailed(OperatorProblem.assistantNotSet)),
|
||||
);
|
||||
await expectLater(
|
||||
repository.changeLimits(
|
||||
repository.change(
|
||||
account: anna,
|
||||
assistant: 'claude',
|
||||
dailyLimit: -1,
|
||||
monthlyLimit: null,
|
||||
assistants: ['claude'],
|
||||
budget: const AssistantBudget(monthlyTokens: -1),
|
||||
),
|
||||
throwsA(const OperatorFailed(OperatorProblem.invalidLimit)),
|
||||
);
|
||||
await expectLater(
|
||||
repository.grant(
|
||||
accounts: [anna, 'account-gone'],
|
||||
assistants: ['claude'],
|
||||
budget: AssistantBudget.none,
|
||||
),
|
||||
throwsA(const OperatorFailed(OperatorProblem.notFound)),
|
||||
);
|
||||
await expectLater(
|
||||
repository.revoke(ben),
|
||||
throwsA(const OperatorFailed(OperatorProblem.notFound)),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
expect(changes, 0);
|
||||
expect(gateway.people, isEmpty);
|
||||
});
|
||||
|
||||
test('changing limits keeps the agreement; taking one back removes '
|
||||
'it', () async {
|
||||
await repository.changeLimits(
|
||||
account: anna,
|
||||
assistant: 'claude',
|
||||
dailyLimit: 1,
|
||||
monthlyLimit: 2,
|
||||
test('changing keeps the agreements of the assistants kept; taking back '
|
||||
'removes every one and the budget', () async {
|
||||
await repository.grant(
|
||||
accounts: [anna],
|
||||
assistants: ['claude', 'opus'],
|
||||
budget: budget,
|
||||
);
|
||||
gateway.granted[0] = AssistantGrant(
|
||||
final agreed = DateTime.utc(2026, 10, 25);
|
||||
gateway.people[anna] = AssistantGrantee(
|
||||
account: anna,
|
||||
handle: 'Anna',
|
||||
assistant: 'claude',
|
||||
grantedAt: gateway.granted[0].grantedAt,
|
||||
unit: AssistantLimitUnit.money,
|
||||
dailyLimit: 1,
|
||||
monthlyLimit: 2,
|
||||
agreedAt: DateTime.utc(2026, 10, 25),
|
||||
grants: [
|
||||
for (final grant in gateway.people[anna]!.grants)
|
||||
AssistantGrant(
|
||||
assistant: grant.assistant,
|
||||
grantedAt: grant.grantedAt,
|
||||
agreedAt: agreed,
|
||||
),
|
||||
],
|
||||
budget: budget,
|
||||
);
|
||||
|
||||
await repository.changeLimits(
|
||||
await repository.change(
|
||||
account: anna,
|
||||
assistant: 'claude',
|
||||
dailyLimit: 5,
|
||||
monthlyLimit: null,
|
||||
assistants: ['opus'],
|
||||
budget: AssistantBudget.none,
|
||||
);
|
||||
expect(gateway.granted.single.agreedAt, DateTime.utc(2026, 10, 25));
|
||||
expect(gateway.granted.single.dailyLimit, 5);
|
||||
final changed = gateway.people[anna]!;
|
||||
expect(changed.grants.single.assistant, 'opus');
|
||||
expect(changed.grants.single.agreedAt, agreed);
|
||||
expect(changed.budget, AssistantBudget.none);
|
||||
|
||||
await repository.revoke(account: anna, assistant: 'claude');
|
||||
expect(gateway.granted, isEmpty);
|
||||
await repository.revoke(anna);
|
||||
expect(gateway.people, isEmpty);
|
||||
});
|
||||
|
||||
test('asks the figures of the UTC month a moment is in', () async {
|
||||
|
|
|
|||
|
|
@ -3,15 +3,16 @@ import 'dart:convert';
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart'
|
||||
hide AssistantLimitUnit, AssistantsSetUp;
|
||||
hide AssistantGrantee, AssistantUse, AssistantsSetUp;
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart'
|
||||
as protocol
|
||||
show AssistantLimitUnit, AssistantsSetUp;
|
||||
show AssistantGrantee, AssistantUse, AssistantsSetUp;
|
||||
import 'package:halkyon_llm/halkyon_llm.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
|
||||
/// An instance's assistants as the network shows them: Claude granted to
|
||||
/// the person, priced; or each call refuses as [refusals] says, by its
|
||||
/// the person, with a budget partly used; or each call refuses as [refusals] says, by its
|
||||
/// endpoint and method. Asking streams over a WebSocket, which this can't
|
||||
/// fake: `eventsOf` is tested on its own.
|
||||
final class _Instance() {
|
||||
|
|
@ -50,15 +51,15 @@ final class _Instance() {
|
|||
model: 'claude-sonnet-5-5',
|
||||
images: true,
|
||||
agreedAt: DateTime.utc(2026, 10, 25, 9),
|
||||
limitUnit: protocol.AssistantLimitUnit.costMicros,
|
||||
dailyLimit: 1000000,
|
||||
usedToday: 8100,
|
||||
usedThisMonth: 8100,
|
||||
outdated: false,
|
||||
effort: AssistantEffort.medium,
|
||||
efforts: [AssistantEffort.low, AssistantEffort.medium],
|
||||
),
|
||||
],
|
||||
ownKeys: true,
|
||||
currency: 'EUR',
|
||||
budget: AssistantLimits(dailyCostMicros: 1000000, monthlyTokens: 50000),
|
||||
usedToday: protocol.AssistantUse(costMicros: 8100, tokens: 1500),
|
||||
usedThisMonth: protocol.AssistantUse(costMicros: 8100, tokens: 1500),
|
||||
).toJson(),
|
||||
'halkyon_base.assistantOperator/setUp' => protocol.AssistantsSetUp(
|
||||
assistants: [
|
||||
|
|
@ -71,6 +72,8 @@ final class _Instance() {
|
|||
images: true,
|
||||
priceInput: 3,
|
||||
priceOutput: 15,
|
||||
effort: AssistantEffort.medium,
|
||||
maxEffort: AssistantEffort.high,
|
||||
),
|
||||
AssistantInfo(
|
||||
id: 'local',
|
||||
|
|
@ -83,17 +86,29 @@ final class _Instance() {
|
|||
],
|
||||
currency: 'EUR',
|
||||
ownKeys: false,
|
||||
people: 1,
|
||||
).toJson(),
|
||||
'halkyon_base.assistantOperator/grants' => [
|
||||
AssistantGrantOverview(
|
||||
'halkyon_base.assistantOperator/grantees' ||
|
||||
'halkyon_base.assistantOperator/granteesOf' => [
|
||||
protocol.AssistantGrantee(
|
||||
account: anna,
|
||||
handle: 'anna',
|
||||
assistant: 'local',
|
||||
assistantSet: false,
|
||||
grantedAt: DateTime.utc(2026, 10, 20),
|
||||
limitUnit: protocol.AssistantLimitUnit.tokens,
|
||||
monthlyLimit: 2000000,
|
||||
outdated: true,
|
||||
assistants: [
|
||||
AssistantGranted(
|
||||
assistant: 'claude',
|
||||
assistantSet: true,
|
||||
grantedAt: DateTime.utc(2026, 10, 20),
|
||||
agreedAt: DateTime.utc(2026, 10, 21),
|
||||
),
|
||||
AssistantGranted(
|
||||
assistant: 'local',
|
||||
assistantSet: false,
|
||||
grantedAt: DateTime.utc(2026, 10, 20),
|
||||
),
|
||||
],
|
||||
budget: AssistantLimits(monthlyTokens: 2000000),
|
||||
usedToday: protocol.AssistantUse(costMicros: 0, tokens: 300),
|
||||
usedThisMonth: protocol.AssistantUse(costMicros: 900, tokens: 1500),
|
||||
).toJson(),
|
||||
],
|
||||
'halkyon_base.assistant/figures' ||
|
||||
|
|
@ -129,6 +144,7 @@ final class _Instance() {
|
|||
return switch (call) {
|
||||
'halkyon_base.assistant/agree' ||
|
||||
'halkyon_base.assistantOperator/grant' ||
|
||||
'halkyon_base.assistantOperator/change' ||
|
||||
'halkyon_base.assistantOperator/revoke' => http.Response('null', 200),
|
||||
_ when _answer(call) != null => http.Response(
|
||||
jsonEncode(_answer(call)),
|
||||
|
|
@ -159,7 +175,8 @@ void main() {
|
|||
|
||||
setUp(() => gateway = ServerpodAssistantGateway(app.modules));
|
||||
|
||||
test('reads what is offered, priced in money, with what is left', () async {
|
||||
test('reads what is offered, with its efforts, and the budget with '
|
||||
'what is left', () async {
|
||||
final offer = await gateway.offered();
|
||||
|
||||
expect(
|
||||
|
|
@ -174,18 +191,34 @@ void main() {
|
|||
model: 'claude-sonnet-5-5',
|
||||
images: true,
|
||||
agreedAt: DateTime.utc(2026, 10, 25, 9),
|
||||
unit: AssistantLimitUnit.money,
|
||||
dailyLimit: 1000000,
|
||||
usedToday: 8100,
|
||||
usedThisMonth: 8100,
|
||||
effort: LlmEffort.medium,
|
||||
efforts: [LlmEffort.low, LlmEffort.medium],
|
||||
),
|
||||
],
|
||||
ownKeys: true,
|
||||
currency: 'EUR',
|
||||
budget: const AssistantBudget(
|
||||
dailyCostMicros: 1000000,
|
||||
monthlyTokens: 50000,
|
||||
),
|
||||
usedToday: const BudgetUse(costMicros: 8100, tokens: 1500),
|
||||
usedThisMonth: const BudgetUse(costMicros: 8100, tokens: 1500),
|
||||
),
|
||||
);
|
||||
expect(offer.assistants.single.leftToday, 991900);
|
||||
expect(offer.assistants.single.leftThisMonth, isNull);
|
||||
expect(offer.left, [
|
||||
(
|
||||
unit: AssistantLimitUnit.money,
|
||||
daily: true,
|
||||
limit: 1000000,
|
||||
left: 991900,
|
||||
),
|
||||
(
|
||||
unit: AssistantLimitUnit.tokens,
|
||||
daily: false,
|
||||
limit: 50000,
|
||||
left: 48500,
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
test("agrees by the assistant's ID, and reads its own figures", () async {
|
||||
|
|
@ -246,54 +279,102 @@ void main() {
|
|||
|
||||
setUp(() => gateway = ServerpodAssistantOperatorGateway(app.modules));
|
||||
|
||||
test('reads the assistants set, priced or not, without keys', () async {
|
||||
test('reads the assistants set, priced or not, with their efforts, '
|
||||
'without keys', () async {
|
||||
final setUp = await gateway.setUp();
|
||||
|
||||
expect(setUp.currency, 'EUR');
|
||||
expect(setUp.ownKeys, isFalse);
|
||||
expect(setUp.named('claude')?.unit, AssistantLimitUnit.money);
|
||||
expect(setUp.named('local')?.unit, AssistantLimitUnit.tokens);
|
||||
expect(setUp.people, 1);
|
||||
expect(setUp.named('claude')?.priced, isTrue);
|
||||
expect(setUp.named('claude')?.effort, LlmEffort.medium);
|
||||
expect(setUp.named('claude')?.maxEffort, LlmEffort.high);
|
||||
expect(setUp.named('local')?.priced, isFalse);
|
||||
expect(setUp.named('local')?.effort, isNull);
|
||||
expect(setUp.named('gpt'), isNull);
|
||||
});
|
||||
|
||||
test('reads grants, one of an assistant no longer set', () async {
|
||||
expect(await gateway.grants(), [
|
||||
AssistantGrant(
|
||||
test('reads the people granted, a page by the start of their handle, '
|
||||
'with their budget and use', () async {
|
||||
final grantees = await gateway.grantees(startingWith: 'an', offset: 50);
|
||||
|
||||
expect(grantees, [
|
||||
AssistantGrantee(
|
||||
account: '${_Instance.anna}',
|
||||
handle: 'anna',
|
||||
assistant: 'local',
|
||||
assistantSet: false,
|
||||
grantedAt: DateTime.utc(2026, 10, 20),
|
||||
monthlyLimit: 2000000,
|
||||
outdated: true,
|
||||
grants: [
|
||||
AssistantGrant(
|
||||
assistant: 'claude',
|
||||
grantedAt: DateTime.utc(2026, 10, 20),
|
||||
agreedAt: DateTime.utc(2026, 10, 21),
|
||||
),
|
||||
AssistantGrant(
|
||||
assistant: 'local',
|
||||
assistantSet: false,
|
||||
grantedAt: DateTime.utc(2026, 10, 20),
|
||||
),
|
||||
],
|
||||
budget: const AssistantBudget(monthlyTokens: 2000000),
|
||||
usedToday: const BudgetUse(tokens: 300),
|
||||
usedThisMonth: const BudgetUse(costMicros: 900, tokens: 1500),
|
||||
),
|
||||
]);
|
||||
expect(
|
||||
jsonDecode(
|
||||
instance.bodies['halkyon_base.assistantOperator/grantees']!,
|
||||
),
|
||||
{'startingWith': 'an', 'offset': 50},
|
||||
);
|
||||
|
||||
await gateway.granteesOf(['${_Instance.anna}']);
|
||||
expect(
|
||||
jsonDecode(
|
||||
instance.bodies['halkyon_base.assistantOperator/granteesOf']!,
|
||||
),
|
||||
{
|
||||
'accounts': ['${_Instance.anna}'],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('grants and takes back by account ID, and asks a month in '
|
||||
'UTC', () async {
|
||||
test('grants several at once, changes and takes back by account ID, '
|
||||
'and asks a month in UTC', () async {
|
||||
final anna = '${_Instance.anna}';
|
||||
await gateway.grant(
|
||||
account: anna,
|
||||
assistant: 'claude',
|
||||
dailyLimit: 1000000,
|
||||
monthlyLimit: null,
|
||||
accounts: [anna],
|
||||
assistants: ['claude', 'local'],
|
||||
budget: const AssistantBudget(dailyCostMicros: 1000000),
|
||||
);
|
||||
await gateway.revoke(account: anna, assistant: 'claude');
|
||||
await gateway.change(
|
||||
account: anna,
|
||||
assistants: ['claude'],
|
||||
budget: AssistantBudget.none,
|
||||
);
|
||||
await gateway.revoke(anna);
|
||||
await gateway.figures(DateTime.utc(2026, 10));
|
||||
|
||||
expect(
|
||||
jsonDecode(instance.bodies['halkyon_base.assistantOperator/grant']!),
|
||||
{
|
||||
'accounts': [anna],
|
||||
'assistants': ['claude', 'local'],
|
||||
'budget': {
|
||||
'__className__': 'halkyon_base.AssistantLimits',
|
||||
'dailyCostMicros': 1000000,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(
|
||||
jsonDecode(instance.bodies['halkyon_base.assistantOperator/change']!),
|
||||
{
|
||||
'account': anna,
|
||||
'assistant': 'claude',
|
||||
'dailyLimit': 1000000,
|
||||
'monthlyLimit': null,
|
||||
'assistants': ['claude'],
|
||||
'budget': {'__className__': 'halkyon_base.AssistantLimits'},
|
||||
},
|
||||
);
|
||||
expect(
|
||||
jsonDecode(instance.bodies['halkyon_base.assistantOperator/revoke']!),
|
||||
{'account': anna, 'assistant': 'claude'},
|
||||
{'account': anna},
|
||||
);
|
||||
expect(
|
||||
jsonDecode(instance.bodies['halkyon_base.assistantOperator/figures']!),
|
||||
|
|
@ -303,7 +384,7 @@ void main() {
|
|||
|
||||
test("an account ID that isn't one fails, and nothing is sent", () async {
|
||||
await expectLater(
|
||||
gateway.revoke(account: 'anna', assistant: 'claude'),
|
||||
gateway.revoke('anna'),
|
||||
throwsA(const OperatorFailed(OperatorProblem.failed)),
|
||||
);
|
||||
expect(instance.bodies, isEmpty);
|
||||
|
|
@ -319,10 +400,9 @@ void main() {
|
|||
OperatorRefusedException(reason: refusal);
|
||||
await expectLater(
|
||||
gateway.grant(
|
||||
account: '${_Instance.anna}',
|
||||
assistant: 'claude',
|
||||
dailyLimit: null,
|
||||
monthlyLimit: null,
|
||||
accounts: ['${_Instance.anna}'],
|
||||
assistants: ['claude'],
|
||||
budget: AssistantBudget.none,
|
||||
),
|
||||
throwsA(OperatorFailed(problem)),
|
||||
reason: refusal.name,
|
||||
|
|
@ -332,9 +412,9 @@ void main() {
|
|||
|
||||
test("someone who doesn't operate the instance is not allowed", () async {
|
||||
for (final status in [401, 403]) {
|
||||
instance.refusals['halkyon_base.assistantOperator/grants'] = status;
|
||||
instance.refusals['halkyon_base.assistantOperator/grantees'] = status;
|
||||
await expectLater(
|
||||
gateway.grants(),
|
||||
gateway.grantees(startingWith: '', offset: 0),
|
||||
throwsA(const OperatorFailed(OperatorProblem.notAllowed)),
|
||||
reason: '$status',
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue