LlmRequest.effort (LlmEffort: low, medium, high, xhigh, max, Anthropic's names) goes as Anthropic's output_config.effort, as reasoning.effort to the Responses API and OpenRouter, and as reasoning_effort to OpenAI's chat completions, Gemini and any other host. endpoint.efforts says which levels a host takes (Gemini low to high, Mistral none); send refuses another before anything goes, the fake client too. LlmEffort.outputCeiling gives the room for output each level wants, as thinking counts towards the ceiling (Halkyon Learn's ADR 0021, D63). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
260 lines
8 KiB
Dart
260 lines
8 KiB
Dart
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)';
|
|
}
|