halkyon/packages/halkyon_llm/lib/src/conversation.dart

260 lines
8 KiB
Dart
Raw Normal View History

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<LlmPart> 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<LlmPart> 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<LlmPart> parts)
: this._(
LlmRole.assistant,
parts,
(part) => part is! LlmImage && part is! LlmToolResult,
);
/// What it holds, in order.
final List<LlmPart> parts = List.unmodifiable(parts);
/// The text parts, joined: what a person reads of the model's turn.
String get text => parts.whereType<LlmText>().map((part) => part.text).join();
/// The tool calls the model made in this turn, in order.
List<LlmToolCall> get toolCalls => parts.whereType<LlmToolCall>().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<String, Object?> 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<String, Object?> 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<String, Object?> 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<String, Object?> 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<String, Object?> 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<LlmMessage> 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<LlmTool> 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)';
}