275 lines
8.8 KiB
Dart
275 lines
8.8 KiB
Dart
|
|
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);
|
||
|
|
});
|
||
|
|
|
||
|
|
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'),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
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];
|
||
|
|
}
|