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().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({}.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? get(String name) => this[name]; }