import 'dart:typed_data'; import 'package:halkyon_llm/src/effort.dart'; import 'package:halkyon_llm/src/endpoint.dart'; import 'package:halkyon_llm/src/failure.dart'; /// Who said a [LlmMessage]: the person (with what their app adds, such as /// tools' results), or the model. enum LlmRole() { user, assistant, } /// One turn of a conversation: its [role] and its [parts], in order. /// /// What a role may hold is what both protocols allow: a person's turn holds /// text, images and tools' results; the model's turn holds text, tool calls /// and the blocks only its provider reads. Anything else is refused as the /// message is made, not when a provider refuses it. final class LlmMessage._( /// Who said it. final LlmRole role, List parts, bool Function(LlmPart) allowed, ) { this { if (parts.isEmpty) { throw ArgumentError.value(parts, 'parts', 'A message needs a part'); } for (final part in parts) { if (!allowed(part)) { throw ArgumentError.value( part, 'parts', 'Not in a turn of ${role.name}', ); } } } /// A person's turn: text, images, and the results of the model's tool /// calls. new user(List parts) : this._( LlmRole.user, parts, (part) => part is! LlmToolCall && part is! LlmProviderBlock, ); /// The model's turn, as [LlmFinished] hands it back: text, tool calls and /// its provider's own blocks. new assistant(List parts) : this._( LlmRole.assistant, parts, (part) => part is! LlmImage && part is! LlmToolResult, ); /// What it holds, in order. final List parts = List.unmodifiable(parts); /// The text parts, joined: what a person reads of the model's turn. String get text => parts.whereType().map((part) => part.text).join(); /// The tool calls the model made in this turn, in order. List get toolCalls => parts.whereType().toList(); } /// A part of a [LlmMessage]. sealed class const LlmPart(); /// Text. final class const LlmText( /// The text. final String text, { /// What the provider gave with the text of the model's turn to have back /// with it: the Responses API's `phase` of a message (`commentary` before /// tool calls, `final_answer`), which its newest models want returned. final Map extra = const {}, }) extends LlmPart { @override String toString() => 'LlmText(${text.length} characters)'; } /// An image a person adds, for an assistant that reads images (ADR 0021, /// D12). final class const LlmImage({ /// Its type: `image/jpeg`, `image/png`, `image/gif` or `image/webp`, which /// both protocols' providers take. required final String mediaType, /// The image's bytes. required final Uint8List bytes, }) extends LlmPart { @override String toString() => 'LlmImage($mediaType, ${bytes.length} bytes)'; } /// A call of one of the request's tools, as the model made it. final class const LlmToolCall({ /// The provider's ID of the call, which its result names. required final String id, /// The tool's name. required final String name, /// What the model passes, as the tool's schema describes it. required final Map input, /// What the provider gave with the call to have back with it, as it gave /// it: Gemini's `extra_content`, whose thought signature its models want /// returned. final Map extra = const {}, }) extends LlmPart { @override String toString() => 'LlmToolCall($id, $name)'; } /// What the app answers to a [LlmToolCall]. final class const LlmToolResult({ /// The call's [LlmToolCall.id]. required final String callId, /// The answer, as text. required final String content, /// Whether the tool failed, [content] then saying why. final bool isError = false, }) extends LlmPart { @override String toString() => 'LlmToolResult($callId${isError ? ', error' : ''})'; } /// A block of the model's turn that only its provider reads, kept as it came /// and sent back to that provider alone: Anthropic's `thinking` and /// `redacted_thinking` blocks, which its models write by default, and the /// Responses API's encrypted `reasoning` items (D46); both must come back /// unchanged with the tools' results. final class const LlmProviderBlock({ /// The protocol it came in, which alone gets it back. required final LlmProtocol protocol, /// The block, as the provider wrote it. required final Map block, }) extends LlmPart { @override String toString() => 'LlmProviderBlock(${protocol.name}, ${block['type']})'; } /// A tool the model may call, which the app runs (ADR 0021, point 7). final class const LlmTool({ /// Its name: letters, digits, `_` and `-`, 1 to 64 of them, which both /// protocols take. required final String name, /// What it does, for the model. required final String description, /// Its input, as a JSON Schema of an object. required final Map inputSchema, }); /// One request: the conversation so far and what the model may do. final class const LlmRequest({ /// The conversation, ending with a person's turn. required final List messages, /// The ceiling on the answer's tokens, which also bounds what one request /// may cost; a model's thinking counts towards it, so a higher [effort] /// wants a higher one ([LlmEffort.outputCeiling]). required final int maxOutputTokens, /// How much the model thinks first; null: as much as it does by default. /// Only a level the endpoint takes (its `efforts`). final LlmEffort? effort, /// What the model is told before the conversation; empty: nothing. final String system = '', /// The tools it may call. final List tools = const [], /// An opaque ID of the person who asks, which the provider may use against /// one person's abuse (ADR 0021, D30); 64 characters at most, never their /// handle. Null with a personal key, whose provider knows its owner. final String? personId, }); /// The stream of an answer: [LlmTextDelta]s as the model writes, then one /// [LlmFinished]. A failure ends it with an [LlmFailure] instead. sealed class const LlmEvent(); /// More of the answer's text, as the model writes it. final class const LlmTextDelta( /// The text that follows what came before. final String text, ) extends LlmEvent { @override String toString() => 'LlmTextDelta(${text.length} characters)'; } /// The answer, whole: the last event of a stream. final class const LlmFinished({ /// The model's turn as it is to be sent back: its text, tool calls and its /// provider's own blocks, in the order written. required final LlmMessage message, /// Why the model stopped. required final LlmStop stop, /// What the provider counted. required final LlmUsage usage, }) extends LlmEvent { @override String toString() => 'LlmFinished(${stop.name}, $usage)'; } /// Why the model stopped. enum LlmStop() { /// It finished its answer. end, /// It reached the request's ceiling on tokens, or the model's context. maxTokens, /// It calls tools, whose results the app sends in the next request. toolCalls, /// It declined to answer, or the provider's filter stopped it. refused, /// Something else the provider names. other, } /// What a provider counted for one request, which limits and figures use /// (ADR 0021, point 8). final class const LlmUsage({ /// The request's tokens, those read from a provider's cache included. required final int inputTokens, /// The answer's tokens, a model's thinking included. required final int outputTokens, }) { /// Nothing counted. static const none = LlmUsage(inputTokens: 0, outputTokens: 0); @override bool operator ==(Object other) => other is LlmUsage && other.inputTokens == inputTokens && other.outputTokens == outputTokens; @override int get hashCode => Object.hash(inputTokens, outputTokens); @override String toString() => 'LlmUsage(in $inputTokens, out $outputTokens)'; }