feat(base,learn,todo): the operator's assistants, and the instance's privacy page (ADR 0021, step 2)
The server half of the assistants (points 4 to 8, D27 to D46): the
operator sets language models in deploy/assistants.env, grants them to
people with limits, and the module forwards their questions with the
operator's key, counting what each used, never what was asked.
- assistantsFrom: ASSISTANTS, ASSISTANT_ID_KEY, ASSISTANT_<ID>_NAME,
_PROTOCOL (anthropic, openai, openai-responses), _BASE_URL, _MODEL,
_KEY, _IMAGES, _PRICE_INPUT and _PRICE_OUTPUT (per million tokens),
ASSISTANT_CURRENCY, ASSISTANT_OWN_KEYS; anything unusable stops the
server as it starts, naming the variable.
- The assistant endpoint (identified): offered, agree (D40), ask as a
Serverpod stream (D35): the text as the model writes it, then the
whole turn, sent once the request is counted; figures, the caller's
own. Checked in order: at most 2 questions on their way per person
(tooManyAtOnce), the grant, the agreement, limits in the pricing's
unit now (limitOutdated), the month's and the day's limits (with when
they reset, UTC), the question (malformed, imagesNotRead, tooLarge
past 400,000 characters, an image over 3,932,160 bytes or more than
20 images); output capped at 16,384 tokens. Each request carries the
person's opaque ID (HMAC-SHA256 under ASSISTANT_ID_KEY, D30). Once
sent, the provider's reply is read to its end even if the app
cancels, and counted on a session of its own; a provider's failure is
logged by kind and status, told as providerKey, providerBusy (with
its wait), providerRefused or providerFailed.
- The assistantOperator endpoint (identified and operator, on the
register): setUp, grants, grant (assistantNotSet, invalidLimit,
notFound), revoke, figures of a UTC month.
- Tables assistant_grant and assistant_figures (per person, assistant
and UTC day), in the module's, Learn's and Todo's migrations. The
daily pruning keeps this month and the two before
(forgetOldAssistantFigures, D41); deletePerson deletes both.
A member offers no assistant (D43).
- instance.basics() gains assistantOwnKeys.
- /privacy (D39, addPrivacyPageRoute, on the register's web server,
before the profile pages' route): made from the instance's settings,
English or German, no JavaScript: who may sign up, where the data and
backups are, what the module keeps (the account, sessions, IP
addresses, the server's log), the host's privacyPagePoints (Learn's:
studysets synced, private ones encrypted on the devices with what
stays readable, what is published, dictionary lookups, the learner
count), each assistant with its model and host, what goes and what is
counted, and whether personal keys are allowed. Never a key.
Tests: 70 on the assistants (grants, limits, refusals in order,
streaming, cancelling, counting, concurrency, the operator's denials,
deletion, pruning), 19 on the settings, 8 on the privacy page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-25 18:07:01 +01:00
|
|
|
import 'package:halkyon_base_server/base.dart';
|
|
|
|
|
import 'package:halkyon_llm/halkyon_llm.dart';
|
|
|
|
|
import 'package:serverpod/serverpod.dart' show UuidValue;
|
|
|
|
|
import 'package:test/test.dart';
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
final idKey = 'i' * assistantIdKeyMinLength;
|
|
|
|
|
final claude = {
|
|
|
|
|
'ASSISTANTS': 'claude',
|
|
|
|
|
'ASSISTANT_ID_KEY': idKey,
|
|
|
|
|
'ASSISTANT_CLAUDE_NAME': 'Claude Sonnet',
|
|
|
|
|
'ASSISTANT_CLAUDE_PROTOCOL': 'anthropic',
|
|
|
|
|
'ASSISTANT_CLAUDE_MODEL': 'claude-sonnet-5-5',
|
|
|
|
|
'ASSISTANT_CLAUDE_KEY': 'sk-ant-key',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Matcher refusedNaming(String text) => throwsA(
|
|
|
|
|
isA<StateError>().having(
|
|
|
|
|
(error) => error.message,
|
|
|
|
|
'message',
|
|
|
|
|
contains(text),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
group("The operator's assistants (deploy/assistants.env, ADR 0021, D37)", () {
|
|
|
|
|
test('none set: no assistant, no personal keys', () {
|
|
|
|
|
final assistants = assistantsFrom(<String, String>{}.get);
|
|
|
|
|
expect(assistants.all, isEmpty);
|
|
|
|
|
expect(assistants.ownKeys, isFalse);
|
|
|
|
|
expect(assistants.currency, 'USD');
|
|
|
|
|
expect(assistants.idKey, isNull);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('empty values count as unset, as Compose passes them on', () {
|
|
|
|
|
expect(
|
|
|
|
|
assistantsFrom({'ASSISTANTS': '', 'ASSISTANT_OWN_KEYS': ''}.get).all,
|
|
|
|
|
isEmpty,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('one is read with its name, protocol, model and key, on the '
|
|
|
|
|
"provider's own address", () {
|
|
|
|
|
final assistant = assistantsFrom(claude.get).all.single;
|
|
|
|
|
expect(assistant.id, 'claude');
|
|
|
|
|
expect(assistant.name, 'Claude Sonnet');
|
|
|
|
|
expect(assistant.endpoint.protocol, LlmProtocol.anthropic);
|
|
|
|
|
expect(assistant.endpoint.model, 'claude-sonnet-5-5');
|
|
|
|
|
expect(assistant.endpoint.apiKey, 'sk-ant-key');
|
|
|
|
|
expect(
|
|
|
|
|
assistant.endpoint.baseUrl,
|
|
|
|
|
Uri.parse('https://api.anthropic.com'),
|
|
|
|
|
);
|
|
|
|
|
expect(assistant.images, isFalse);
|
|
|
|
|
expect(assistant.prices, isNull);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('several, in the order listed, by comma or space, whatever their '
|
|
|
|
|
'case; a name defaults to the ID', () {
|
|
|
|
|
final assistants = assistantsFrom(
|
|
|
|
|
{
|
|
|
|
|
...claude,
|
|
|
|
|
'ASSISTANTS': ' Claude, gpt local ',
|
|
|
|
|
'ASSISTANT_GPT_PROTOCOL': 'openai-responses',
|
|
|
|
|
'ASSISTANT_GPT_MODEL': 'gpt-6-astra',
|
|
|
|
|
'ASSISTANT_GPT_KEY': 'sk-key',
|
|
|
|
|
'ASSISTANT_LOCAL_PROTOCOL': 'openai',
|
|
|
|
|
'ASSISTANT_LOCAL_BASE_URL': 'http://ollama:11434/v1',
|
|
|
|
|
'ASSISTANT_LOCAL_MODEL': 'qwen3:8b',
|
|
|
|
|
}.get,
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
[for (final a in assistants.all) a.id],
|
|
|
|
|
['claude', 'gpt', 'local'],
|
|
|
|
|
);
|
|
|
|
|
expect(assistants.named('gpt')!.name, 'gpt');
|
|
|
|
|
expect(
|
|
|
|
|
assistants.named('gpt')!.endpoint.protocol,
|
|
|
|
|
LlmProtocol.openaiResponses,
|
|
|
|
|
);
|
|
|
|
|
expect(assistants.named('local')!.endpoint.apiKey, '');
|
|
|
|
|
expect(
|
|
|
|
|
assistants.named('local')!.endpoint.baseUrl,
|
|
|
|
|
Uri.parse('http://ollama:11434/v1'),
|
|
|
|
|
);
|
|
|
|
|
expect(assistants.named('nobody'), isNull);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('images and personal keys only where on', () {
|
|
|
|
|
final assistants = assistantsFrom(
|
|
|
|
|
{
|
|
|
|
|
...claude,
|
|
|
|
|
'ASSISTANT_CLAUDE_IMAGES': 'on',
|
|
|
|
|
'ASSISTANT_OWN_KEYS': 'ON',
|
|
|
|
|
}.get,
|
|
|
|
|
);
|
|
|
|
|
expect(assistants.all.single.images, isTrue);
|
|
|
|
|
expect(assistants.ownKeys, isTrue);
|
|
|
|
|
expect(
|
|
|
|
|
assistantsFrom({...claude, 'ASSISTANT_CLAUDE_IMAGES': 'off'}.get)
|
|
|
|
|
.all
|
|
|
|
|
.single
|
|
|
|
|
.images,
|
|
|
|
|
isFalse,
|
|
|
|
|
);
|
|
|
|
|
expect(assistantsFrom({'ASSISTANT_OWN_KEYS': 'on'}.get).ownKeys, isTrue);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('prices per million tokens, both or none, in the currency set '
|
|
|
|
|
'(D38)', () {
|
|
|
|
|
final assistants = assistantsFrom(
|
|
|
|
|
{
|
|
|
|
|
...claude,
|
|
|
|
|
'ASSISTANT_CLAUDE_PRICE_INPUT': '3',
|
|
|
|
|
'ASSISTANT_CLAUDE_PRICE_OUTPUT': '15.50',
|
|
|
|
|
'ASSISTANT_CURRENCY': 'eur',
|
|
|
|
|
}.get,
|
|
|
|
|
);
|
|
|
|
|
final prices = assistants.all.single.prices!;
|
|
|
|
|
expect((prices.input, prices.output), (3.0, 15.5));
|
|
|
|
|
expect(assistants.currency, 'EUR');
|
|
|
|
|
// 1,000 input tokens at 3 and 200 output at 15.50 per million:
|
|
|
|
|
// 0.003 + 0.0031, in millionths.
|
|
|
|
|
expect(prices.costMicros(inputTokens: 1000, outputTokens: 200), 6100);
|
|
|
|
|
});
|
|
|
|
|
|
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>
2026-10-26 15:07:04 +01:00
|
|
|
test("the effort: the operator's default and highest level, by "
|
|
|
|
|
"Anthropic's names, whatever their case (D63)", () {
|
|
|
|
|
final assistant = assistantsFrom(
|
|
|
|
|
{
|
|
|
|
|
...claude,
|
|
|
|
|
'ASSISTANT_CLAUDE_EFFORT': 'Medium',
|
|
|
|
|
'ASSISTANT_CLAUDE_MAX_EFFORT': 'xhigh',
|
|
|
|
|
}.get,
|
|
|
|
|
).all.single;
|
|
|
|
|
expect(assistant.effort, LlmEffort.medium);
|
|
|
|
|
expect(assistant.efforts, [
|
|
|
|
|
LlmEffort.low,
|
|
|
|
|
LlmEffort.medium,
|
|
|
|
|
LlmEffort.high,
|
|
|
|
|
LlmEffort.xhigh,
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("a default alone is the highest too; none: the model's own, and "
|
|
|
|
|
'none to choose', () {
|
|
|
|
|
final fixed = assistantsFrom(
|
|
|
|
|
{...claude, 'ASSISTANT_CLAUDE_EFFORT': 'high'}.get,
|
|
|
|
|
).all.single;
|
|
|
|
|
expect(fixed.effort, LlmEffort.high);
|
|
|
|
|
expect(fixed.efforts, [LlmEffort.low, LlmEffort.medium, LlmEffort.high]);
|
|
|
|
|
final own = assistantsFrom(claude.get).all.single;
|
|
|
|
|
expect(own.effort, isNull);
|
|
|
|
|
expect(own.efforts, isEmpty);
|
|
|
|
|
});
|
|
|
|
|
|
feat(base,learn,todo): the operator's assistants, and the instance's privacy page (ADR 0021, step 2)
The server half of the assistants (points 4 to 8, D27 to D46): the
operator sets language models in deploy/assistants.env, grants them to
people with limits, and the module forwards their questions with the
operator's key, counting what each used, never what was asked.
- assistantsFrom: ASSISTANTS, ASSISTANT_ID_KEY, ASSISTANT_<ID>_NAME,
_PROTOCOL (anthropic, openai, openai-responses), _BASE_URL, _MODEL,
_KEY, _IMAGES, _PRICE_INPUT and _PRICE_OUTPUT (per million tokens),
ASSISTANT_CURRENCY, ASSISTANT_OWN_KEYS; anything unusable stops the
server as it starts, naming the variable.
- The assistant endpoint (identified): offered, agree (D40), ask as a
Serverpod stream (D35): the text as the model writes it, then the
whole turn, sent once the request is counted; figures, the caller's
own. Checked in order: at most 2 questions on their way per person
(tooManyAtOnce), the grant, the agreement, limits in the pricing's
unit now (limitOutdated), the month's and the day's limits (with when
they reset, UTC), the question (malformed, imagesNotRead, tooLarge
past 400,000 characters, an image over 3,932,160 bytes or more than
20 images); output capped at 16,384 tokens. Each request carries the
person's opaque ID (HMAC-SHA256 under ASSISTANT_ID_KEY, D30). Once
sent, the provider's reply is read to its end even if the app
cancels, and counted on a session of its own; a provider's failure is
logged by kind and status, told as providerKey, providerBusy (with
its wait), providerRefused or providerFailed.
- The assistantOperator endpoint (identified and operator, on the
register): setUp, grants, grant (assistantNotSet, invalidLimit,
notFound), revoke, figures of a UTC month.
- Tables assistant_grant and assistant_figures (per person, assistant
and UTC day), in the module's, Learn's and Todo's migrations. The
daily pruning keeps this month and the two before
(forgetOldAssistantFigures, D41); deletePerson deletes both.
A member offers no assistant (D43).
- instance.basics() gains assistantOwnKeys.
- /privacy (D39, addPrivacyPageRoute, on the register's web server,
before the profile pages' route): made from the instance's settings,
English or German, no JavaScript: who may sign up, where the data and
backups are, what the module keeps (the account, sessions, IP
addresses, the server's log), the host's privacyPagePoints (Learn's:
studysets synced, private ones encrypted on the devices with what
stays readable, what is published, dictionary lookups, the learner
count), each assistant with its model and host, what goes and what is
counted, and whether personal keys are allowed. Never a key.
Tests: 70 on the assistants (grants, limits, refusals in order,
streaming, cancelling, counting, concurrency, the operator's denials,
deletion, pruning), 19 on the settings, 8 on the privacy page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-25 18:07:01 +01:00
|
|
|
group('refuse what would go wrong later, so the server stops', () {
|
|
|
|
|
test('an ID that is no name of a variable', () {
|
|
|
|
|
expect(
|
|
|
|
|
() => assistantsFrom({...claude, 'ASSISTANTS': 'claude, gpt-4'}.get),
|
|
|
|
|
refusedNaming('gpt-4'),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('an ID twice', () {
|
|
|
|
|
expect(
|
|
|
|
|
() => assistantsFrom({...claude, 'ASSISTANTS': 'claude claude'}.get),
|
|
|
|
|
refusedNaming('twice'),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a protocol missing or unknown, naming those there are', () {
|
|
|
|
|
expect(
|
|
|
|
|
() => assistantsFrom(
|
|
|
|
|
({...claude}..remove('ASSISTANT_CLAUDE_PROTOCOL')).get,
|
|
|
|
|
),
|
|
|
|
|
refusedNaming('ASSISTANT_CLAUDE_PROTOCOL'),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
() => assistantsFrom(
|
|
|
|
|
{...claude, 'ASSISTANT_CLAUDE_PROTOCOL': 'claude'}.get,
|
|
|
|
|
),
|
|
|
|
|
refusedNaming('anthropic, openai, openai-responses'),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a model missing', () {
|
|
|
|
|
expect(
|
|
|
|
|
() => assistantsFrom(
|
|
|
|
|
({...claude}..remove('ASSISTANT_CLAUDE_MODEL')).get,
|
|
|
|
|
),
|
|
|
|
|
refusedNaming('ASSISTANT_CLAUDE_MODEL'),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("a key missing for the provider's own address", () {
|
|
|
|
|
expect(
|
|
|
|
|
() =>
|
|
|
|
|
assistantsFrom(({...claude}..remove('ASSISTANT_CLAUDE_KEY')).get),
|
|
|
|
|
refusedNaming('ASSISTANT_CLAUDE_KEY'),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a base URL that is no address, or http beyond this network', () {
|
|
|
|
|
expect(
|
|
|
|
|
() => assistantsFrom(
|
|
|
|
|
{
|
|
|
|
|
...claude,
|
|
|
|
|
'ASSISTANT_CLAUDE_BASE_URL': 'http://proxy.example.org',
|
|
|
|
|
}.get,
|
|
|
|
|
),
|
|
|
|
|
refusedNaming('ASSISTANT_CLAUDE_BASE_URL'),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('one price without the other, or one that is no number', () {
|
|
|
|
|
expect(
|
|
|
|
|
() => assistantsFrom(
|
|
|
|
|
{...claude, 'ASSISTANT_CLAUDE_PRICE_INPUT': '3'}.get,
|
|
|
|
|
),
|
|
|
|
|
refusedNaming('ASSISTANT_CLAUDE_PRICE_OUTPUT'),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
() => assistantsFrom(
|
|
|
|
|
{
|
|
|
|
|
...claude,
|
|
|
|
|
'ASSISTANT_CLAUDE_PRICE_INPUT': '3,00',
|
|
|
|
|
'ASSISTANT_CLAUDE_PRICE_OUTPUT': '15',
|
|
|
|
|
}.get,
|
|
|
|
|
),
|
|
|
|
|
refusedNaming('ASSISTANT_CLAUDE_PRICE_INPUT'),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
() => assistantsFrom(
|
|
|
|
|
{
|
|
|
|
|
...claude,
|
|
|
|
|
'ASSISTANT_CLAUDE_PRICE_INPUT': '-1',
|
|
|
|
|
'ASSISTANT_CLAUDE_PRICE_OUTPUT': '15',
|
|
|
|
|
}.get,
|
|
|
|
|
),
|
|
|
|
|
refusedNaming('ASSISTANT_CLAUDE_PRICE_INPUT'),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
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>
2026-10-26 15:07:04 +01:00
|
|
|
test('an effort that is no level, naming those there are', () {
|
|
|
|
|
expect(
|
|
|
|
|
() => assistantsFrom(
|
|
|
|
|
{...claude, 'ASSISTANT_CLAUDE_EFFORT': 'minimal'}.get,
|
|
|
|
|
),
|
|
|
|
|
refusedNaming('low, medium, high, xhigh, max'),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a highest effort without a default, or below it', () {
|
|
|
|
|
expect(
|
|
|
|
|
() => assistantsFrom(
|
|
|
|
|
{...claude, 'ASSISTANT_CLAUDE_MAX_EFFORT': 'high'}.get,
|
|
|
|
|
),
|
|
|
|
|
refusedNaming('ASSISTANT_CLAUDE_EFFORT'),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
() => assistantsFrom(
|
|
|
|
|
{
|
|
|
|
|
...claude,
|
|
|
|
|
'ASSISTANT_CLAUDE_EFFORT': 'high',
|
|
|
|
|
'ASSISTANT_CLAUDE_MAX_EFFORT': 'low',
|
|
|
|
|
}.get,
|
|
|
|
|
),
|
|
|
|
|
refusedNaming('ASSISTANT_CLAUDE_MAX_EFFORT'),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("an effort the provider takes not: Mistral's none, Gemini's "
|
|
|
|
|
'above high', () {
|
|
|
|
|
final atMistral = {
|
|
|
|
|
...claude,
|
|
|
|
|
'ASSISTANT_CLAUDE_PROTOCOL': 'openai',
|
|
|
|
|
'ASSISTANT_CLAUDE_BASE_URL': 'https://api.mistral.ai/v1',
|
|
|
|
|
};
|
|
|
|
|
expect(
|
|
|
|
|
() => assistantsFrom(
|
|
|
|
|
{...atMistral, 'ASSISTANT_CLAUDE_EFFORT': 'low'}.get,
|
|
|
|
|
),
|
|
|
|
|
refusedNaming('ASSISTANT_CLAUDE_EFFORT'),
|
|
|
|
|
);
|
|
|
|
|
expect(assistantsFrom(atMistral.get).all.single.efforts, isEmpty);
|
|
|
|
|
expect(
|
|
|
|
|
() => assistantsFrom(
|
|
|
|
|
{
|
|
|
|
|
...claude,
|
|
|
|
|
'ASSISTANT_CLAUDE_PROTOCOL': 'openai',
|
|
|
|
|
'ASSISTANT_CLAUDE_BASE_URL':
|
|
|
|
|
'https://generativelanguage.googleapis.com/v1beta/openai',
|
|
|
|
|
'ASSISTANT_CLAUDE_EFFORT': 'high',
|
|
|
|
|
'ASSISTANT_CLAUDE_MAX_EFFORT': 'xhigh',
|
|
|
|
|
}.get,
|
|
|
|
|
),
|
|
|
|
|
refusedNaming('ASSISTANT_CLAUDE_MAX_EFFORT'),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
feat(base,learn,todo): the operator's assistants, and the instance's privacy page (ADR 0021, step 2)
The server half of the assistants (points 4 to 8, D27 to D46): the
operator sets language models in deploy/assistants.env, grants them to
people with limits, and the module forwards their questions with the
operator's key, counting what each used, never what was asked.
- assistantsFrom: ASSISTANTS, ASSISTANT_ID_KEY, ASSISTANT_<ID>_NAME,
_PROTOCOL (anthropic, openai, openai-responses), _BASE_URL, _MODEL,
_KEY, _IMAGES, _PRICE_INPUT and _PRICE_OUTPUT (per million tokens),
ASSISTANT_CURRENCY, ASSISTANT_OWN_KEYS; anything unusable stops the
server as it starts, naming the variable.
- The assistant endpoint (identified): offered, agree (D40), ask as a
Serverpod stream (D35): the text as the model writes it, then the
whole turn, sent once the request is counted; figures, the caller's
own. Checked in order: at most 2 questions on their way per person
(tooManyAtOnce), the grant, the agreement, limits in the pricing's
unit now (limitOutdated), the month's and the day's limits (with when
they reset, UTC), the question (malformed, imagesNotRead, tooLarge
past 400,000 characters, an image over 3,932,160 bytes or more than
20 images); output capped at 16,384 tokens. Each request carries the
person's opaque ID (HMAC-SHA256 under ASSISTANT_ID_KEY, D30). Once
sent, the provider's reply is read to its end even if the app
cancels, and counted on a session of its own; a provider's failure is
logged by kind and status, told as providerKey, providerBusy (with
its wait), providerRefused or providerFailed.
- The assistantOperator endpoint (identified and operator, on the
register): setUp, grants, grant (assistantNotSet, invalidLimit,
notFound), revoke, figures of a UTC month.
- Tables assistant_grant and assistant_figures (per person, assistant
and UTC day), in the module's, Learn's and Todo's migrations. The
daily pruning keeps this month and the two before
(forgetOldAssistantFigures, D41); deletePerson deletes both.
A member offers no assistant (D43).
- instance.basics() gains assistantOwnKeys.
- /privacy (D39, addPrivacyPageRoute, on the register's web server,
before the profile pages' route): made from the instance's settings,
English or German, no JavaScript: who may sign up, where the data and
backups are, what the module keeps (the account, sessions, IP
addresses, the server's log), the host's privacyPagePoints (Learn's:
studysets synced, private ones encrypted on the devices with what
stays readable, what is published, dictionary lookups, the learner
count), each assistant with its model and host, what goes and what is
counted, and whether personal keys are allowed. Never a key.
Tests: 70 on the assistants (grants, limits, refusals in order,
streaming, cancelling, counting, concurrency, the operator's denials,
deletion, pruning), 19 on the settings, 8 on the privacy page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-25 18:07:01 +01:00
|
|
|
test('a switch neither on nor off', () {
|
|
|
|
|
expect(
|
|
|
|
|
() =>
|
|
|
|
|
assistantsFrom({...claude, 'ASSISTANT_CLAUDE_IMAGES': 'yes'}.get),
|
|
|
|
|
refusedNaming('ASSISTANT_CLAUDE_IMAGES'),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
() => assistantsFrom({'ASSISTANT_OWN_KEYS': 'true'}.get),
|
|
|
|
|
refusedNaming('ASSISTANT_OWN_KEYS'),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a currency that is no ISO 4217 code', () {
|
|
|
|
|
expect(
|
|
|
|
|
() => assistantsFrom({...claude, 'ASSISTANT_CURRENCY': 'Euro'}.get),
|
|
|
|
|
refusedNaming('ASSISTANT_CURRENCY'),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("the opaque IDs' key missing or short where assistants are set "
|
|
|
|
|
'(D30)', () {
|
|
|
|
|
expect(
|
|
|
|
|
() => assistantsFrom(({...claude}..remove('ASSISTANT_ID_KEY')).get),
|
|
|
|
|
refusedNaming('ASSISTANT_ID_KEY'),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
() => assistantsFrom({...claude, 'ASSISTANT_ID_KEY': 'short'}.get),
|
|
|
|
|
refusedNaming('ASSISTANT_ID_KEY'),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group("The provider's opaque ID of a person (D30)", () {
|
|
|
|
|
final assistants = assistantsFrom(claude.get);
|
|
|
|
|
final anna = UuidValue.fromString('0193a3d4-5c6e-7f80-9a1b-2c3d4e5f6a7b');
|
|
|
|
|
final ben = UuidValue.fromString('0193a3d4-5c6e-7f80-9a1b-2c3d4e5f6a7c');
|
|
|
|
|
|
|
|
|
|
test('is the same for a person each time, and differs between people', () {
|
|
|
|
|
expect(assistants.personIdOf(anna), assistants.personIdOf(anna));
|
|
|
|
|
expect(assistants.personIdOf(anna), isNot(assistants.personIdOf(ben)));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('is 43 characters of base64url, within what every provider takes, '
|
|
|
|
|
'and never the account', () {
|
|
|
|
|
final id = assistants.personIdOf(anna);
|
|
|
|
|
expect(id, matches(RegExp(r'^[A-Za-z0-9_-]{43}$')));
|
|
|
|
|
expect(id, isNot(contains(anna.uuid)));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('changes with the key', () {
|
|
|
|
|
final other = assistantsFrom(
|
|
|
|
|
{...claude, 'ASSISTANT_ID_KEY': 'j' * assistantIdKeyMinLength}.get,
|
|
|
|
|
);
|
|
|
|
|
expect(other.personIdOf(anna), isNot(assistants.personIdOf(anna)));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
extension on Map<String, String> {
|
|
|
|
|
String? get(String name) => this[name];
|
|
|
|
|
}
|