conduit/test/features/chat/providers/reasoning_effort_provider_test.dart
cogwheel 4e73c9a0ea
Some checks are pending
L10n / l10n (push) Waiting to run
Fix ten reported issues and vendor Hermes Agent as an API reference (#725)
* Fix ten reported issues and vendor Hermes Agent as an API reference

Closes #693, #695, #699, #700, #701, #703, #708, #712, #716, #723.

- Hermes (#693, #699): load transcripts over REST even when session.resume
  fails, page at 200 messages and shrink on oversized responses, and show
  distinct errors for too-large, not-found, and RPC failures. Add the
  hermes-src submodule (NousResearch/hermes-agent v2026.9.11) as the RPC and
  REST contract reference.
- Server version banner (#695): dismissible, remembered per server and version.
- Model selector (#700): overflow fade and selection tint use the card surface.
- OpenRouter (#701): skip Batch API-only ":batch" model variants.
- Persisted chat content (#703): store plain output text instead of rendered
  <details> markup when structured output exists, matching the web client.
- Direct connections (#708): probes report their real failure instead of a
  generic message, the Open WebUI logout fence no longer blocks Direct setup,
  and 401/403 under the api-key header mode hints at Bearer auth.
- Reasoning effort (#712): per-server, per-model user picks override the
  model's configured default and are sent explicitly in the request.
- Voice calls (#716): stop the record plugin from clearing the Android
  communication device and verify the loudspeaker route before reporting it.
- Voice mode (#723): finalize speech on the responseDone marker.
- Add the five new strings to every locale.

* Address review: keep incomplete-clear block, pass effort on sends, keep all dismissals

- Direct profiles stay hidden and unusable after an incomplete full app-data
  clear, in memory and across restarts, until the logout fence is cleared by a
  completed cleanup or a new authenticated session. Ordinary failed sign-outs
  still do not block Direct setup.
- The primary Open WebUI send path now passes the resolved per-model reasoning
  effort like regeneration and queued completions already did.
- Server version warning dismissals are stored as a bounded set so dismissing
  one server no longer forgets another.

* Address review: release rejected speaker device, speak settled text on responseDone

- When Android accepts the built-in speaker but the read-back disagrees,
  clear that selection before the legacy setSpeakerphoneOn fallback, which is
  ignored while a communication device stays selected.
- Voice mode takes the final text from the message once responseDone is set;
  the streaming content provider can lag behind the flushed message.

* Address review: await the incomplete-clear restart marker before returning

The marker is written through the app-data-clear barrier with a checked write
and awaited by the clear coordinator, so a process death right after an
incomplete clear cannot reload the surviving Direct profiles on restart.

* Address review: arm the restart fence before the wipe and propagate marker failures

The incomplete-clear marker is now armed before any data is wiped, disarmed
only when the clear completes or yields ownership, re-asserted on an
incomplete outcome, and a failed checked write aborts the clear instead of
being swallowed. The in-memory block is unchanged.

* Do not raise an error banner for response.incomplete

Open WebUI forwards the provider's `response.incomplete` event over the
socket but treats it as a no-op: the output is kept and the turn still ends
with a normal `done`. Raising an error on that event flashed "The response
stopped before it was complete" under the answer until `done` cleared it.
`response.failed` still surfaces the provider error.
2026-09-15 00:48:03 +05:30

846 lines
29 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:checks/checks.dart';
import 'package:conduit/core/models/model.dart';
import 'package:conduit/core/models/server_config.dart';
import 'package:conduit/core/models/server_user_settings.dart';
import 'package:conduit/core/persistence/persistence_keys.dart';
import 'package:conduit/core/persistence/preferences_store.dart';
import 'package:conduit/core/providers/app_providers.dart';
import 'package:conduit/core/services/api_service.dart';
import 'package:conduit/core/services/worker_manager.dart';
import 'package:conduit/features/chat/providers/chat_providers.dart';
import 'package:conduit/features/chat/providers/reasoning_effort_provider.dart';
import 'package:conduit/features/direct_connections/models/direct_connection_profile.dart';
import 'package:conduit/features/direct_connections/models/direct_remote_model.dart';
import 'package:conduit/features/direct_connections/models/ollama_thinking.dart';
import 'package:conduit/features/direct_connections/models/openrouter_reasoning.dart';
import 'package:conduit/features/direct_connections/providers/direct_connection_providers.dart';
import 'package:conduit/features/direct_connections/services/direct_model_registry.dart';
import 'package:conduit/features/hermes/models/hermes_model.dart';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
final class _PendingProfiles extends DirectConnectionProfilesController {
_PendingProfiles(this.pending);
final Completer<List<DirectConnectionProfile>> pending;
final List<({String profileId, String modelId, String? setting})> writes = [];
@override
Future<List<DirectConnectionProfile>> build() => pending.future;
@override
Future<void> setOllamaThinking(
String profileId,
String remoteModelId,
OllamaThinkingSetting? setting,
) async {
writes.add((
profileId: profileId,
modelId: remoteModelId,
setting: setting?.storageValue,
));
}
}
final class _FixedPersonalizationSettings extends PersonalizationSettings {
_FixedPersonalizationSettings(this.settings);
final ServerUserSettings settings;
@override
Future<ServerUserSettings> build() async => settings;
}
final class _FixedSelectedModel extends SelectedModel {
_FixedSelectedModel(this.model);
final Model model;
@override
Model build() => model;
}
final class _ModelDetailsAdapter implements HttpClientAdapter {
_ModelDetailsAdapter({
this.failuresBeforeSuccess = 0,
this.holdSuccessfulResponse = true,
this.writeAccess = true,
this.includeEffort = true,
});
final int failuresBeforeSuccess;
final bool holdSuccessfulResponse;
final bool writeAccess;
final bool includeEffort;
int requestCount = 0;
final Completer<void> requested = Completer<void>();
final Completer<void> release = Completer<void>();
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) async {
requestCount++;
if (!requested.isCompleted) requested.complete();
check(options.method).equals('GET');
check(options.path).equals('/api/v1/models/model');
check(options.queryParameters)
.deepEquals(<String, dynamic>{'id': 'workspace-reasoning-model'});
if (requestCount <= failuresBeforeSuccess) {
return ResponseBody.fromString(
'{"detail":"temporary failure"}',
503,
headers: <String, List<String>>{
Headers.contentTypeHeader: <String>[Headers.jsonContentType],
},
);
}
if (holdSuccessfulResponse) await release.future;
return ResponseBody(
Stream<Uint8List>.value(
Uint8List.fromList(
utf8.encode(
jsonEncode(<String, dynamic>{
'id': 'workspace-reasoning-model',
'name': 'Workspace reasoning model',
'base_model_id': 'gpt-5',
'params': <String, dynamic>{
if (includeEffort) 'reasoning_effort': 'Vendor_Ultra',
},
'meta': <String, dynamic>{},
'is_active': true,
'write_access': writeAccess,
}),
),
),
),
200,
headers: <String, List<String>>{
Headers.contentTypeHeader: <String>[Headers.jsonContentType],
},
);
}
@override
void close({bool force = false}) {}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
PreferencesStore.debugReset();
SharedPreferences.setMockInitialValues(<String, Object>{});
PreferencesStore.debugOverride(await SharedPreferences.getInstance());
});
tearDown(PreferencesStore.debugReset);
test('explicit model effort does not follow the global selection', () async {
final profile = DirectConnectionProfile(
id: 'profile',
name: 'Provider',
adapterKey: kOpenAiCompatibleAdapterKey,
baseUrl: 'https://provider.example/v1',
);
final registry = DirectModelRegistry();
final models = registry.replaceProfileModels(profile, [
DirectRemoteModel(id: 'model-a'),
DirectRemoteModel(id: 'model-b'),
]);
final container = ProviderContainer(
overrides: [directModelRegistryProvider.overrideWithValue(registry)],
);
addTearDown(container.dispose);
container.read(selectedModelProvider.notifier).set(models.first);
await setReasoningEffortForModel(container.read, models.last, 'high');
check(reasoningEffortForModel(container.read, models.first))
.equals('automatic');
check(reasoningEffortForModel(container.read, models.last)).equals('high');
check(container.read(selectedModelProvider)).identicalTo(models.first);
});
ApiService buildApi(String serverId) => ApiService(
serverConfig: ServerConfig(
id: serverId,
name: 'Server $serverId',
url: 'https://$serverId.example.test',
),
workerManager: WorkerManager(),
);
test('server model default beats the global user effort without a pick', () {
const model = Model(
id: 'server-model',
name: 'Server model',
metadata: {
'info': {
'params': {'reasoning_effort': ' none '},
},
},
);
final container = ProviderContainer(
overrides: [
apiServiceProvider.overrideWithValue(buildApi('server-a')),
personalizationSettingsProvider.overrideWith(
() => _FixedPersonalizationSettings(
const ServerUserSettings(reasoningEffort: 'low'),
),
),
selectedModelProvider.overrideWith(() => _FixedSelectedModel(model)),
],
);
addTearDown(container.dispose);
check(container.read(reasoningEffortProvider)).equals('none');
check(reasoningEffortForModel(container.read, model)).equals('none');
});
test('user pick overrides the server model default per model', () async {
const model = Model(
id: 'server-model',
name: 'Server model',
metadata: {
'params': {'reasoning_effort': 'medium'},
},
);
const sibling = Model(
id: 'sibling-model',
name: 'Sibling model',
metadata: {
'params': {'reasoning_effort': 'medium'},
},
);
final container = ProviderContainer(
overrides: [
apiServiceProvider.overrideWithValue(buildApi('server-a')),
personalizationSettingsProvider.overrideWith(
() => _FixedPersonalizationSettings(
const ServerUserSettings(reasoningEffort: 'low'),
),
),
selectedModelProvider.overrideWith(() => _FixedSelectedModel(model)),
],
);
addTearDown(container.dispose);
const key = 'openwebui:server-a:server-model';
check(localReasoningEffortKeyForModel(container.read, model)).equals(key);
check(container.read(reasoningEffortProvider)).equals('medium');
await setReasoningEffortForModel(container.read, model, 'high');
check(container.read(localReasoningEffortsProvider)[key]).equals('high');
check(container.read(reasoningEffortProvider)).equals('high');
check(reasoningEffortForModel(container.read, model)).equals('high');
check(reasoningEffortForModel(container.read, sibling)).equals('medium');
check(
jsonDecode(
PreferencesStore.getString(PreferenceKeys.reasoningEffortByModel)!,
),
).isA<Map>().deepEquals({key: 'high'});
// An explicit "automatic" pick is remembered and overrides the model
// default back to "let the server decide".
await setReasoningEffortForModel(container.read, model, 'automatic');
check(container.read(localReasoningEffortsProvider)[key])
.equals('automatic');
check(container.read(reasoningEffortProvider)).equals('automatic');
check(reasoningEffortForModel(container.read, model)).equals('automatic');
// Clearing the pick restores the model default.
await container.read(localReasoningEffortsProvider.notifier).set(key, null);
check(container.read(reasoningEffortProvider)).equals('medium');
check(reasoningEffortForModel(container.read, model)).equals('medium');
});
test('server model picks are namespaced by server id', () async {
const model = Model(id: 'gpt-5', name: 'GPT-5');
final serverA = ProviderContainer(
overrides: [apiServiceProvider.overrideWithValue(buildApi('server-a'))],
);
addTearDown(serverA.dispose);
final serverB = ProviderContainer(
overrides: [apiServiceProvider.overrideWithValue(buildApi('server-b'))],
);
addTearDown(serverB.dispose);
await setReasoningEffortForModel(serverA.read, model, 'high');
check(localReasoningEffortKeyForModel(serverA.read, model))
.equals('openwebui:server-a:gpt-5');
check(localReasoningEffortKeyForModel(serverB.read, model))
.equals('openwebui:server-b:gpt-5');
check(reasoningEffortForModel(serverA.read, model)).equals('high');
check(reasoningEffortForModel(serverB.read, model)).equals('automatic');
check(localReasoningEffortForModel(serverB.read, model)).isNull();
});
test('server model pick is not stored without an active server', () async {
const model = Model(id: 'gpt-5', name: 'GPT-5');
final container = ProviderContainer(
overrides: [apiServiceProvider.overrideWithValue(null)],
);
addTearDown(container.dispose);
check(localReasoningEffortKeyForModel(container.read, model)).isNull();
await setReasoningEffortForModel(container.read, model, 'high');
check(container.read(localReasoningEffortsProvider)).isEmpty();
});
test('server model preserves custom reasoning effort values', () {
const model = Model(
id: 'custom-server-model',
name: 'Custom server model',
metadata: {
'params': {'reasoning_effort': 'Vendor_Ultra'},
},
);
final container = ProviderContainer(
overrides: [apiServiceProvider.overrideWithValue(buildApi('server-a'))],
);
addTearDown(container.dispose);
container.read(selectedModelProvider.notifier).set(model);
check(modelConfiguredReasoningEffort(model)).equals('vendor_ultra');
check(container.read(reasoningEffortProvider)).equals('vendor_ultra');
});
test(
'workspace model detail restores effort stripped from the model catalog',
() async {
const model = Model(
id: 'workspace-reasoning-model',
name: 'Workspace reasoning model',
metadata: <String, dynamic>{
'info': <String, dynamic>{
'id': 'workspace-reasoning-model',
'user_id': 'owner',
'base_model_id': 'gpt-5',
'meta': <String, dynamic>{},
},
},
);
final adapter = _ModelDetailsAdapter();
final api = ApiService(
serverConfig: const ServerConfig(
id: 'reasoning-test',
name: 'Reasoning test',
url: 'https://example.test',
),
workerManager: WorkerManager(),
);
api.dio.httpClientAdapter = adapter;
api.dio.interceptors.clear();
final container = ProviderContainer(
overrides: [
apiServiceProvider.overrideWithValue(api),
personalizationSettingsProvider.overrideWith(
() => _FixedPersonalizationSettings(
const ServerUserSettings(reasoningEffort: 'low'),
),
),
selectedModelProvider.overrideWith(() => _FixedSelectedModel(model)),
],
);
addTearDown(container.dispose);
await container.read(personalizationSettingsProvider.future);
final subscription = container.listen<String?>(
configuredReasoningEffortProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(subscription.close);
await adapter.requested.future.timeout(const Duration(seconds: 1));
check(container.read(configuredReasoningEffortProvider)).isNull();
adapter.release.complete();
await container.read(serverModelReasoningEffortProvider(model).future);
check(adapter.requestCount).equals(1);
check(container.read(configuredReasoningEffortProvider))
.equals('vendor_ultra');
},
);
test('workspace model detail retries after a transient failure', () async {
const model = Model(
id: 'workspace-reasoning-model',
name: 'Workspace reasoning model',
metadata: <String, dynamic>{
'info': <String, dynamic>{
'id': 'workspace-reasoning-model',
'user_id': 'owner',
'base_model_id': 'gpt-5',
},
},
);
final adapter = _ModelDetailsAdapter(
failuresBeforeSuccess: 1,
holdSuccessfulResponse: false,
);
final api = ApiService(
serverConfig: const ServerConfig(
id: 'reasoning-retry-test',
name: 'Reasoning retry test',
url: 'https://example.test',
),
workerManager: WorkerManager(),
);
api.dio.httpClientAdapter = adapter;
api.dio.interceptors.clear();
final container = ProviderContainer(
overrides: [apiServiceProvider.overrideWithValue(api)],
);
addTearDown(container.dispose);
check(
(await container.read(serverModelReasoningEffortProvider(model).future))
.value,
).isNull();
await container.pump();
check(
(await container.read(serverModelReasoningEffortProvider(model).future))
.value,
).equals('vendor_ultra');
check(adapter.requestCount).equals(2);
});
test('read-only workspace params do not restore the user fallback', () async {
const model = Model(
id: 'workspace-reasoning-model',
name: 'Workspace reasoning model',
metadata: <String, dynamic>{
'info': <String, dynamic>{
'id': 'workspace-reasoning-model',
'user_id': 'owner',
'base_model_id': 'gpt-5',
},
},
);
final adapter = _ModelDetailsAdapter(
holdSuccessfulResponse: false,
writeAccess: false,
includeEffort: false,
);
final api = ApiService(
serverConfig: const ServerConfig(
id: 'reasoning-read-only-test',
name: 'Reasoning read-only test',
url: 'https://example.test',
),
workerManager: WorkerManager(),
);
api.dio.httpClientAdapter = adapter;
api.dio.interceptors.clear();
final container = ProviderContainer(
overrides: [
apiServiceProvider.overrideWithValue(api),
personalizationSettingsProvider.overrideWith(
() => _FixedPersonalizationSettings(
const ServerUserSettings(reasoningEffort: 'low'),
),
),
selectedModelProvider.overrideWith(() => _FixedSelectedModel(model)),
],
);
addTearDown(container.dispose);
await container.read(personalizationSettingsProvider.future);
final detail = await container.read(
serverModelReasoningEffortProvider(model).future,
);
check(detail.canUsePersonalizationFallback).isFalse();
check(container.read(configuredReasoningEffortProvider)).isNull();
check(container.read(reasoningEffortProvider)).equals('automatic');
});
test('server policy only exposes effort for supported models', () {
const unsupported = Model(id: 'gpt-4o', name: 'GPT-4o');
const explicitlyUnsupported = Model(
id: 'catalog-model',
name: 'Catalog model',
capabilities: <String, dynamic>{
'reasoning': <String, dynamic>{'supported_efforts': <String>[]},
},
);
const supported = Model(id: 'gpt-5', name: 'GPT-5');
final hermes = hermesSyntheticModel();
final container = ProviderContainer();
addTearDown(container.dispose);
check(reasoningEffortPolicyForModel(container.read, unsupported).visible)
.isFalse();
check(
reasoningEffortPolicyForModel(
container.read,
explicitlyUnsupported,
).visible,
).isFalse();
check(reasoningEffortPolicyForModel(container.read, supported).visible)
.isTrue();
check(reasoningEffortPolicyForModel(container.read, hermes).visible)
.isTrue();
});
test('chat payload omits effort for unsupported server models', () {
final api = ApiService(
serverConfig: const ServerConfig(
id: 'reasoning-test',
name: 'Reasoning test',
url: 'https://example.test',
),
workerManager: WorkerManager(),
);
final userSettings = <String, dynamic>{
'params': <String, dynamic>{
'reasoning_effort': 'medium',
'temperature': 0.3,
},
};
Map<String, dynamic> build(String model) =>
api.buildChatCompletionPayloadForTest(
messages: const <Map<String, dynamic>>[
<String, dynamic>{'role': 'user', 'content': 'Hello'},
],
model: model,
messageId: 'message-id',
sessionId: 'session-id',
modelItem: <String, dynamic>{'id': model, 'name': model},
userSettings: userSettings,
);
final unsupportedParams = build('gpt-4o')['params'] as Map<String, dynamic>;
final supportedParams = build('gpt-5')['params'] as Map<String, dynamic>;
check(unsupportedParams.containsKey('reasoning_effort')).isFalse();
check(unsupportedParams['temperature']).equals(0.3);
check(supportedParams['reasoning_effort']).equals('medium');
});
test('chat payload sends the per-model pick over the user default', () {
final api = ApiService(
serverConfig: const ServerConfig(
id: 'reasoning-test',
name: 'Reasoning test',
url: 'https://example.test',
),
workerManager: WorkerManager(),
);
Map<String, dynamic> params(
String model,
String? effort, {
bool configured = true,
}) =>
api.buildChatCompletionPayloadForTest(
messages: const <Map<String, dynamic>>[
<String, dynamic>{'role': 'user', 'content': 'Hello'},
],
model: model,
messageId: 'message-id',
sessionId: 'session-id',
modelItem: <String, dynamic>{
'id': model,
'name': model,
if (configured)
'params': <String, dynamic>{'reasoning_effort': 'medium'},
},
userSettings: <String, dynamic>{
'params': <String, dynamic>{'reasoning_effort': 'low'},
},
reasoningEffort: effort,
)['params']
as Map<String, dynamic>;
check(params('gpt-5', 'high')['reasoning_effort']).equals('high');
check(params('gpt-5', null)['reasoning_effort']).equals('low');
check(params('gpt-5', 'automatic').containsKey('reasoning_effort'))
.isFalse();
check(
params(
'gpt-4o',
'high',
configured: false,
).containsKey('reasoning_effort'),
).isFalse();
});
test('custom OpenWebUI model metadata retains supported effort', () {
final api = ApiService(
serverConfig: const ServerConfig(
id: 'reasoning-test',
name: 'Reasoning test',
url: 'https://example.test',
),
workerManager: WorkerManager(),
);
Map<String, dynamic> build(Model model, String effort) {
final modelItem = buildLocalModelItemForTest(model);
return api.buildChatCompletionPayloadForTest(
messages: const <Map<String, dynamic>>[
<String, dynamic>{'role': 'user', 'content': 'Hello'},
],
model: model.id,
messageId: 'message-id',
sessionId: 'session-id',
modelItem: modelItem,
userSettings: <String, dynamic>{
'params': <String, dynamic>{'reasoning_effort': effort},
},
);
}
final configuredModel = Model.fromJson(<String, dynamic>{
'id': 'custom-configured-model',
'name': 'Custom configured model',
'params': <String, dynamic>{'reasoning_effort': 'vendor_ultra'},
});
final aliasModel = Model.fromJson(<String, dynamic>{
'id': 'custom-gpt-alias',
'name': 'Custom GPT alias',
'base_model_id': 'gpt-5',
});
final configuredModelItem = buildLocalModelItemForTest(configuredModel);
final aliasModelItem = buildLocalModelItemForTest(aliasModel);
check(configuredModelItem['params'])
.isA<Map<String, dynamic>>()
.deepEquals(<String, dynamic>{'reasoning_effort': 'vendor_ultra'});
check(aliasModelItem['base_model_id']).equals('gpt-5');
check(
(build(configuredModel, 'vendor_ultra')['params']
as Map<String, dynamic>)['reasoning_effort'],
).equals('vendor_ultra');
check(
(build(aliasModel, 'high')['params']
as Map<String, dynamic>)['reasoning_effort'],
).equals('high');
});
test(
'malformed saved effort does not discard valid model settings',
() async {
await PreferencesStore.put(
PreferenceKeys.reasoningEffortByModel,
jsonEncode(<String, Object>{
'hermes:valid': 'high',
'hermes:invalid': 'not valid!',
}),
);
final container = ProviderContainer();
addTearDown(container.dispose);
check(container.read(localReasoningEffortsProvider))
.deepEquals({'hermes:valid': 'high'});
},
);
test('Ollama effort waits for profile hydration', () async {
final profile = DirectConnectionProfile(
id: 'ollama-profile',
name: 'Ollama',
adapterKey: kOllamaAdapterKey,
baseUrl: 'https://ollama.com',
);
final registry = DirectModelRegistry();
final model = registry.replaceProfileModels(profile, [
DirectRemoteModel(id: 'model-a'),
]).single;
final pending = Completer<List<DirectConnectionProfile>>();
final profiles = _PendingProfiles(pending);
final container = ProviderContainer(
overrides: [
directModelRegistryProvider.overrideWithValue(registry),
directConnectionProfilesProvider.overrideWith(() => profiles),
],
);
addTearDown(container.dispose);
container.read(selectedModelProvider.notifier).set(model);
check(container.read(configuredReasoningEffortProvider)).isNull();
check(container.read(reasoningEffortAllowsCustomProvider)).isFalse();
final policy = reasoningEffortPolicyForModel(container.read, model);
check(policy.restrictsValues).isTrue();
await check(
setReasoningEffortForModel(container.read, model, 'unsupported'),
).throws<FormatException>();
final write = setReasoningEffortForModel(container.read, model, 'high');
await Future<void>.delayed(Duration.zero);
check(container.read(localReasoningEffortsProvider)).isEmpty();
check(profiles.writes).isEmpty();
pending.complete([profile]);
await write;
check(profiles.writes).deepEquals([
(profileId: 'ollama-profile', modelId: 'model-a', setting: 'high'),
]);
});
test('Ollama profile hydration failure propagates to the caller', () async {
final profile = DirectConnectionProfile(
id: 'ollama-profile',
name: 'Ollama',
adapterKey: kOllamaAdapterKey,
baseUrl: 'https://ollama.com',
);
final registry = DirectModelRegistry();
final model = registry.replaceProfileModels(profile, [
DirectRemoteModel(id: 'model-a'),
]).single;
final pending = Completer<List<DirectConnectionProfile>>();
final profiles = _PendingProfiles(pending);
final container = ProviderContainer(
overrides: [
directModelRegistryProvider.overrideWithValue(registry),
directConnectionProfilesProvider.overrideWith(() => profiles),
],
);
addTearDown(container.dispose);
final write = setReasoningEffortForModel(container.read, model, 'high');
pending.completeError(StateError('profile storage unavailable'));
await check(write).throws<StateError>();
check(container.read(localReasoningEffortsProvider)).isEmpty();
check(profiles.writes).isEmpty();
});
test('OpenRouter policy follows supported efforts and mandatory mode', () {
final profile = DirectConnectionProfile(
id: 'openrouter-profile',
name: 'OpenRouter',
adapterKey: kOpenAiCompatibleAdapterKey,
baseUrl: kOpenRouterApiBaseUrl,
);
final registry = DirectModelRegistry();
final model = registry.replaceProfileModels(profile, [
DirectRemoteModel(
id: 'model',
capabilities: const {
'reasoning': {
'supported_efforts': ['high', 'minimal', 'none'],
'default_effort': 'minimal',
'mandatory': true,
},
},
),
]).single;
final container = ProviderContainer(
overrides: [directModelRegistryProvider.overrideWithValue(registry)],
);
addTearDown(container.dispose);
final policy = reasoningEffortPolicyForModel(container.read, model);
check(policy.visible).isTrue();
check(policy.allowsCustom).isFalse();
check(policy.restrictsValues).isTrue();
check(policy.options).deepEquals(['automatic', 'high', 'minimal']);
check(policy.accepts('none')).isFalse();
check(policy.effectiveConfiguredEffort('automatic')).isNull();
});
test('Apple reasoning controls stay exclusive to cloud models', () {
final registry = DirectModelRegistry();
final cloud = registry.replaceProfileModels(
DirectConnectionProfile.applePrivateCloudCompute(),
[DirectRemoteModel(id: kApplePccRemoteModelId)],
).single;
final onDevice = registry.replaceProfileModels(
DirectConnectionProfile.appleOnDevice(),
[DirectRemoteModel(id: kAppleOnDeviceRemoteModelId)],
).single;
final container = ProviderContainer(
overrides: [directModelRegistryProvider.overrideWithValue(registry)],
);
addTearDown(container.dispose);
check(reasoningEffortPolicyForModel(container.read, cloud).visible)
.isTrue();
check(reasoningEffortPolicyForModel(container.read, onDevice).visible)
.isFalse();
});
test('OpenRouter null supported efforts exposes all gateway levels', () {
final profile = DirectConnectionProfile(
id: 'openrouter-profile',
name: 'OpenRouter',
adapterKey: kOpenAiCompatibleAdapterKey,
baseUrl: kOpenRouterApiBaseUrl,
);
final registry = DirectModelRegistry();
final model = registry.replaceProfileModels(profile, [
DirectRemoteModel(
id: 'model',
capabilities: const {
'reasoning': {'supported_efforts': null, 'mandatory': false},
},
),
]).single;
final container = ProviderContainer(
overrides: [directModelRegistryProvider.overrideWithValue(registry)],
);
addTearDown(container.dispose);
final policy = reasoningEffortPolicyForModel(container.read, model);
check(policy.options)
.deepEquals(['automatic', ...kOpenRouterReasoningEfforts]);
});
test(
'OpenRouter hides absent reasoning and keeps stale preference stored',
() async {
final profile = DirectConnectionProfile(
id: 'openrouter-profile',
name: 'OpenRouter',
adapterKey: kOpenAiCompatibleAdapterKey,
baseUrl: kOpenRouterApiBaseUrl,
);
final registry = DirectModelRegistry();
final models = registry.replaceProfileModels(profile, [
DirectRemoteModel(id: 'without-reasoning'),
DirectRemoteModel(
id: 'limited',
capabilities: const {
'reasoning': {
'supported_efforts': ['high', 'minimal'],
'mandatory': false,
},
},
),
]);
final container = ProviderContainer(
overrides: [directModelRegistryProvider.overrideWithValue(registry)],
);
addTearDown(container.dispose);
const key = 'direct:openrouter-profile:limited';
await container
.read(localReasoningEffortsProvider.notifier)
.set(key, 'medium');
check(reasoningEffortPolicyForModel(container.read, models.first).visible)
.isFalse();
check(reasoningEffortForModel(container.read, models.last))
.equals('automatic');
check(container.read(localReasoningEffortsProvider)[key])
.equals('medium');
await check(
setReasoningEffortForModel(container.read, models.last, 'medium'),
).throws<FormatException>();
},
);
}