main
dart 261 lines 10 KB
Raw
1 import 'package:flutter_riverpod/flutter_riverpod.dart';
2
3 import '../../models/data/models/model_info.dart';
4
5 /// How hard a reasoning-capable model should think, sent as the server's
6 /// `reasoning.effort` / `reasoning_effort` fields when thinking is enabled.
7 ///
8 /// Ordered from lightest to heaviest. OpenRouter advertises a per-model
9 /// subset (e.g. `["minimal","low","medium","high","xhigh"]`) plus a default
10 /// and whether reasoning is mandatory; LM Studio advertises
11 /// `capabilities.reasoning.allowed_options` (e.g. `["off","on"]`,
12 /// `["low","medium","high","xhigh"]`) plus a default. Providers without
13 /// per-model data fall back to [low]/[medium]/[high] via [effortsForModel].
14 enum ReasoningEffort {
15 minimal,
16 low,
17 medium,
18 high,
19 xhigh,
20 max;
21
22 String get apiValue => name;
23
24 /// Parses an OpenRouter effort string. Falls back to [medium] for unknown
25 /// values so a request never sends an effort the enum can't represent.
26 factory ReasoningEffort.fromApiValue(String value) {
27 for (final e in ReasoningEffort.values) {
28 if (e.apiValue == value) return e;
29 }
30 return ReasoningEffort.medium;
31 }
32 }
33
34 /// Returns the effort levels to offer for a model, in lightest→heaviest
35 /// order. When [supported] is null/empty (provider didn't advertise efforts)
36 /// the classic low/medium/high set is returned so existing behaviour is
37 /// preserved. LM Studio `allowed_options` entries `off`/`on` are not efforts
38 /// and are filtered out; a binary `["off","on"]` model yields a single
39 /// placeholder (the picker should be hidden via [hasGranularReasoningChoice]
40 /// — the request layer sends literal `"on"` for those models).
41 List<ReasoningEffort> effortsForModel(List<String>? supported) {
42 if (supported == null || supported.isEmpty) {
43 return const [
44 ReasoningEffort.low,
45 ReasoningEffort.medium,
46 ReasoningEffort.high,
47 ];
48 }
49 final normalized = supported.map((e) => e.trim().toLowerCase()).toSet();
50 final list = ReasoningEffort.values
51 .where((e) => normalized.contains(e.apiValue))
52 .toList(growable: false);
53 if (list.isNotEmpty) return list;
54 if (normalized.contains('on')) {
55 // Binary on/off model (e.g. LM Studio ["off","on"]): no granular choice.
56 return const [ReasoningEffort.medium];
57 }
58 // Shouldn't happen (a model advertising only efforts we don't
59 // model), but fall back to the classic set rather than rendering nothing.
60 return const [
61 ReasoningEffort.low,
62 ReasoningEffort.medium,
63 ReasoningEffort.high,
64 ];
65 }
66
67 /// True when [supported] advertises at least one granular effort
68 /// (`minimal`/`low`/`medium`/`high`/`xhigh`/`max`). Binary LM Studio models
69 /// (`["off","on"]`) return false — the UI should hide the effort picker and
70 /// offer only the on/off toggle.
71 bool hasGranularReasoningChoice(List<String>? supported) {
72 if (supported == null || supported.isEmpty) return true;
73 final normalized = supported.map((e) => e.trim().toLowerCase()).toSet();
74 return ReasoningEffort.values.any((e) => normalized.contains(e.apiValue));
75 }
76
77 /// Resolves the effort to actually send for [model], snapping [current] to
78 /// the model's advertised default when [current] isn't supported. Used when
79 /// building request params so a stale UI config never produces an invalid
80 /// `reasoning_effort` (e.g. `low` for a model that only supports `high`).
81 /// Comparison is case-insensitive so LM Studio `allowed_options` (which may
82 /// include `off`/`on`) interoperate with OpenRouter `supported_efforts`.
83 /// Binary on/off models (no granular effort advertised) keep [current]
84 /// unchanged — the effort value is irrelevant there because the request
85 /// layer sends literal `"on"`.
86 ReasoningEffort resolveEffortForModel(
87 ModelInfo? model,
88 ReasoningEffort current,
89 ) {
90 final supported = model?.supportedReasoningEfforts;
91 if (supported == null || supported.isEmpty) return current;
92 final normalized = supported.map((e) => e.trim().toLowerCase()).toSet();
93 if (normalized.contains(current.apiValue)) return current;
94 if (!hasGranularReasoningChoice(supported)) return current;
95 final defaultVal = model?.defaultReasoningEffort?.trim().toLowerCase();
96 if (defaultVal != null && normalized.contains(defaultVal)) {
97 // Default may be "on"/"off" for LM Studio binary models; only snap to
98 // it when it is an actual effort value.
99 for (final e in ReasoningEffort.values) {
100 if (e.apiValue == defaultVal) return e;
101 }
102 }
103 // Pick the heaviest supported effort we model, else the first advertised.
104 for (final e in ReasoningEffort.values.reversed) {
105 if (normalized.contains(e.apiValue)) return e;
106 }
107 return ReasoningEffort.fromApiValue(supported.first);
108 }
109
110 /// Resolves the exact `think` value for Ollama's native `/api/chat`, or null
111 /// when the key should be omitted (model doesn't support reasoning or the
112 /// model can't run with thinking off).
113 ///
114 /// - [enabled] null (model doesn't support reasoning) → null (omit).
115 /// - Disabled + `off` advertised (or unknown caps) → `false`.
116 /// - Disabled + `off` NOT advertised (e.g. GPT-OSS `["low","medium","high"]`)
117 /// → null (omit). Sending `false` there is ignored, so omitting lets the
118 /// server use its default instead of implying an unsupported mode.
119 /// - Enabled → `true` for binary `["off","on"]` models; the granular level
120 /// (`"low"`, `"medium"`, `"high"`, `"max"`) when advertised;
121 /// `minimal`→`"low"` and `xhigh`→`"high"` normalization for Ollama, which
122 /// never advertises those two.
123 Object? resolveOllamaThinkValue({
124 required bool? enabled,
125 required ReasoningEffort effort,
126 List<String>? allowedOptions,
127 String? defaultOption,
128 }) {
129 if (enabled == null) return null;
130 final allowed = allowedOptions
131 ?.map((e) => e.trim().toLowerCase())
132 .where((e) => e.isNotEmpty)
133 .toSet();
134 final def = defaultOption?.trim().toLowerCase();
135
136 if (!enabled) {
137 if (allowed == null || allowed.isEmpty) return false;
138 if (allowed.contains('off')) return false;
139 return null;
140 }
141
142 if (allowed == null || allowed.isEmpty) return true;
143
144 const ollamaLevels = ['low', 'medium', 'high', 'max'];
145 final granularAllowed = ollamaLevels
146 .where((level) => allowed.contains(level))
147 .toList(growable: false);
148
149 if (granularAllowed.isEmpty) {
150 if (allowed.contains('on')) return true;
151 if (def != null && allowed.contains(def)) {
152 if (def == 'on') return true;
153 if (ollamaLevels.contains(def)) return def;
154 }
155 return true;
156 }
157
158 var candidate = effort.apiValue;
159 if (effort == ReasoningEffort.minimal) candidate = 'low';
160 if (effort == ReasoningEffort.xhigh) candidate = 'high';
161
162 if (allowed.contains(candidate)) return candidate;
163
164 if (def != null && granularAllowed.contains(def)) return def;
165 if (candidate == 'low') return granularAllowed.first;
166 return granularAllowed.last;
167 }
168
169 /// Resolves the exact `reasoning` string for LM Studio's native
170 /// `/api/v1/chat`, or null when the key should be omitted (server default).
171 ///
172 /// - [enabled] null (model doesn't support reasoning) → null (omit).
173 /// - Disabled + `off` advertised (or unknown caps) → `'off'`.
174 /// - Disabled + `off` NOT advertised (e.g. `meta/muse-glimmer` with
175 /// `["low","medium","high","xhigh"]`) → null (omit). Sending `'off'`
176 /// there yields HTTP 400, so omitting lets the server use its default
177 /// instead of failing the whole request (fixes #75 follow-up).
178 /// - Enabled → the granular effort when advertised; literal `'on'` for
179 /// binary `["off","on"]` models; `minimal`→`low` and `max`→`xhigh`
180 /// normalization for LM Studio, which never advertises those two.
181 String? resolveLmStudioReasoningValue({
182 required bool? enabled,
183 required ReasoningEffort effort,
184 List<String>? allowedOptions,
185 String? defaultOption,
186 }) {
187 if (enabled == null) return null;
188 final allowed = allowedOptions
189 ?.map((e) => e.trim().toLowerCase())
190 .where((e) => e.isNotEmpty)
191 .toSet();
192 final def = defaultOption?.trim().toLowerCase();
193
194 if (!enabled) {
195 if (allowed == null || allowed.isEmpty) return 'off';
196 if (allowed.contains('off')) return 'off';
197 return null;
198 }
199
200 // Map the two efforts LM Studio never advertises to the nearest value it
201 // does understand.
202 var candidate = effort.apiValue;
203 if (effort == ReasoningEffort.minimal) candidate = 'low';
204 if (effort == ReasoningEffort.max) candidate = 'xhigh';
205
206 if (allowed == null || allowed.isEmpty) return candidate;
207 if (allowed.contains(candidate)) return candidate;
208
209 final granularAllowed = ReasoningEffort.values
210 .where((e) => allowed.contains(e.apiValue))
211 .toList(growable: false);
212
213 if (granularAllowed.isNotEmpty) {
214 // Snap out-of-range choices to the nearest advertised end; prefer the
215 // server default when it is granular.
216 if (candidate == 'low') return granularAllowed.first.apiValue;
217 if (candidate == 'xhigh') return granularAllowed.last.apiValue;
218 if (def != null && def != 'on' && def != 'off' && allowed.contains(def)) {
219 return def;
220 }
221 // Rare non-contiguous case (e.g. medium requested, only low+high
222 // allowed): fall back to the heaviest, matching resolveEffortForModel.
223 return granularAllowed.last.apiValue;
224 }
225
226 if (allowed.contains('on')) return 'on';
227 if (def != null && allowed.contains(def)) return def;
228 return null;
229 }
230
231 class ChatReasoningConfig {
232 const ChatReasoningConfig({
233 this.enabled = true,
234 this.effort = ReasoningEffort.low,
235 });
236
237 final bool enabled;
238 final ReasoningEffort effort;
239
240 ChatReasoningConfig copyWith({bool? enabled, ReasoningEffort? effort}) {
241 return ChatReasoningConfig(
242 enabled: enabled ?? this.enabled,
243 effort: effort ?? this.effort,
244 );
245 }
246 }
247
248 class ChatReasoningConfigNotifier extends Notifier<ChatReasoningConfig> {
249 @override
250 ChatReasoningConfig build() => const ChatReasoningConfig();
251
252 void setEnabled(bool enabled) => state = state.copyWith(enabled: enabled);
253
254 void setEffort(ReasoningEffort effort) =>
255 state = state.copyWith(effort: effort);
256 }
257
258 final chatReasoningConfigProvider =
259 NotifierProvider<ChatReasoningConfigNotifier, ChatReasoningConfig>(() {
260 return ChatReasoningConfigNotifier();
261 });