forked from microsoft/copilot-for-eclipse
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathModelUtils.java
More file actions
324 lines (299 loc) · 13 KB
/
Copy pathModelUtils.java
File metadata and controls
324 lines (299 loc) · 13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
package com.microsoft.copilot.eclipse.ui.utils;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.apache.commons.lang3.StringUtils;
import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel;
import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCapabilities;
import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCapabilitiesLimits;
import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCapabilitiesSupports;
import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotScope;
import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokModel;
import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokModelCapabilities;
import com.microsoft.copilot.eclipse.ui.i18n.Messages;
/**
* Utility class for model related operations.
*/
public class ModelUtils {
private ModelUtils() {
// Private constructor to prevent instantiation
}
/**
* Convert ByokModel to CopilotModel format for unified handling.
*/
public static CopilotModel convertByokModelToCopilotModel(ByokModel byokModel) {
CopilotModel copilotModel = new CopilotModel();
copilotModel.setId(byokModel.getModelId());
String modelName = byokModel.getModelCapabilities() != null ? byokModel.getModelCapabilities().getName()
: byokModel.getModelId();
copilotModel.setModelName(modelName);
copilotModel.setModelFamily(byokModel.getModelId());
copilotModel.setProviderName(byokModel.getProviderName());
List<String> scopes = new ArrayList<>();
scopes.add(CopilotScope.CHAT_PANEL);
if (byokModel.getModelCapabilities() != null && byokModel.getModelCapabilities().isToolCalling()) {
scopes.add(CopilotScope.AGENT_PANEL);
}
copilotModel.setScopes(scopes);
ByokModelCapabilities byokCapabilities = byokModel.getModelCapabilities();
if (byokCapabilities != null) {
CopilotModelCapabilitiesSupports supports = new CopilotModelCapabilitiesSupports(
byokCapabilities.isVision(), null, false);
// BYOK only exposes input/output token limits; context window and non-streaming output are unknown.
CopilotModelCapabilitiesLimits limits = new CopilotModelCapabilitiesLimits(null,
byokCapabilities.getMaxOutputTokens(), byokCapabilities.getMaxInputTokens(), null);
copilotModel.setCapabilities(new CopilotModelCapabilities(supports, limits));
}
copilotModel.setBilling(null);
copilotModel.setPreview(false);
copilotModel.setChatDefault(false);
copilotModel.setChatFallback(false);
return copilotModel;
}
/**
* Formats the billing multiplier for display. Returns the multiplier value with trailing zeros removed and an "x"
* suffix (e.g., "0x", "1x", "1.5x").
*
* @param multiplier the billing multiplier value
* @return the formatted multiplier text
*/
public static String formatBillingMultiplier(double multiplier) {
BigDecimal multiplierValue = BigDecimal.valueOf(multiplier).stripTrailingZeros();
return multiplierValue.toPlainString() + Messages.model_billing_multiplier_suffix;
}
/**
* Separator used between parts of the model picker suffix (e.g. "1M - High - $$$").
*/
private static final String SUFFIX_PART_SEPARATOR = " - ";
/**
* Returns the display suffix for a model in the model picker.
*
* <p>The suffix is composed of multiple parts joined by {@value #SUFFIX_PART_SEPARATOR} (e.g. context window,
* reasoning effort, price tier). For BYOK models the provider name is used as-is, and the {@code Auto} model uses
* a fixed {@code Variable} label.
*
* @param model the model
* @param reasoningEffort the effective reasoning effort to display, or {@code null} to omit
* @return the suffix string, or an empty string if no suffix applies
*/
public static String getModelSuffix(CopilotModel model, String reasoningEffort) {
if (model == null) {
return "";
}
if (model.getProviderName() != null) {
return model.getProviderName();
}
if (isAutoModel(model)) {
return Messages.model_billing_multiplier_variable;
}
// TODO: Remove this legacy fallback after TBB is officially released.
// When token-based billing is not enabled on the language server side, fall back to the
// original multiplier suffix (e.g. "1x", "1.5x") instead of the context-window | price tier
// form that depends on the TBB-only capability metadata.
if (model.getBilling() != null && !model.getBilling().tokenBasedBillingEnabled()) {
return formatBillingMultiplier(model.getBilling().multiplier());
}
return String.join(SUFFIX_PART_SEPARATOR, buildSuffixParts(model, reasoningEffort));
}
/**
* Builds the ordered list of suffix parts for a model. Add new parts (e.g. thinking effort) here in the desired
* display order. Blank values are filtered out by the caller.
*/
private static List<String> buildSuffixParts(CopilotModel model, String reasoningEffort) {
List<String> parts = new ArrayList<>();
addIfNotBlank(parts, getContextWindowText(model));
// Only surface a reasoning-effort suffix when the language server has explicitly advertised the model as
// supporting selectable effort levels. The server only sets supportsReasoningEffortLevel when the model has
// more than one effort level AND is hosted on a compatible endpoint.
if (supportsReasoningEffortLevel(model)) {
addIfNotBlank(parts, formatReasoningEffortLevel(reasoningEffort));
}
addIfNotBlank(parts, formatPriceCategory(model.getModelPickerPriceCategory()));
return parts;
}
private static void addIfNotBlank(List<String> parts, String value) {
if (StringUtils.isNotBlank(value)) {
parts.add(value);
}
}
/**
* Formats the model picker price category as a dollar-sign tier: {@code Low} -> {@code $}, {@code Medium} ->
* {@code $$}, {@code High} -> {@code $$$}. Returns {@code null} for blank or unrecognized values.
*
* @param priceCategory the model picker price category (e.g. {@code Low}, {@code Medium}, {@code High})
* @return the dollar-sign tier string, or {@code null} if the category is blank or unrecognized
*/
public static String formatPriceCategory(String priceCategory) {
if (StringUtils.isBlank(priceCategory)) {
return null;
}
switch (priceCategory.toLowerCase()) {
case "low":
return "$";
case "medium":
return "$$";
case "high":
return "$$$";
default:
return null;
}
}
/**
* Returns the formatted context window size for the model, or {@code null} if unavailable.
*/
private static String getContextWindowText(CopilotModel model) {
if (model.getCapabilities() == null || model.getCapabilities().limits() == null) {
return null;
}
Integer maxContextWindowTokens = model.getCapabilities().limits().maxContextWindowTokens();
if (maxContextWindowTokens == null || maxContextWindowTokens <= 0) {
return null;
}
return formatTokenCount(maxContextWindowTokens);
}
/**
* Formats a token count into a compact human-readable string (e.g. 128K, 1M, 1.5M).
*
* @param tokens the token count
* @return the formatted string
*/
public static String formatTokenCount(int tokens) {
if (tokens >= 1_000_000 && tokens % 1_000_000 == 0) {
return tokens / 1_000_000 + "M";
} else if (tokens >= 1_000_000) {
String formatted = String.format("%.1f", tokens / 1_000_000.0);
formatted = formatted.replaceAll("0+$", "").replaceAll("\\.$", "");
return formatted + "M";
} else if (tokens >= 1_000 && tokens % 1_000 == 0) {
return tokens / 1_000 + "K";
} else if (tokens >= 1_000) {
String formatted = String.format("%.1f", tokens / 1_000.0);
formatted = formatted.replaceAll("0+$", "").replaceAll("\\.$", "");
return formatted + "K";
}
return String.valueOf(tokens);
}
/**
* Returns the default reasoning effort to use when the user has not made a selection. Prefers {@code medium} when
* it is supported, falling back to the first entry in the supported list. Returns {@code null} when the model
* does not surface selectable reasoning effort levels (see {@link #supportsReasoningEffortLevel(CopilotModel)}).
*
* @param model the model
* @return the default effort identifier, or {@code null} when none can be determined
*/
public static String resolveDefaultReasoningEffort(CopilotModel model) {
if (!supportsReasoningEffortLevel(model)) {
return null;
}
List<String> efforts = getSupportedReasoningEfforts(model);
if (efforts.isEmpty()) {
return null;
}
for (String effort : efforts) {
if ("medium".equalsIgnoreCase(effort)) {
return effort;
}
}
return efforts.get(0);
}
/**
* Formats a reasoning effort identifier as a localized display label. Known identifiers ({@code none}, {@code low},
* {@code medium}, {@code high}, {@code xhigh}) resolve to their localized {@code Messages} constants; unknown
* identifiers fall back to a title-cased rendering of the first character (e.g. {@code custom} -> {@code Custom}).
* Returns {@code null} when {@code effort} is blank.
*
* @param effort the effort identifier
* @return the display label, or {@code null} when blank
*/
public static String formatReasoningEffortLevel(String effort) {
if (StringUtils.isBlank(effort)) {
return null;
}
String trimmed = effort.trim();
switch (trimmed.toLowerCase(Locale.ROOT)) {
case "none":
return Messages.model_reasoningEffort_none;
case "low":
return Messages.model_reasoningEffort_low;
case "medium":
return Messages.model_reasoningEffort_medium;
case "high":
return Messages.model_reasoningEffort_high;
case "xhigh":
return Messages.model_reasoningEffort_xhigh;
default:
return Character.toUpperCase(trimmed.charAt(0)) + trimmed.substring(1).toLowerCase(Locale.ROOT);
}
}
/**
* Returns the localized secondary description for a reasoning effort identifier (e.g. {@code low} -> "Faster
* responses, less thorough reasoning"). Returns {@code null} when {@code effort} is blank or unrecognized.
*
* @param effort the effort identifier
* @return the localized description, or {@code null} when blank or unrecognized
*/
public static String formatReasoningEffortDescription(String effort) {
if (StringUtils.isBlank(effort)) {
return null;
}
switch (effort.trim().toLowerCase(Locale.ROOT)) {
case "none":
return Messages.model_reasoningEffort_none_description;
case "low":
return Messages.model_reasoningEffort_low_description;
case "medium":
return Messages.model_reasoningEffort_medium_description;
case "high":
return Messages.model_reasoningEffort_high_description;
case "xhigh":
return Messages.model_reasoningEffort_xhigh_description;
default:
return null;
}
}
/**
* Returns the list of reasoning effort levels advertised by the model, or an empty list when none are advertised.
*
* @param model the model
* @return the list of supported reasoning effort identifiers (e.g. {@code low}, {@code medium}, {@code high})
*/
public static List<String> getSupportedReasoningEfforts(CopilotModel model) {
if (model == null || model.getCapabilities() == null || model.getCapabilities().supports() == null) {
return List.of();
}
List<String> efforts = model.getCapabilities().supports().reasoningEfforts();
return efforts == null ? List.of() : efforts;
}
/**
* Returns whether the model is the special "Auto" model, which dynamically routes requests to other models and
* therefore does not expose its own reasoning-effort selection.
*
* @param model the model
* @return {@code true} when the model is the Auto model
*/
public static boolean isAutoModel(CopilotModel model) {
return model != null && "Auto".equals(model.getModelName());
}
/**
* Returns whether the model surfaces selectable reasoning effort levels to the user. The language server only
* advertises {@code supportsReasoningEffortLevel = true} when the model has more than one effort level and is
* hosted on a compatible endpoint, so this is the canonical gate for the reasoning-effort UI and for sending a
* {@code modelInfo.reasoningEffort} payload with chat requests. The Auto model is excluded because it routes to
* other models and does not own its own effort selection.
*
* @param model the model
* @return {@code true} when the user can select a reasoning effort for this model
*/
public static boolean supportsReasoningEffortLevel(CopilotModel model) {
if (model == null || model.getCapabilities() == null || model.getCapabilities().supports() == null) {
return false;
}
if (isAutoModel(model)) {
return false;
}
return model.getCapabilities().supports().supportsReasoningEffortLevel();
}
}