561 lines
17 KiB
Dart
561 lines
17 KiB
Dart
|
|
import 'dart:convert';
|
||
|
|
import 'dart:typed_data';
|
||
|
|
|
||
|
|
import 'package:halkyon_llm/halkyon_llm.dart';
|
||
|
|
import 'package:test/test.dart';
|
||
|
|
|
||
|
|
import 'support/fixture.dart';
|
||
|
|
|
||
|
|
LlmEndpoint _at(String? baseUrl, {String apiKey = 'sk-test'}) => LlmEndpoint(
|
||
|
|
protocol: LlmProtocol.openai,
|
||
|
|
model: 'the-model',
|
||
|
|
apiKey: apiKey,
|
||
|
|
baseUrl: baseUrl,
|
||
|
|
);
|
||
|
|
|
||
|
|
final _openAi = _at(null);
|
||
|
|
final _mistral = _at('https://api.mistral.ai/v1');
|
||
|
|
final _gemini = _at('https://generativelanguage.googleapis.com/v1beta/openai/');
|
||
|
|
final _openRouter = _at('https://openrouter.ai/api/v1');
|
||
|
|
final _ollama = _at('http://localhost:11434/v1', apiKey: '');
|
||
|
|
|
||
|
|
final _question = LlmRequest(
|
||
|
|
system: 'Du hilfst beim Japanischlernen.',
|
||
|
|
messages: [
|
||
|
|
LlmMessage.user([const LlmText('Was heißt „Hund“ auf Japanisch?')]),
|
||
|
|
],
|
||
|
|
maxOutputTokens: 4096,
|
||
|
|
personId: 'p_8Zq3',
|
||
|
|
);
|
||
|
|
|
||
|
|
final _documented = fixturesOf('documented', 'openai');
|
||
|
|
|
||
|
|
Future<(List<LlmEvent>, LlmFailure?)> _reply(
|
||
|
|
Fixture fixture, {
|
||
|
|
LlmEndpoint? endpoint,
|
||
|
|
LlmRequest? request,
|
||
|
|
Sent? sent,
|
||
|
|
int chunk = 7,
|
||
|
|
}) async {
|
||
|
|
final client = LlmClient(
|
||
|
|
httpClient: replying(fixture, sent: sent, chunk: chunk),
|
||
|
|
);
|
||
|
|
final events = <LlmEvent>[];
|
||
|
|
try {
|
||
|
|
await client
|
||
|
|
.send(endpoint ?? _openAi, request ?? _question)
|
||
|
|
.forEach(events.add);
|
||
|
|
} on LlmFailure catch (failure) {
|
||
|
|
return (events, failure);
|
||
|
|
}
|
||
|
|
return (events, null);
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<Map<String, Object?>> _bodyFor(
|
||
|
|
LlmEndpoint endpoint, [
|
||
|
|
LlmRequest? request,
|
||
|
|
]) async {
|
||
|
|
final sent = Sent();
|
||
|
|
await _reply(
|
||
|
|
_documented['text']!,
|
||
|
|
endpoint: endpoint,
|
||
|
|
request: request,
|
||
|
|
sent: sent,
|
||
|
|
);
|
||
|
|
return sent.body;
|
||
|
|
}
|
||
|
|
|
||
|
|
String _text(List<LlmEvent> events) =>
|
||
|
|
events.whereType<LlmTextDelta>().map((delta) => delta.text).join();
|
||
|
|
|
||
|
|
Fixture _stream(String chunks) => Fixture.parse(
|
||
|
|
'inline',
|
||
|
|
Uint8List.fromList(
|
||
|
|
utf8.encode('HTTP 200\ncontent-type: text/event-stream\n\n$chunks'),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
|
||
|
|
void main() {
|
||
|
|
group('the request', () {
|
||
|
|
test(
|
||
|
|
'goes to the chat completions with the key as a bearer token',
|
||
|
|
() async {
|
||
|
|
final sent = Sent();
|
||
|
|
await _reply(_documented['text']!, sent: sent);
|
||
|
|
expect(sent.request.method, 'POST');
|
||
|
|
expect(
|
||
|
|
sent.request.url,
|
||
|
|
Uri.parse('https://api.openai.com/v1/chat/completions'),
|
||
|
|
);
|
||
|
|
expect(
|
||
|
|
sent.request.headers,
|
||
|
|
containsPair('authorization', 'Bearer sk-test'),
|
||
|
|
);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
test('to a local model without a key, carries none', () async {
|
||
|
|
final sent = Sent();
|
||
|
|
await _reply(_documented['text']!, endpoint: _ollama, sent: sent);
|
||
|
|
expect(
|
||
|
|
sent.request.url,
|
||
|
|
Uri.parse('http://localhost:11434/v1/chat/completions'),
|
||
|
|
);
|
||
|
|
expect(sent.request.headers.keys, isNot(contains('authorization')));
|
||
|
|
});
|
||
|
|
|
||
|
|
test('to OpenAI: the ceiling its reasoning models take, usage asked for, '
|
||
|
|
'nothing stored, the opaque ID as safety_identifier', () async {
|
||
|
|
expect(await _bodyFor(_openAi), {
|
||
|
|
'model': 'the-model',
|
||
|
|
'messages': [
|
||
|
|
{'role': 'system', 'content': 'Du hilfst beim Japanischlernen.'},
|
||
|
|
{'role': 'user', 'content': 'Was heißt „Hund“ auf Japanisch?'},
|
||
|
|
],
|
||
|
|
'max_completion_tokens': 4096,
|
||
|
|
'stream': true,
|
||
|
|
'stream_options': {'include_usage': true},
|
||
|
|
'store': false,
|
||
|
|
'safety_identifier': 'p_8Zq3',
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
test('to Mistral: only fields its strict schema has', () async {
|
||
|
|
final body = await _bodyFor(_mistral);
|
||
|
|
expect(
|
||
|
|
body.keys,
|
||
|
|
unorderedEquals(['model', 'messages', 'max_tokens', 'stream']),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('to Gemini: no store and no ID, which it refuses', () async {
|
||
|
|
final body = await _bodyFor(_gemini);
|
||
|
|
expect(
|
||
|
|
body.keys,
|
||
|
|
unorderedEquals([
|
||
|
|
'model',
|
||
|
|
'messages',
|
||
|
|
'max_completion_tokens',
|
||
|
|
'stream',
|
||
|
|
'stream_options',
|
||
|
|
]),
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('to OpenRouter: the ID as user, which it hashes on', () async {
|
||
|
|
final body = await _bodyFor(_openRouter);
|
||
|
|
expect(
|
||
|
|
body.keys,
|
||
|
|
unorderedEquals([
|
||
|
|
'model',
|
||
|
|
'messages',
|
||
|
|
'max_completion_tokens',
|
||
|
|
'stream',
|
||
|
|
'user',
|
||
|
|
]),
|
||
|
|
);
|
||
|
|
expect(body['user'], 'p_8Zq3');
|
||
|
|
});
|
||
|
|
|
||
|
|
test(
|
||
|
|
'to any other host: what Ollama, vLLM and llama.cpp all take',
|
||
|
|
() async {
|
||
|
|
final body = await _bodyFor(_ollama);
|
||
|
|
expect(
|
||
|
|
body.keys,
|
||
|
|
unorderedEquals([
|
||
|
|
'model',
|
||
|
|
'messages',
|
||
|
|
'max_tokens',
|
||
|
|
'stream',
|
||
|
|
'stream_options',
|
||
|
|
]),
|
||
|
|
);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
final conversation = LlmRequest(
|
||
|
|
messages: [
|
||
|
|
LlmMessage.user([
|
||
|
|
const LlmText('Was steht da? '),
|
||
|
|
LlmImage(
|
||
|
|
mediaType: 'image/png',
|
||
|
|
bytes: Uint8List.fromList([1, 2, 3]),
|
||
|
|
),
|
||
|
|
]),
|
||
|
|
LlmMessage.assistant([
|
||
|
|
const LlmProviderBlock(
|
||
|
|
protocol: LlmProtocol.anthropic,
|
||
|
|
block: {'type': 'thinking'},
|
||
|
|
),
|
||
|
|
const LlmToolCall(
|
||
|
|
id: 'call_1',
|
||
|
|
name: 'find_cards',
|
||
|
|
input: {'query': '犬'},
|
||
|
|
extra: {
|
||
|
|
'extra_content': {
|
||
|
|
'google': {'thought_signature': 'CiQB'},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
),
|
||
|
|
const LlmToolCall(id: 'call_2', name: 'find_cards', input: {}),
|
||
|
|
]),
|
||
|
|
LlmMessage.user([
|
||
|
|
const LlmToolResult(callId: 'call_1', content: '[]'),
|
||
|
|
const LlmToolResult(
|
||
|
|
callId: 'call_2',
|
||
|
|
content: 'Kein Studienset',
|
||
|
|
isError: true,
|
||
|
|
),
|
||
|
|
const LlmText('Und?'),
|
||
|
|
]),
|
||
|
|
LlmMessage.assistant([const LlmText('')]),
|
||
|
|
],
|
||
|
|
tools: const [
|
||
|
|
LlmTool(
|
||
|
|
name: 'find_cards',
|
||
|
|
description: 'Finds cards.',
|
||
|
|
inputSchema: {'type': 'object'},
|
||
|
|
),
|
||
|
|
],
|
||
|
|
maxOutputTokens: 100,
|
||
|
|
);
|
||
|
|
|
||
|
|
test('holds images as data URLs, calls without content, results as tool '
|
||
|
|
'messages before the text, and leaves out empty turns and other '
|
||
|
|
"protocols' blocks", () async {
|
||
|
|
final body = await _bodyFor(_openAi, conversation);
|
||
|
|
expect(body['messages'], [
|
||
|
|
{
|
||
|
|
'role': 'user',
|
||
|
|
'content': [
|
||
|
|
{'type': 'text', 'text': 'Was steht da? '},
|
||
|
|
{
|
||
|
|
'type': 'image_url',
|
||
|
|
'image_url': {'url': 'data:image/png;base64,AQID'},
|
||
|
|
},
|
||
|
|
],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
'role': 'assistant',
|
||
|
|
'tool_calls': [
|
||
|
|
{
|
||
|
|
'id': 'call_1',
|
||
|
|
'type': 'function',
|
||
|
|
'function': {'name': 'find_cards', 'arguments': '{"query":"犬"}'},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
'id': 'call_2',
|
||
|
|
'type': 'function',
|
||
|
|
'function': {'name': 'find_cards', 'arguments': '{}'},
|
||
|
|
},
|
||
|
|
],
|
||
|
|
},
|
||
|
|
{'role': 'tool', 'tool_call_id': 'call_1', 'content': '[]'},
|
||
|
|
{
|
||
|
|
'role': 'tool',
|
||
|
|
'tool_call_id': 'call_2',
|
||
|
|
'content': 'Error: Kein Studienset',
|
||
|
|
},
|
||
|
|
{'role': 'user', 'content': 'Und?'},
|
||
|
|
]);
|
||
|
|
expect(body['tools'], [
|
||
|
|
{
|
||
|
|
'type': 'function',
|
||
|
|
'function': {
|
||
|
|
'name': 'find_cards',
|
||
|
|
'description': 'Finds cards.',
|
||
|
|
'parameters': {'type': 'object'},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('gives Gemini its thought signature back with the call', () async {
|
||
|
|
final body = await _bodyFor(_gemini, conversation);
|
||
|
|
final calls =
|
||
|
|
((body['messages']! as List)[1] as Map)['tool_calls'] as List;
|
||
|
|
expect((calls.first as Map)['extra_content'], {
|
||
|
|
'google': {'thought_signature': 'CiQB'},
|
||
|
|
});
|
||
|
|
expect((calls.last as Map).keys, isNot(contains('extra_content')));
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
group('replies', () {
|
||
|
|
for (final chunk in [1, 7, 4096]) {
|
||
|
|
test("OpenAI's text streams as written, in pieces of $chunk bytes, its "
|
||
|
|
'usage from the last chunk', () async {
|
||
|
|
final (events, failure) = await _reply(
|
||
|
|
_documented['text']!,
|
||
|
|
chunk: chunk,
|
||
|
|
);
|
||
|
|
expect(failure, isNull);
|
||
|
|
expect(_text(events), '„Hund“ heißt auf Japanisch 犬 (いぬ, inu).');
|
||
|
|
final finished = events.last as LlmFinished;
|
||
|
|
expect(finished.stop, LlmStop.end);
|
||
|
|
expect(
|
||
|
|
finished.usage,
|
||
|
|
const LlmUsage(inputTokens: 412, outputTokens: 38),
|
||
|
|
);
|
||
|
|
expect(finished.message.parts, [isA<LlmText>()]);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
test("OpenAI's tool calls come in pieces, put together by index", () async {
|
||
|
|
final (events, failure) = await _reply(_documented['tool']!);
|
||
|
|
expect(failure, isNull);
|
||
|
|
final finished = events.last as LlmFinished;
|
||
|
|
expect(finished.stop, LlmStop.toolCalls);
|
||
|
|
expect(
|
||
|
|
finished.usage,
|
||
|
|
const LlmUsage(inputTokens: 472, outputTokens: 89),
|
||
|
|
);
|
||
|
|
expect(finished.message.parts, everyElement(isA<LlmToolCall>()));
|
||
|
|
final [first, second] = finished.message.toolCalls;
|
||
|
|
expect(first.id, 'call_DdmO9pD3xa9XTPNJ32zg2hcA');
|
||
|
|
expect(first.name, 'find_cards');
|
||
|
|
expect(first.input, {'query': 'Hund', 'limit': 5});
|
||
|
|
expect(second.id, 'call_7Hq2ZtX0aP4kLm9sB1cD3eF5');
|
||
|
|
expect(second.input, {'query': '犬'});
|
||
|
|
});
|
||
|
|
|
||
|
|
test("Mistral's calls come whole, with usage on the last choice", () async {
|
||
|
|
final (events, failure) = await _reply(
|
||
|
|
_documented['mistral_tool']!,
|
||
|
|
endpoint: _mistral,
|
||
|
|
);
|
||
|
|
expect(failure, isNull);
|
||
|
|
final finished = events.last as LlmFinished;
|
||
|
|
expect(finished.stop, LlmStop.toolCalls);
|
||
|
|
expect(
|
||
|
|
finished.usage,
|
||
|
|
const LlmUsage(inputTokens: 472, outputTokens: 89),
|
||
|
|
);
|
||
|
|
final [first, second] = finished.message.toolCalls;
|
||
|
|
expect(first.id, 'D681PevKs');
|
||
|
|
expect(first.input, {'query': 'Hund', 'limit': 5});
|
||
|
|
expect(second.id, 'Qk2Ty7Wb1');
|
||
|
|
expect(second.input, {'query': '犬'});
|
||
|
|
});
|
||
|
|
|
||
|
|
test("Gemini's call comes whole without an index, ends with stop, keeps "
|
||
|
|
'its thought signature, and counts its thinking as output', () async {
|
||
|
|
final (events, failure) = await _reply(
|
||
|
|
_documented['gemini_tool']!,
|
||
|
|
endpoint: _gemini,
|
||
|
|
);
|
||
|
|
expect(failure, isNull);
|
||
|
|
expect(_text(events), 'Ich sehe nach.');
|
||
|
|
final finished = events.last as LlmFinished;
|
||
|
|
expect(finished.stop, LlmStop.toolCalls);
|
||
|
|
expect(
|
||
|
|
finished.usage,
|
||
|
|
const LlmUsage(inputTokens: 472, outputTokens: 89),
|
||
|
|
);
|
||
|
|
final call = finished.message.toolCalls.single;
|
||
|
|
expect(call.id, 'function-call-1');
|
||
|
|
expect(call.extra, {
|
||
|
|
'extra_content': {
|
||
|
|
'google': {'thought_signature': 'CiQBcsjafQ=='},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
test("Ollama's lost error (a blank chunk, then the end) fails as "
|
||
|
|
'malformed', () async {
|
||
|
|
final (events, failure) = await _reply(
|
||
|
|
_documented['ollama_lost_error']!,
|
||
|
|
endpoint: _ollama,
|
||
|
|
);
|
||
|
|
expect(_text(events), '„Hund“');
|
||
|
|
expect(failure!.kind, LlmFailureKind.malformed);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("llama.cpp's error chunk fails the answer with its words", () async {
|
||
|
|
final (_, failure) = await _reply(
|
||
|
|
_documented['llamacpp_error']!,
|
||
|
|
endpoint: _ollama,
|
||
|
|
);
|
||
|
|
expect(failure!.kind, LlmFailureKind.provider);
|
||
|
|
expect(failure.detail, 'the request exceeds the available context size');
|
||
|
|
});
|
||
|
|
|
||
|
|
test("OpenRouter's comments are passed over and its error chunk fails the "
|
||
|
|
'answer', () async {
|
||
|
|
final (events, failure) = await _reply(
|
||
|
|
_documented['openrouter_error']!,
|
||
|
|
endpoint: _openRouter,
|
||
|
|
);
|
||
|
|
expect(_text(events), '„Hund“');
|
||
|
|
expect(failure!.kind, LlmFailureKind.provider);
|
||
|
|
expect(failure.detail, 'Provider disconnected');
|
||
|
|
});
|
||
|
|
|
||
|
|
final statuses = {
|
||
|
|
'status_400': (LlmFailureKind.request, null),
|
||
|
|
'status_401': (LlmFailureKind.key, null),
|
||
|
|
'status_429': (LlmFailureKind.rateLimited, const Duration(seconds: 20)),
|
||
|
|
'status_429_quota': (LlmFailureKind.key, null),
|
||
|
|
'mistral_status_422': (LlmFailureKind.request, null),
|
||
|
|
'gemini_status_400': (LlmFailureKind.request, null),
|
||
|
|
};
|
||
|
|
for (final MapEntry(key: name, value: (kind, retryAfter))
|
||
|
|
in statuses.entries) {
|
||
|
|
test('$name fails as ${kind.name}', () async {
|
||
|
|
final (events, failure) = await _reply(_documented[name]!);
|
||
|
|
expect(events, isEmpty);
|
||
|
|
expect(failure!.kind, kind);
|
||
|
|
expect(failure.retryAfter, retryAfter);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
test("keeps OpenAI's words but the key", () async {
|
||
|
|
final (_, failure) = await _reply(_documented['status_401']!);
|
||
|
|
expect(failure!.detail, startsWith('Incorrect API key provided: sk-…'));
|
||
|
|
expect(failure.detail, isNot(contains('wxyz')));
|
||
|
|
});
|
||
|
|
|
||
|
|
test("keeps of Mistral's refusal the field and why, never the value it "
|
||
|
|
'quotes', () async {
|
||
|
|
final (_, failure) = await _reply(_documented['mistral_status_422']!);
|
||
|
|
expect(
|
||
|
|
failure!.detail,
|
||
|
|
'body.messages.0.content: Extra inputs are not permitted',
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("reads Gemini's error in a list", () async {
|
||
|
|
final (_, failure) = await _reply(_documented['gemini_status_400']!);
|
||
|
|
expect(failure!.detail, startsWith('Invalid JSON payload received.'));
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
group('replies off the documentation', () {
|
||
|
|
String chunk(Map<String, Object?> delta, {String? finish, Object? extra}) =>
|
||
|
|
'data: ${jsonEncode({
|
||
|
|
'choices': [
|
||
|
|
{'index': 0, 'delta': delta, 'finish_reason': finish},
|
||
|
|
],
|
||
|
|
...?extra as Map<String, Object?>?,
|
||
|
|
})}\n\n';
|
||
|
|
|
||
|
|
test(
|
||
|
|
'an answer that ends with its finish reason but no [DONE] stands',
|
||
|
|
() async {
|
||
|
|
final (events, failure) = await _reply(
|
||
|
|
_stream('${chunk({'content': 'Hi'})}${chunk({}, finish: 'stop')}'),
|
||
|
|
);
|
||
|
|
expect(failure, isNull);
|
||
|
|
expect((events.last as LlmFinished).message.text, 'Hi');
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
test("a call whose ID is Mistral's default 'null' gets one, and arguments "
|
||
|
|
'as an object are taken', () async {
|
||
|
|
final (events, _) = await _reply(
|
||
|
|
_stream(
|
||
|
|
'${chunk({
|
||
|
|
'tool_calls': [
|
||
|
|
{
|
||
|
|
'id': 'null',
|
||
|
|
'function': {
|
||
|
|
'name': 'find_cards',
|
||
|
|
'arguments': {'query': 'Hund'},
|
||
|
|
},
|
||
|
|
},
|
||
|
|
],
|
||
|
|
}, finish: 'tool_calls')}data: [DONE]\n\n',
|
||
|
|
),
|
||
|
|
);
|
||
|
|
final call = (events.last as LlmFinished).message.toolCalls.single;
|
||
|
|
expect(call.id, 'call_0');
|
||
|
|
expect(call.input, {'query': 'Hund'});
|
||
|
|
});
|
||
|
|
|
||
|
|
test('an index of -1 falls back to the ID', () async {
|
||
|
|
final (events, _) = await _reply(
|
||
|
|
_stream(
|
||
|
|
'${chunk({
|
||
|
|
'tool_calls': [
|
||
|
|
{
|
||
|
|
'index': -1,
|
||
|
|
'id': 'a',
|
||
|
|
'function': {'name': 'n', 'arguments': '{"x":'},
|
||
|
|
},
|
||
|
|
],
|
||
|
|
})}${chunk({
|
||
|
|
'tool_calls': [
|
||
|
|
{
|
||
|
|
'index': -1,
|
||
|
|
'id': 'a',
|
||
|
|
'function': {'arguments': '1}'},
|
||
|
|
},
|
||
|
|
],
|
||
|
|
}, finish: 'tool_calls')}data: [DONE]\n\n',
|
||
|
|
),
|
||
|
|
);
|
||
|
|
expect((events.last as LlmFinished).message.toolCalls.single.input, {
|
||
|
|
'x': 1,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
test('a refusal is text, and the stop says so', () async {
|
||
|
|
final (events, _) = await _reply(
|
||
|
|
_stream(
|
||
|
|
'${chunk({'refusal': 'Dabei helfe ich nicht.'})}'
|
||
|
|
'${chunk({}, finish: 'content_filter')}data: [DONE]\n\n',
|
||
|
|
),
|
||
|
|
);
|
||
|
|
expect(_text(events), 'Dabei helfe ich nicht.');
|
||
|
|
expect((events.last as LlmFinished).stop, LlmStop.refused);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("Mistral's thinking parts are passed over, its text kept", () async {
|
||
|
|
final (events, _) = await _reply(
|
||
|
|
_stream(
|
||
|
|
'${chunk({
|
||
|
|
'content': [
|
||
|
|
{'type': 'thinking', 'thinking': <Object?>[]},
|
||
|
|
{'type': 'text', 'text': 'Hi'},
|
||
|
|
],
|
||
|
|
}, finish: 'stop')}data: [DONE]\n\n',
|
||
|
|
),
|
||
|
|
);
|
||
|
|
expect(_text(events), 'Hi');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('a length stop is maxTokens', () async {
|
||
|
|
final (events, _) = await _reply(
|
||
|
|
_stream(
|
||
|
|
'${chunk({'content': 'Hi'}, finish: 'length')}data: [DONE]\n\n',
|
||
|
|
),
|
||
|
|
);
|
||
|
|
expect((events.last as LlmFinished).stop, LlmStop.maxTokens);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('an error as a string in a chunk fails the answer', () async {
|
||
|
|
final (_, failure) = await _reply(
|
||
|
|
_stream('data: {"error":"model not loaded"}\n\n'),
|
||
|
|
);
|
||
|
|
expect(failure!.detail, 'model not loaded');
|
||
|
|
});
|
||
|
|
|
||
|
|
test("a call's arguments that aren't JSON fail as malformed", () async {
|
||
|
|
final (_, failure) = await _reply(
|
||
|
|
_stream(
|
||
|
|
'${chunk({
|
||
|
|
'tool_calls': [
|
||
|
|
{
|
||
|
|
'index': 0,
|
||
|
|
'id': 'a',
|
||
|
|
'function': {'name': 'n', 'arguments': '{"x":'},
|
||
|
|
},
|
||
|
|
],
|
||
|
|
}, finish: 'tool_calls')}data: [DONE]\n\n',
|
||
|
|
),
|
||
|
|
);
|
||
|
|
expect(failure!.kind, LlmFailureKind.malformed);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|