A pure Dart package, no SDKs, one interface (LlmClient) over Anthropic's Messages API, OpenAI's chat completions, in each known host's dialect and the local servers' (D45), and OpenAI's Responses API, stateless (D46). A model's turn keeps what its provider wants back with the tools' results: Anthropic's thinking blocks, the Responses API's reasoning items and message phase, Gemini's thought signatures. Failures come by kind, the provider's words cut and without keys. Tested on replies written from the providers' documentation, replayed in pieces of 1, 7 and 4,096 bytes; tool/record_llm.dart records real ones with a key of one's own, which the tests then run on too (D36). FakeLlmClient for the code that uses it. BSD-3-Clause, as halkyon_oidc. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
117 lines
3.6 KiB
Dart
117 lines
3.6 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:http/testing.dart';
|
|
|
|
/// A provider's reply as a fixture file keeps it (`.http`): `HTTP <status>`,
|
|
/// the headers that matter, one per line, an empty line, then the body as it
|
|
/// came, byte for byte. `tool/record_llm.dart` writes recorded replies so.
|
|
final class const Fixture({
|
|
required final String name,
|
|
required final int status,
|
|
required final Map<String, String> headers,
|
|
required final Uint8List body,
|
|
}) {
|
|
/// The fixture in [file].
|
|
factory read(File file) => Fixture.parse(
|
|
file.uri.pathSegments.last.replaceAll('.http', ''),
|
|
file.readAsBytesSync(),
|
|
);
|
|
|
|
/// The fixture [bytes] hold.
|
|
factory parse(String name, Uint8List bytes) {
|
|
final end = _headEnd(bytes);
|
|
final head = utf8.decode(bytes.sublist(0, end)).split('\n');
|
|
final status = RegExp(r'^HTTP (\d{3})$').firstMatch(head.first);
|
|
if (status == null) {
|
|
throw FormatException('No status in $name');
|
|
}
|
|
return Fixture(
|
|
name: name,
|
|
status: int.parse(status[1]!),
|
|
headers: {
|
|
for (final line in head.skip(1).where((line) => line.isNotEmpty))
|
|
line.substring(0, line.indexOf(':')).toLowerCase(): line
|
|
.substring(line.indexOf(':') + 1)
|
|
.trim(),
|
|
},
|
|
body: bytes.sublist(end + 2),
|
|
);
|
|
}
|
|
|
|
/// The bytes of a fixture of [status], [headers] and [body].
|
|
static Uint8List write(
|
|
int status,
|
|
Map<String, String> headers,
|
|
List<int> body,
|
|
) => Uint8List.fromList([
|
|
...utf8.encode('HTTP $status\n'),
|
|
for (final MapEntry(:key, :value) in headers.entries)
|
|
...utf8.encode('$key: $value\n'),
|
|
...utf8.encode('\n'),
|
|
...body,
|
|
]);
|
|
|
|
static int _headEnd(Uint8List bytes) {
|
|
for (var i = 0; i + 1 < bytes.length; i++) {
|
|
if (bytes[i] == 0x0A && bytes[i + 1] == 0x0A) {
|
|
return i;
|
|
}
|
|
}
|
|
throw const FormatException('No end of the head');
|
|
}
|
|
|
|
/// The body in pieces of [size] bytes, as a network may cut it (cutting
|
|
/// characters and lines in two).
|
|
Stream<List<int>> chunked(int size) => Stream.fromIterable([
|
|
for (var at = 0; at < body.length; at += size)
|
|
body.sublist(at, at + size > body.length ? body.length : at + size),
|
|
]);
|
|
}
|
|
|
|
/// The fixtures in `test/fixtures/<kind>/<protocol>/`, by name.
|
|
Map<String, Fixture> fixturesOf(String kind, String protocol) {
|
|
final folder = Directory('test/fixtures/$kind/$protocol');
|
|
if (!folder.existsSync()) {
|
|
return const {};
|
|
}
|
|
final files =
|
|
folder
|
|
.listSync()
|
|
.whereType<File>()
|
|
.where(
|
|
(file) => file.path.endsWith('.http'),
|
|
)
|
|
.toList()
|
|
..sort((a, b) => a.path.compareTo(b.path));
|
|
return {
|
|
for (final file in files) Fixture.read(file).name: Fixture.read(file),
|
|
};
|
|
}
|
|
|
|
/// What a [MockClient] was sent.
|
|
final class Sent() {
|
|
late http.BaseRequest request;
|
|
late Map<String, Object?> body;
|
|
bool aborted = false;
|
|
}
|
|
|
|
/// An HTTP client replying [fixture] in pieces of [chunk] bytes, keeping
|
|
/// what it was sent in [sent].
|
|
http.Client replying(Fixture fixture, {Sent? sent, int chunk = 7}) =>
|
|
MockClient.streaming((request, bodyStream) async {
|
|
sent?.request = request;
|
|
sent?.body = jsonDecode(
|
|
utf8.decode(await bodyStream.toBytes()),
|
|
) as Map<String, Object?>;
|
|
if (request case http.Abortable(:final abortTrigger?)) {
|
|
abortTrigger.whenComplete(() => sent?.aborted = true).ignore();
|
|
}
|
|
return http.StreamedResponse(
|
|
fixture.chunked(chunk),
|
|
fixture.status,
|
|
headers: fixture.headers,
|
|
);
|
|
});
|