feat(llm): a request carries how much the model thinks, in each provider's field

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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-26 14:53:48 +01:00
commit 0590a4b43d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
14 changed files with 291 additions and 10 deletions

View file

@ -73,6 +73,15 @@ the local network, which may be shared.
phase in `LlmText.extra`. Send the `LlmFinished.message` back unchanged.
- **The person's opaque ID** (`LlmRequest.personId`) goes where the provider takes one:
Anthropic's `metadata.user_id`, OpenAI's `safety_identifier`, OpenRouter's `user`.
- **The effort** (`LlmRequest.effort`, `LlmEffort`: `low`, `medium`, `high`, `xhigh`,
`max`, Anthropic's names; null leaves it to the model) goes as Anthropic's
`output_config.effort`, the Responses API's and OpenRouter's `reasoning.effort`, and
`reasoning_effort` to OpenAI's chat completions, Gemini and any other host.
`endpoint.efforts` says which levels a host takes: Gemini's low to high, Mistral's
none, every one elsewhere; `send` throws an `ArgumentError` for another. Which levels a
model has is the model's, and its provider refuses one it lacks. The model's thinking
counts towards `maxOutputTokens`, so a higher effort wants a higher ceiling:
`LlmEffort.outputCeiling` (8,192 at low to 65,536 at max; 16,384 without an effort).
## Testing with it

View file

@ -6,7 +6,8 @@
/// calls. Nothing here keeps or logs what is asked or answered.
library;
export 'src/client.dart' show HttpLlmClient, LlmClient;
export 'src/client.dart' show EndpointEfforts, HttpLlmClient, LlmClient;
export 'src/conversation.dart';
export 'src/effort.dart';
export 'src/endpoint.dart';
export 'src/failure.dart';

View file

@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:halkyon_llm/src/client.dart';
import 'package:halkyon_llm/src/conversation.dart';
import 'package:halkyon_llm/src/effort.dart';
import 'package:halkyon_llm/src/endpoint.dart';
import 'package:halkyon_llm/src/failure.dart';
import 'package:halkyon_llm/src/sse.dart';
@ -14,7 +15,10 @@ import 'package:halkyon_llm/src/sse.dart';
/// they see fit and stream each thinking block empty but for its signature
/// (`display` defaults to `omitted` there), and older ones don't think. The
/// blocks come back as [LlmProviderBlock]s, which must be sent back
/// unchanged with the tools' results.
/// unchanged with the tools' results. How much they think is the request's
/// effort (`output_config.effort`,
/// https://platform.claude.com/docs/en/build-with-claude/effort), every
/// level taken.
final class AnthropicAdapter(
/// Where it sends.
final LlmEndpoint endpoint,
@ -28,6 +32,9 @@ final class AnthropicAdapter(
/// (a person's own key, which is theirs to expose).
static const _inBrowser = bool.fromEnvironment('dart.library.js_interop');
/// The levels of effort it takes: every one, Anthropic's being the names.
static final efforts = Set<LlmEffort>.unmodifiable(LlmEffort.values);
@override
Uri get url =>
endpoint.baseUrl.replace(path: '${endpoint.baseUrl.path}/v1/messages');
@ -61,6 +68,8 @@ final class AnthropicAdapter(
],
if (request.personId case final personId?)
'metadata': {'user_id': personId},
if (request.effort case final effort?)
'output_config': {'effort': effort.key},
};
/// A message's blocks: tools' results first, as Anthropic requires them

View file

@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:halkyon_llm/src/anthropic.dart';
import 'package:halkyon_llm/src/conversation.dart';
import 'package:halkyon_llm/src/effort.dart';
import 'package:halkyon_llm/src/endpoint.dart';
import 'package:halkyon_llm/src/failure.dart';
import 'package:halkyon_llm/src/openai.dart';
@ -18,7 +19,9 @@ abstract interface class LlmClient {
/// Sends [request] to [endpoint]: [LlmTextDelta]s as the model writes,
/// then [LlmFinished], or an [LlmFailure] as the stream's error. Cancelling
/// the subscription aborts the request.
/// the subscription aborts the request. Throws an [ArgumentError] for an
/// effort the endpoint takes not ([EndpointEfforts.efforts]), before
/// anything goes.
Stream<LlmEvent> send(LlmEndpoint endpoint, LlmRequest request);
/// Closes the HTTP client, where it is the client's own.
@ -42,6 +45,7 @@ final class HttpLlmClient({
@override
Stream<LlmEvent> send(LlmEndpoint endpoint, LlmRequest request) {
checkEffort(endpoint, request);
final ProtocolAdapter adapter = switch (endpoint.protocol) {
LlmProtocol.anthropic => AnthropicAdapter(endpoint),
LlmProtocol.openai => OpenAiAdapter(endpoint),
@ -175,6 +179,32 @@ final class HttpLlmClient({
}
}
/// What an [LlmEndpoint] takes, by its protocol and host.
extension EndpointEfforts on LlmEndpoint {
/// The levels of effort a request to it may carry ([LlmRequest.effort],
/// ADR 0021, D63): every one but at Gemini (low to high) and Mistral
/// (none). Which a model has is the model's: a level it lacks is refused
/// by its provider.
Set<LlmEffort> get efforts => switch (protocol) {
LlmProtocol.anthropic => AnthropicAdapter.efforts,
LlmProtocol.openai => OpenAiAdapter.effortsAt(host),
LlmProtocol.openaiResponses => OpenAiResponsesAdapter.efforts,
};
}
/// Throws an [ArgumentError] where [request]'s effort is one [endpoint]
/// takes not ([EndpointEfforts.efforts]): [LlmClient.send]'s first check.
void checkEffort(LlmEndpoint endpoint, LlmRequest request) {
if (request.effort case final effort?
when !endpoint.efforts.contains(effort)) {
throw ArgumentError.value(
effort.key,
'effort',
'Not taken at ${endpoint.host}',
);
}
}
/// What a protocol's adapter provides [HttpLlmClient] with.
abstract interface class ProtocolAdapter() {
/// Where requests go.

View file

@ -1,5 +1,6 @@
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';
@ -166,9 +167,14 @@ final class const LlmRequest({
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.
/// 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 = '',

View file

@ -0,0 +1,33 @@
/// How much a model thinks before it answers (ADR 0021, D63), from the least
/// to the most: Anthropic's levels, which OpenAI's fields and those
/// compatible with them take by the same names. More effort spends more
/// tokens, each at the same price.
enum LlmEffort(
/// Its name in settings and in the providers' fields.
final String key,
/// The ceiling on output tokens a request at it is meant to ask for: room
/// for the thinking it allows, which counts towards the ceiling, and for
/// the answer after it.
final int outputCeiling,
) {
low('low', 8192),
medium('medium', 12288),
high('high', 16384),
xhigh('xhigh', 32768),
max('max', 65536);
/// The ceiling a request without an effort is meant to ask for, as the
/// model's own default then thinks: high's, the default of most.
static const defaultOutputCeiling = 16384;
/// The level of this [key], or null.
static LlmEffort? named(String key) {
for (final effort in values) {
if (effort.key == key) {
return effort;
}
}
return null;
}
}

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:halkyon_llm/src/client.dart';
import 'package:halkyon_llm/src/conversation.dart';
import 'package:halkyon_llm/src/effort.dart';
import 'package:halkyon_llm/src/endpoint.dart';
import 'package:halkyon_llm/src/failure.dart';
import 'package:halkyon_llm/src/sse.dart';
@ -22,6 +23,9 @@ final class OpenAiAdapter(
) implements ProtocolAdapter {
late final _dialect = _Dialect.of(endpoint.host);
/// The levels of effort a request to [host] may carry (ADR 0021, D63).
static Set<LlmEffort> effortsAt(String host) => _Dialect.of(host).efforts;
@override
Uri get url => endpoint.baseUrl.replace(
path: '${endpoint.baseUrl.path}/chat/completions',
@ -59,6 +63,11 @@ final class OpenAiAdapter(
if (_dialect.storesUnlessTold) 'store': false,
if ((_dialect.personField, request.personId) case (final field?, final id?))
field: id,
if (request.effort case final effort?)
if (_dialect.effortNested)
'reasoning': {'effort': effort.key}
else
'reasoning_effort': effort.key,
};
/// A turn as chat completions hold it: the tools' results as messages of
@ -193,15 +202,44 @@ enum _Dialect(
/// takes one: OpenAI's `safety_identifier` (`user` is deprecated there),
/// OpenRouter's `user`.
final String? personField,
/// The levels of effort it takes (D63): Gemini's `reasoning_effort` low
/// to high (its `minimal` and `none` lie below Anthropic's levels),
/// Mistral's none (its schema has no such field, and refuses one).
final Set<LlmEffort> efforts,
/// Whether the effort goes as `reasoning: {effort}`, OpenRouter's field,
/// rather than as `reasoning_effort`, OpenAI's, which Gemini, Ollama and
/// vLLM take too.
final bool effortNested,
) {
openai('max_completion_tokens', true, true, 'safety_identifier'),
mistral('max_tokens', false, false, null),
gemini('max_completion_tokens', true, false, null),
openrouter('max_completion_tokens', false, false, 'user'),
openai(
'max_completion_tokens',
true,
true,
'safety_identifier',
_everyEffort,
false,
),
mistral('max_tokens', false, false, null, {}, false),
gemini('max_completion_tokens', true, false, null, {
LlmEffort.low,
LlmEffort.medium,
LlmEffort.high,
}, false),
openrouter('max_completion_tokens', false, false, 'user', _everyEffort, true),
/// Any other host: what Ollama, vLLM and llama.cpp all take, each ignoring
/// what it doesn't know.
generic('max_tokens', true, false, null);
generic('max_tokens', true, false, null, _everyEffort, false);
static const _everyEffort = {
LlmEffort.low,
LlmEffort.medium,
LlmEffort.high,
LlmEffort.xhigh,
LlmEffort.max,
};
static _Dialect of(String host) => switch (host.toLowerCase()) {
'api.openai.com' => openai,

View file

@ -4,6 +4,7 @@ import 'dart:math';
import 'package:halkyon_llm/src/client.dart';
import 'package:halkyon_llm/src/conversation.dart';
import 'package:halkyon_llm/src/effort.dart';
import 'package:halkyon_llm/src/endpoint.dart';
import 'package:halkyon_llm/src/failure.dart';
import 'package:halkyon_llm/src/openai.dart';
@ -18,7 +19,10 @@ import 'package:halkyon_llm/src/sse.dart';
/// the whole conversation, the model's reasoning with it as the encrypted
/// items it streamed, which come back as [LlmProviderBlock]s and go back in
/// their place. Each item is read once it is done, as the encrypted
/// reasoning may be incomplete before.
/// reasoning may be incomplete before. How much the model reasons is the
/// request's effort (`reasoning.effort`), every level taken: which a model
/// has is the model's (OpenAI's newest have `xhigh` and `max`), and a level
/// it lacks is refused by its provider.
final class OpenAiResponsesAdapter(
/// Where it sends.
final LlmEndpoint endpoint,
@ -26,6 +30,9 @@ final class OpenAiResponsesAdapter(
/// The least ceiling on output tokens the API takes.
static const _leastOutput = 16;
/// The levels of effort it takes: every one.
static final efforts = Set<LlmEffort>.unmodifiable(LlmEffort.values);
@override
Uri get url =>
endpoint.baseUrl.replace(path: '${endpoint.baseUrl.path}/responses');
@ -55,6 +62,7 @@ final class OpenAiResponsesAdapter(
},
],
'max_output_tokens': max(request.maxOutputTokens, _leastOutput),
if (request.effort case final effort?) 'reasoning': {'effort': effort.key},
'stream': true,
'store': false,
// What a stateless request has by default now; asked for still, as

View file

@ -6,6 +6,7 @@ import 'dart:async';
import 'dart:collection';
import 'package:halkyon_llm/halkyon_llm.dart';
import 'package:halkyon_llm/src/client.dart' show checkEffort;
/// An [LlmClient] answering each request with the next reply scripted.
final class FakeLlmClient() implements LlmClient {
@ -53,6 +54,7 @@ final class FakeLlmClient() implements LlmClient {
@override
Stream<LlmEvent> send(LlmEndpoint endpoint, LlmRequest request) {
checkEffort(endpoint, request);
sent.add((endpoint, request));
if (_replies.isEmpty) {
throw StateError('No reply scripted for request ${sent.length}');

View file

@ -139,6 +139,25 @@ void main() {
);
});
test("carries the effort in Anthropic's output_config (D63)", () async {
final sent = Sent();
await _reply(
_documented['text']!,
sent: sent,
request: LlmRequest(
messages: _question.messages,
maxOutputTokens: LlmEffort.xhigh.outputCeiling,
effort: LlmEffort.xhigh,
),
);
expect(sent.body['output_config'], {'effort': 'xhigh'});
expect(sent.body['max_tokens'], 32768);
});
test('takes every level', () {
expect(_endpoint.efforts, LlmEffort.values.toSet());
});
test('sends tools, images, tool calls, results first, and its own blocks '
'back unchanged', () async {
final sent = Sent();

View file

@ -52,4 +52,31 @@ void main() {
expect(_at(url).onThisMachine, isFalse, reason: url);
}
});
group('effort (D63)', () {
test('runs from low to max, by name', () {
expect(LlmEffort.values.map((effort) => effort.key), [
'low',
'medium',
'high',
'xhigh',
'max',
]);
expect(LlmEffort.named('xhigh'), LlmEffort.xhigh);
expect(LlmEffort.named('XHIGH'), isNull);
expect(LlmEffort.named('minimal'), isNull);
});
test('leaves more room for output the higher it is: what the model thinks '
'counts towards the ceiling', () {
expect(LlmEffort.values.map((effort) => effort.outputCeiling), [
8192,
12288,
16384,
32768,
65536,
]);
expect(LlmEffort.defaultOutputCeiling, LlmEffort.high.outputCeiling);
});
});
}

View file

@ -131,6 +131,19 @@ void main() {
expect(body['max_output_tokens'], 16);
});
test('carries the effort as reasoning.effort, every level (D63)', () async {
final body = await _bodyFor(
_openAi,
LlmRequest(
messages: _question.messages,
maxOutputTokens: 4096,
effort: LlmEffort.max,
),
);
expect(body['reasoning'], {'effort': 'max'});
expect(_openAi.efforts, LlmEffort.values.toSet());
});
final conversation = LlmRequest(
messages: [
LlmMessage.user([

View file

@ -157,6 +157,71 @@ void main() {
expect(body['user'], 'p_8Zq3');
});
group('the effort (D63)', () {
LlmRequest at(LlmEffort effort) => LlmRequest(
messages: _question.messages,
maxOutputTokens: 4096,
effort: effort,
);
test(
'goes as reasoning_effort to OpenAI, Gemini and any other host',
() async {
for (final endpoint in [_openAi, _gemini, _ollama]) {
final body = await _bodyFor(endpoint, at(LlmEffort.medium));
expect(body['reasoning_effort'], 'medium', reason: endpoint.host);
expect(body.keys, isNot(contains('reasoning')));
}
},
);
test('goes as reasoning.effort to OpenRouter', () async {
final body = await _bodyFor(_openRouter, at(LlmEffort.max));
expect(body['reasoning'], {'effort': 'max'});
expect(body.keys, isNot(contains('reasoning_effort')));
});
test('is left out without one', () async {
for (final endpoint in [_openAi, _gemini, _openRouter, _ollama]) {
final body = await _bodyFor(endpoint);
expect(
body.keys,
isNot(anyOf(contains('reasoning_effort'), contains('reasoning'))),
reason: endpoint.host,
);
}
});
test("is each host's levels: Gemini's low to high, Mistral's none, "
'every level elsewhere', () {
expect(_gemini.efforts, {
LlmEffort.low,
LlmEffort.medium,
LlmEffort.high,
});
expect(_mistral.efforts, isEmpty);
for (final endpoint in [_openAi, _openRouter, _ollama]) {
expect(
endpoint.efforts,
LlmEffort.values.toSet(),
reason: endpoint.host,
);
}
});
test('a level the host takes not is refused before anything goes', () {
final client = LlmClient(httpClient: replying(_documented['text']!));
expect(
() => client.send(_mistral, at(LlmEffort.low)),
throwsArgumentError,
);
expect(
() => client.send(_gemini, at(LlmEffort.xhigh)),
throwsArgumentError,
);
});
});
test(
'to any other host: what Ollama, vLLM and llama.cpp all take',
() async {

View file

@ -57,5 +57,26 @@ void main() {
throwsStateError,
);
});
test("refuses an effort the endpoint's host takes not, as HTTP's does", () {
final client = FakeLlmClient()..replyText('Hallo');
expect(
() => client.send(
LlmEndpoint(
protocol: LlmProtocol.openai,
model: 'm',
apiKey: 'k',
baseUrl: 'https://api.mistral.ai/v1',
),
LlmRequest(
messages: _request.messages,
maxOutputTokens: 10,
effort: LlmEffort.low,
),
),
throwsArgumentError,
);
expect(client.sent, isEmpty);
});
});
}