complete method
Asks the model to map prompt to an action.
context is the project-specific brief: the available task and job keys
plus the command reference. Throws AiException on transport or protocol
failure.
Implementation
@override
Future<AiReply> complete({
required String context,
required String prompt,
}) async {
final body = await post(
'${config.baseUrl}/v1/messages',
{
'model': config.model,
'max_tokens': config.maxTokens,
// Deliberately no `output_config`/`effort` here. It would suit this
// task — mapping one sentence to one command needs little reasoning —
// but it is rejected outright by older Claude models, and the model is
// the user's choice. Correctness across the range beats the saving.
'system': context,
'messages': [
{'role': 'user', 'content': prompt},
],
'tools': [
{
'name': AiProvider.toolName,
'description': AiProvider.toolDescription,
'input_schema': AiProvider.toolSchema,
},
],
},
{
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
if (config.hasApiKey) 'x-api-key': config.apiKey,
},
);
// Safety classifiers can decline a request with a successful HTTP 200, so
// the stop reason has to be checked before reading any content.
if (body['stop_reason'] == 'refusal') {
final details = body['stop_details'];
final category = details is Map ? details['category']?.toString() : null;
return AiReply(
refusal: category == null
? 'the model declined this request'
: 'the model declined this request ($category)',
);
}
// A response cut short by the token ceiling still arrives as HTTP 200 with
// a `tool_use` block, but its `input` is whatever had been emitted so far.
// Acting on it means running a command the model never finished choosing —
// a half-written `{"command":"run"}` is a full-configuration run.
final truncated = body['stop_reason'] == 'max_tokens';
final content = body['content'];
if (content is! List) {
throw AiException('$name returned no content');
}
final buffer = StringBuffer();
for (final block in content) {
if (block is! Map) continue;
switch (block['type']) {
case 'tool_use':
final input = block['input'];
if (input is Map) {
if (truncated) {
throw AiException(
'the reply was cut off by the token limit, so the command it '
'was choosing is incomplete — raise `max-tokens` (currently '
'${config.maxTokens}) and try again',
);
}
return AiReply(
action: AiAction.fromJson(
Map<String, dynamic>.from(input),
));
}
case 'text':
buffer.write(block['text'] ?? '');
}
}
final text = buffer.toString().trim();
return AiReply(text: text.isEmpty ? null : text);
}