diff --git a/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/chat/UserPreferenceTests.java b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/chat/UserPreferenceTests.java new file mode 100644 index 000000000..e99b28f24 --- /dev/null +++ b/com.microsoft.copilot.eclipse.core.test/src/com/microsoft/copilot/eclipse/core/chat/UserPreferenceTests.java @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.core.chat; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +class UserPreferenceTests { + + private static final Gson GSON = new Gson(); + + @Test + void testContextWindowPreference_deserializesLegacyStringAndSerializesNumber() { + UserPreference preference = GSON.fromJson( + "{\"contextWindowByModel\":{\"gpt-5\":\"1000000\"}}", UserPreference.class); + + assertEquals(1_000_000, preference.getContextWindow("gpt-5")); + + JsonObject serialized = JsonParser.parseString(GSON.toJson(preference)).getAsJsonObject(); + assertTrue(serialized.getAsJsonObject("contextWindowByModel").get("gpt-5").getAsJsonPrimitive().isNumber()); + } +} diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/UserPreference.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/UserPreference.java index 5398fec37..0d5cded98 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/UserPreference.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/chat/UserPreference.java @@ -27,6 +27,12 @@ public class UserPreference { */ private volatile Map reasoningEffortByModel = Map.of(); + /** + * User-selected context-window size (a price tier's {@code maxContext} token count) keyed by the composite model + * key (matching the {@link #chatModel} format). + */ + private volatile Map contextWindowByModel = Map.of(); + /** * Gets the id of the Chat model. * @@ -166,9 +172,98 @@ public synchronized boolean setReasoningEfforts(Map reasoningEff return true; } + /** + * Returns the user-selected context-window size for the given model key, or {@code null} when the user has not made + * an explicit selection. + * + * @param modelKey the composite model key (matching the {@link #getChatModel()} format) + * @return the previously selected context-window size, or {@code null} + */ + public Integer getContextWindow(String modelKey) { + if (modelKey == null) { + return null; + } + return contextWindowByModel.get(modelKey); + } + + /** + * Returns an immutable snapshot of the model-key to context-window map. Useful as an observable value that changes + * by equality whenever any individual entry is added, updated, or removed. + * + * @return an immutable snapshot of the current context-window map (never {@code null}) + */ + public Map getContextWindowSnapshot() { + return contextWindowByModel; + } + + /** + * Stores the user-selected context-window size for the given model key. Passing a {@code null} value clears any + * previously stored value for that key. + * + *

The underlying map is replaced atomically with a fresh immutable snapshot via {@link Map#copyOf}, so concurrent + * readers (including Gson reflectively serializing this preference on a background thread) always observe either + * the old or the new snapshot, never a partially mutated map. + * + * @param modelKey the composite model key (matching the {@link #getChatModel()} format) + * @param contextWindow the context-window size to store, or {@code null} to clear + * @return {@code true} when the stored context window changed, {@code false} otherwise + */ + public synchronized boolean setContextWindow(String modelKey, Integer contextWindow) { + if (modelKey == null) { + return false; + } + Map current = contextWindowByModel; + if (contextWindow == null) { + if (!current.containsKey(modelKey)) { + return false; + } + Map next = new HashMap<>(current); + next.remove(modelKey); + contextWindowByModel = Map.copyOf(next); + return true; + } + if (contextWindow.equals(current.get(modelKey))) { + return false; + } + Map next = new HashMap<>(current); + next.put(modelKey, contextWindow); + contextWindowByModel = Map.copyOf(next); + return true; + } + + /** + * Atomically replaces the entire context-window map with the given snapshot, dropping any keys not present in + * {@code contextWindowsByModel}. {@code null} entries in the input map are ignored. Returns {@code true} when the + * new snapshot differs from the previous one (so callers can decide whether to persist or notify observers). + * + * @param contextWindowsByModel the new context-window map keyed by composite model key (matching the + * {@link #getChatModel()} format); may be {@code null} or empty to clear all entries + * @return {@code true} when the stored map changed, {@code false} otherwise + */ + public synchronized boolean setContextWindows(Map contextWindowsByModel) { + Map updatedContextWindows; + if (contextWindowsByModel == null || contextWindowsByModel.isEmpty()) { + updatedContextWindows = Map.of(); + } else { + Map copy = new HashMap<>(); + for (Map.Entry entry : contextWindowsByModel.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + copy.put(entry.getKey(), entry.getValue()); + } + } + updatedContextWindows = Map.copyOf(copy); + } + if (updatedContextWindows.equals(this.contextWindowByModel)) { + return false; + } + this.contextWindowByModel = updatedContextWindows; + return true; + } + @Override public int hashCode() { - return Objects.hash(chatModeName, chatModel, userInputs, skipGitHubJobConfirmDialog, reasoningEffortByModel); + return Objects.hash(chatModeName, chatModel, userInputs, skipGitHubJobConfirmDialog, reasoningEffortByModel, + contextWindowByModel); } @Override @@ -186,7 +281,8 @@ public boolean equals(Object obj) { return Objects.equals(chatModeName, other.chatModeName) && Objects.equals(chatModel, other.chatModel) && Objects.equals(userInputs, other.userInputs) && skipGitHubJobConfirmDialog == other.skipGitHubJobConfirmDialog - && Objects.equals(reasoningEffortByModel, other.reasoningEffortByModel); + && Objects.equals(reasoningEffortByModel, other.reasoningEffortByModel) + && Objects.equals(contextWindowByModel, other.contextWindowByModel); } @Override @@ -197,6 +293,7 @@ public String toString() { builder.append("userInputs", userInputs); builder.append("skipGitHubJobConfirmDialog", skipGitHubJobConfirmDialog); builder.append("reasoningEffortByModel", reasoningEffortByModel); + builder.append("contextWindowByModel", contextWindowByModel); return builder.toString(); } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java index f6b6a1020..08f27df03 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/CopilotLanguageServerConnection.java @@ -265,13 +265,13 @@ public CompletableFuture sendExceptionTelemetry(Throwable ex) { } /** - * Create a conversation with the given parameters, including an optional reasoning effort to forward to the server - * via {@code modelInfo}. + * Create a conversation with the given parameters, including an optional reasoning effort and context-window size to + * forward to the server via {@code modelInfo}. */ public CompletableFuture createConversation(String workDoneToken, String message, List files, IFile currentFile, Range currentSelection, List turns, CopilotModel activeModel, - String reasoningEffort, String chatModeName, String customChatModeId, List todos, String agentSlug, - String agentJobWorkspaceFolder, String conversationId, String restoreToTurnId, + String reasoningEffort, Integer contextSize, String chatModeName, String customChatModeId, List todos, + String agentSlug, String agentJobWorkspaceFolder, String conversationId, String restoreToTurnId, List workspaceFolders) { boolean supportVision = activeModel.getCapabilities().supports().vision(); @@ -282,7 +282,7 @@ public CompletableFuture createConversation(String workDoneTok param.setReferences(FileUtils.convertToChatReferences(files)); param.setModel(getModelName(activeModel)); param.setModelProviderName(activeModel.getProviderName()); - param.setModelInfo(buildModelInfo(activeModel, reasoningEffort)); + param.setModelInfo(buildModelInfo(activeModel, reasoningEffort, contextSize)); param.setChatMode(chatModeName); param.setCustomChatModeId(customChatModeId); @@ -323,13 +323,14 @@ public CompletableFuture createConversation(String workDoneTok } /** - * Create a conversation turn with the given parameters, including an optional reasoning effort to forward to the - * server via {@code modelInfo}. + * Create a conversation turn with the given parameters, including an optional reasoning effort and context-window + * size to forward to the server via {@code modelInfo}. */ public CompletableFuture addConversationTurn(String workDoneToken, String conversationId, String message, List files, IFile currentFile, Range currentSelection, CopilotModel activeModel, - String reasoningEffort, String chatModeName, String customChatModeId, List todoList, String agentSlug, - String agentJobWorkspaceFolder, List workspaceFolders) { + String reasoningEffort, Integer contextSize, String chatModeName, String customChatModeId, + List todoList, String agentSlug, String agentJobWorkspaceFolder, + List workspaceFolders) { boolean supportVision = activeModel.getCapabilities().supports().vision(); Either> messageWithImages = ChatMessageUtils @@ -339,7 +340,7 @@ public CompletableFuture addConversationTurn(String workDoneToke param.setReferences(FileUtils.convertToChatReferences(files)); param.setModel(getModelName(activeModel)); param.setModelProviderName(activeModel.getProviderName()); - param.setModelInfo(buildModelInfo(activeModel, reasoningEffort)); + param.setModelInfo(buildModelInfo(activeModel, reasoningEffort, contextSize)); param.setChatMode(chatModeName); param.setCustomChatModeId(customChatModeId); @@ -741,11 +742,11 @@ private String getModelName(CopilotModel activeModel) { * prefers over the legacy {@code model} family field), since the family is not unique across models. Returns * {@code null} when no model id is available. */ - private static ModelInfo buildModelInfo(CopilotModel activeModel, String reasoningEffort) { + private static ModelInfo buildModelInfo(CopilotModel activeModel, String reasoningEffort, Integer contextSize) { if (activeModel == null || StringUtils.isBlank(activeModel.getId())) { return null; } String effort = StringUtils.isBlank(reasoningEffort) ? null : reasoningEffort; - return new ModelInfo(activeModel.getId(), activeModel.getProviderName(), effort, null); + return new ModelInfo(activeModel.getId(), activeModel.getProviderName(), effort, contextSize); } } diff --git a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ModelInfo.java b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ModelInfo.java index e7ff3e84c..2bc51d857 100644 --- a/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ModelInfo.java +++ b/com.microsoft.copilot.eclipse.core/src/com/microsoft/copilot/eclipse/core/lsp/protocol/ModelInfo.java @@ -15,7 +15,8 @@ * @param id model identifier (optional) * @param providerName provider name (optional) * @param reasoningEffort user-selected reasoning effort (optional) - * @param contextSize context size (optional) + * @param contextSize user-selected context-window size in tokens (optional). Sent as a JSON number to match the + * language server's {@code modelInfo.contextSize} schema. */ -public record ModelInfo(String id, String providerName, String reasoningEffort, String contextSize) { +public record ModelInfo(String id, String providerName, String reasoningEffort, Integer contextSize) { } diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ModelPickerGroupsBuilderTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ModelPickerGroupsBuilderTests.java new file mode 100644 index 000000000..ab591d01a --- /dev/null +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/ModelPickerGroupsBuilderTests.java @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +package com.microsoft.copilot.eclipse.ui.chat; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel; +import com.microsoft.copilot.eclipse.ui.swt.DropdownItemGroup; + +class ModelPickerGroupsBuilderTests { + + @Test + void testBuild_selectedLabelIncludesContextWindowAndReasoningEffort() { + CopilotModel model = new CopilotModel(); + model.setModelName("gpt-5"); + + List groups = ModelPickerGroupsBuilder.build(Map.of("gpt-5", model), false, false, + ignored -> "high", ignored -> "1M"); + + assertEquals("gpt-5 - 1M - High", groups.get(0).getItems().get(0).getSelectedLabel()); + } +} diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ModelServiceTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ModelServiceTests.java index dfc383d87..1626af9a8 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ModelServiceTests.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/chat/services/ModelServiceTests.java @@ -4,6 +4,7 @@ package com.microsoft.copilot.eclipse.ui.chat.services; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -210,6 +211,26 @@ void testDefaultKeyCollisionSelectsModelFromCurrentInventory() throws IOExceptio assertSame(pickerModel.get(), activeModel.get()); } + @Test + void testSetActiveModel_AlreadyActiveModelDoesNotPersistPreference() throws InterruptedException { + CopilotModel defaultModel = createModel("gpt-4o", "GPT-4o", true); + when(lsConnection.listModels()) + .thenReturn(CompletableFuture.completedFuture(new CopilotModel[] { defaultModel })); + + modelService = new ModelService(lsConnection, authStatusManager); + waitUntil(() -> defaultModel.getId().equals(getActiveModelId())); + + AtomicReference activeModel = new AtomicReference<>(); + Display.getDefault().syncExec(() -> { + modelService.setActiveModel(defaultModel.getModelName()); + activeModel.set(modelService.getActiveModel()); + }); + + assertSame(defaultModel, activeModel.get()); + Thread.sleep(100); + assertFalse(Files.exists(getPreferenceFile())); + } + private static CopilotModel createModel(String id, String name, boolean isChatDefault) { CopilotModel model = new CopilotModel(); model.setId(id); diff --git a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtilsTests.java b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtilsTests.java index 7bbe74342..454ab51cc 100644 --- a/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtilsTests.java +++ b/com.microsoft.copilot.eclipse.ui.test/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtilsTests.java @@ -14,11 +14,15 @@ import org.junit.jupiter.api.Test; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelBilling; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelBillingTokenPrices; 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.CopilotModel.CopilotModelCustomModel; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelTokenPriceTier; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotScope; +import com.microsoft.copilot.eclipse.ui.utils.ModelUtils.ContextWindowOption; import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokModel; import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokModelCapabilities; @@ -160,7 +164,7 @@ void testGetModelSuffix_customModelUsesProvider() { model.setModelName("Sonnet (Org)"); model.setCustomModel(new CopilotModelCustomModel("Contoso Azure Key", "Contoso", "organization", "Azure")); - assertEquals("Azure", ModelUtils.getModelSuffix(model, null)); + assertEquals("Azure", ModelUtils.getModelSuffix(model, null, null)); } @Test @@ -170,7 +174,7 @@ void testGetModelSuffix_providerNameTakesPrecedenceOverCustomModel() { model.setProviderName("OpenAI"); model.setCustomModel(new CopilotModelCustomModel("Key", "Contoso", "organization", "Azure")); - assertEquals("OpenAI", ModelUtils.getModelSuffix(model, null)); + assertEquals("OpenAI", ModelUtils.getModelSuffix(model, null, null)); } @Test @@ -178,12 +182,12 @@ void testGetModelSuffix_autoUsesStableModelId() { CopilotModel auto = new CopilotModel(); auto.setId("auto"); auto.setModelName("Automatic"); - assertEquals("Variable", ModelUtils.getModelSuffix(auto, null)); + assertEquals("Variable", ModelUtils.getModelSuffix(auto, null, null)); CopilotModel matchingDisplayName = new CopilotModel(); matchingDisplayName.setId("gpt-5"); matchingDisplayName.setModelName("Auto"); - assertEquals("", ModelUtils.getModelSuffix(matchingDisplayName, null)); + assertEquals("", ModelUtils.getModelSuffix(matchingDisplayName, null, null)); } @Test @@ -200,4 +204,114 @@ void testIsAutoModel_usesStableModelId() { assertFalse(ModelUtils.isAutoModel(null)); } + + private static CopilotModel modelWithTiers(Integer defaultMaxContext, Integer longContextMaxContext, + Integer maxOutputTokens) { + CopilotModel model = new CopilotModel(); + model.setModelName("gpt-5"); + model.setCapabilities(new CopilotModelCapabilities( + new CopilotModelCapabilitiesSupports(false, null, false), + new CopilotModelCapabilitiesLimits(null, maxOutputTokens, null, null))); + CopilotModelTokenPriceTier defaultTier = defaultMaxContext == null ? null + : new CopilotModelTokenPriceTier(null, 1.0, 2.0, defaultMaxContext); + CopilotModelTokenPriceTier longContextTier = longContextMaxContext == null ? null + : new CopilotModelTokenPriceTier(null, 3.0, 4.0, longContextMaxContext); + model.setBilling(new CopilotModelBilling(true, 1.0, true, + new CopilotModelBillingTokenPrices(1_000_000.0, defaultTier, longContextTier))); + return model; + } + + @Test + void testGetContextWindowOptions_returnsOnePerTier() { + CopilotModel model = modelWithTiers(128000, 1000000, 16000); + + List options = ModelUtils.getContextWindowOptions(model); + + assertEquals(2, options.size()); + assertTrue(options.get(0).isDefault()); + assertEquals(128000, options.get(0).maxContext()); + assertFalse(options.get(1).isDefault()); + assertEquals(1000000, options.get(1).maxContext()); + } + + @Test + void testGetContextWindowOptions_deduplicatesEquivalentTiersWithDefaultPrecedence() { + CopilotModel model = modelWithTiers(1000000, 1000000, 16000); + + List options = ModelUtils.getContextWindowOptions(model); + + assertEquals(1, options.size()); + assertTrue(options.get(0).isDefault()); + assertEquals(1000000, options.get(0).maxContext()); + assertFalse(ModelUtils.supportsContextWindowSelection(model)); + } + + @Test + void testGetContextWindowOptions_emptyWhenNoTokenPrices() { + CopilotModel model = new CopilotModel(); + model.setModelName("gpt-5"); + model.setCapabilities(new CopilotModelCapabilities( + new CopilotModelCapabilitiesSupports(false, null, false), + new CopilotModelCapabilitiesLimits(200000, 16000, null, null))); + + assertTrue(ModelUtils.getContextWindowOptions(model).isEmpty()); + } + + @Test + void testGetContextWindowOptions_defaultTierFallsBackToMaxContextWindowTokens() { + CopilotModel model = new CopilotModel(); + model.setModelName("gpt-5"); + model.setCapabilities(new CopilotModelCapabilities( + new CopilotModelCapabilitiesSupports(false, null, false), + new CopilotModelCapabilitiesLimits(200000, 16000, null, null))); + // Default tier without its own maxContext -> falls back to maxContextWindowTokens (200000). + model.setBilling(new CopilotModelBilling(true, 1.0, true, new CopilotModelBillingTokenPrices(1_000_000.0, + new CopilotModelTokenPriceTier(null, 1.0, 2.0, null), null))); + + List options = ModelUtils.getContextWindowOptions(model); + + assertEquals(1, options.size()); + assertEquals(200000, options.get(0).maxContext()); + } + + @Test + void testGetContextWindowDisplaySize_addsMaxOutputWhenTierHasMaxContext() { + CopilotModel model = modelWithTiers(128000, 1000000, 16000); + List options = ModelUtils.getContextWindowOptions(model); + + assertEquals(144000, ModelUtils.getContextWindowDisplaySize(model, options.get(0))); + assertEquals(1016000, ModelUtils.getContextWindowDisplaySize(model, options.get(1))); + } + + @Test + void testSupportsContextWindowSelection_trueOnlyForMultipleTiers() { + assertTrue(ModelUtils.supportsContextWindowSelection(modelWithTiers(128000, 1000000, 16000))); + assertFalse(ModelUtils.supportsContextWindowSelection(modelWithTiers(128000, null, 16000))); + assertFalse(ModelUtils.supportsContextWindowSelection(new CopilotModel())); + } + + @Test + void testFindContextWindowOption_matchesByMaxContext() { + CopilotModel model = modelWithTiers(128000, 1000000, 16000); + + ContextWindowOption match = ModelUtils.findContextWindowOption(model, 1000000); + assertNotNull(match); + assertFalse(match.isDefault()); + assertEquals(1000000, match.maxContext()); + + assertNull(ModelUtils.findContextWindowOption(model, 999)); + assertNull(ModelUtils.findContextWindowOption(model, null)); + } + + @Test + void testResolveDefaultContextWindowOption_prefersDefaultTier() { + CopilotModel model = modelWithTiers(128000, 1000000, 16000); + + ContextWindowOption option = ModelUtils.resolveDefaultContextWindowOption(model); + + assertNotNull(option); + assertTrue(option.isDefault()); + assertEquals(128000, option.maxContext()); + assertNull(ModelUtils.resolveDefaultContextWindowOption(new CopilotModel())); + } } diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java index 30ceac7ef..29b8eb3d4 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ChatView.java @@ -1106,9 +1106,10 @@ private void onSendInternal(String workDoneToken, String message, String agentSl } String turnReasoningEffort = chatServiceManager.getModelService().resolveEffectiveReasoningEffort(activeModel); + Integer turnContextSize = chatServiceManager.getModelService().resolveEffectiveContextWindow(activeModel); CompletableFuture addConversationFuture = ls.addConversationTurn(workDoneToken, conversationId, - message, references, currentFile, currentSelection, activeModel, turnReasoningEffort, chatModeName, - customChatModeId, currentTodos, agentSlug, agentJobWorkspaceFolder, + message, references, currentFile, currentSelection, activeModel, turnReasoningEffort, + turnContextSize, chatModeName, customChatModeId, currentTodos, agentSlug, agentJobWorkspaceFolder, deriveWorkspaceFolders(currentFile, references)); conversationFutures.add(addConversationFuture); @@ -1171,17 +1172,18 @@ private void onSendInternal(String workDoneToken, String message, String agentSl List workspaceFolders = deriveWorkspaceFolders(currentFile, references); String reasoningEffort = chatServiceManager.getModelService().resolveEffectiveReasoningEffort(activeModel); + Integer contextSize = chatServiceManager.getModelService().resolveEffectiveContextWindow(activeModel); CompletableFuture createConversationFuture = null; if (StringUtils.isBlank(agentSlug)) { createConversationFuture = ls.createConversation(workDoneToken, message, references, currentFile, - currentSelection, turns, activeModel, reasoningEffort, chatModeName, customChatModeId, todosToRestore, null, - null, restoredConversationId, restoreToTurnId, workspaceFolders); + currentSelection, turns, activeModel, reasoningEffort, contextSize, chatModeName, customChatModeId, + todosToRestore, null, null, restoredConversationId, restoreToTurnId, workspaceFolders); } else { // For conversations sending to agents, include agentSlug and specify the target agentJobWorkspaceFolder // Don't send todo list for agent jobs - agents manage their own todo state independently createConversationFuture = ls.createConversation(workDoneToken, message, references, currentFile, - currentSelection, turns, activeModel, reasoningEffort, chatModeName, customChatModeId, null, agentSlug, - agentJobWorkspaceFolder, restoredConversationId, restoreToTurnId, workspaceFolders); + currentSelection, turns, activeModel, reasoningEffort, contextSize, chatModeName, customChatModeId, null, + agentSlug, agentJobWorkspaceFolder, restoredConversationId, restoreToTurnId, workspaceFolders); } conversationFutures.add(createConversationFuture); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ModelPickerGroupsBuilder.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ModelPickerGroupsBuilder.java index 31ca69a14..4333410e7 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ModelPickerGroupsBuilder.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/ModelPickerGroupsBuilder.java @@ -34,30 +34,21 @@ private ModelPickerGroupsBuilder() { } /** - * Builds grouped dropdown items for the model picker. - * - * @param modelMap available models keyed by id - * @param showAddPremiumModelOption whether to include the premium upsell action - * @param showByokManageOption whether to include the BYOK manage action - * @return grouped dropdown items for the model picker - */ - public static List build(Map modelMap, boolean showAddPremiumModelOption, - boolean showByokManageOption) { - return build(modelMap, showAddPremiumModelOption, showByokManageOption, null); - } - - /** - * Builds grouped dropdown items for the model picker, including the effective reasoning effort in the suffix. + * Builds grouped dropdown items for the model picker, including the effective reasoning effort and context-window + * size in the suffix. * * @param modelMap available models keyed by id * @param showAddPremiumModelOption whether to include the premium upsell action * @param showByokManageOption whether to include the BYOK manage action * @param reasoningEffortResolver resolves the effective reasoning effort for a given model (user-selected when * present, otherwise the inferred default), or {@code null} when none applies + * @param contextWindowResolver resolves the effective context-window display size for a given model (user-selected + * tier when present, otherwise the default tier), or {@code null} when none applies * @return grouped dropdown items for the model picker */ public static List build(Map modelMap, boolean showAddPremiumModelOption, - boolean showByokManageOption, Function reasoningEffortResolver) { + boolean showByokManageOption, Function reasoningEffortResolver, + Function contextWindowResolver) { List otherModels = new ArrayList<>(); List standardModels = new ArrayList<>(); List premiumModels = new ArrayList<>(); @@ -83,19 +74,21 @@ public static List build(Map modelMap, List groups = new ArrayList<>(); if (!otherModels.isEmpty()) { - groups.add(DropdownItemGroup.of(buildModelDropdownItems(otherModels, reasoningEffortResolver))); + groups.add(DropdownItemGroup.of(buildModelDropdownItems(otherModels, reasoningEffortResolver, + contextWindowResolver))); } if (!standardModels.isEmpty()) { groups.add(DropdownItemGroup.of(Messages.chat_standardModels, - buildModelDropdownItems(standardModels, reasoningEffortResolver))); + buildModelDropdownItems(standardModels, reasoningEffortResolver, contextWindowResolver))); } if (!premiumModels.isEmpty()) { String header = standardModels.isEmpty() ? Messages.chat_copilotModels : Messages.chat_premiumModels; - groups.add(DropdownItemGroup.of(header, buildModelDropdownItems(premiumModels, reasoningEffortResolver))); + groups.add(DropdownItemGroup.of(header, buildModelDropdownItems(premiumModels, reasoningEffortResolver, + contextWindowResolver))); } if (!customModels.isEmpty()) { groups.add(DropdownItemGroup.of(Messages.chat_customModels, - buildModelDropdownItems(customModels, reasoningEffortResolver))); + buildModelDropdownItems(customModels, reasoningEffortResolver, contextWindowResolver))); } List actionItems = new ArrayList<>(); @@ -115,7 +108,7 @@ public static List build(Map modelMap, } private static List buildModelDropdownItems(List models, - Function reasoningEffortResolver) { + Function reasoningEffortResolver, Function contextWindowResolver) { List items = new ArrayList<>(); for (CopilotModel model : models) { String rawName = model.getModelName(); @@ -123,10 +116,18 @@ private static List buildModelDropdownItems(List mod String name = model.isPreview() && !alreadyHasPreview ? rawName + " " + Messages.model_preview_suffix : rawName; String effectiveEffort = reasoningEffortResolver != null ? reasoningEffortResolver.apply(model) : null; - String suffix = ModelUtils.getModelSuffix(model, effectiveEffort); + String effectiveContextWindow = contextWindowResolver != null ? contextWindowResolver.apply(model) : null; + String suffix = ModelUtils.getModelSuffix(model, effectiveEffort, effectiveContextWindow); String effortLevel = ModelUtils.formatReasoningEffortLevel(effectiveEffort); - String selectedLabel = StringUtils.isNotBlank(effortLevel) && StringUtils.isNotBlank(name) - ? name + " - " + effortLevel : null; + List selectedDetails = new ArrayList<>(); + if (StringUtils.isNotBlank(effectiveContextWindow)) { + selectedDetails.add(effectiveContextWindow); + } + if (StringUtils.isNotBlank(effortLevel)) { + selectedDetails.add(effortLevel); + } + String selectedLabel = StringUtils.isNotBlank(name) && !selectedDetails.isEmpty() + ? name + " - " + String.join(" - ", selectedDetails) : null; items.add(new DropdownItem.Builder().id(rawName).label(name).selectedLabel(selectedLabel).suffix(suffix) .icon(resolveModelIcon(model)).hoverProvider(new ModelHoverContentProvider(model)).build()); diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ModelService.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ModelService.java index 9820c2e05..88bcdf711 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ModelService.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/chat/services/ModelService.java @@ -43,6 +43,7 @@ import com.microsoft.copilot.eclipse.ui.i18n.Messages; import com.microsoft.copilot.eclipse.ui.swt.DropdownButton; import com.microsoft.copilot.eclipse.ui.utils.ModelUtils; +import com.microsoft.copilot.eclipse.ui.utils.ModelUtils.ContextWindowOption; /** * Service for managing AI models and their selection. Handles all model-related functionality including persistence, @@ -54,6 +55,7 @@ public class ModelService extends ChatBaseService { private IObservableValue> modelObservable; private IObservableValue activeModelObservable; private IObservableValue> reasoningEffortObservable; + private IObservableValue> contextWindowObservable; // Used to update modelObservable private Map copilotModels = new HashMap<>(); private Map registeredByokModels = new HashMap<>(); @@ -89,6 +91,11 @@ public ModelService(CopilotLanguageServerConnection lsConnection, AuthStatusMana initialEfforts = initialPreference.getReasoningEffortSnapshot(); } reasoningEffortObservable = new WritableValue<>(initialEfforts, Map.class); + Map initialContextWindows = Map.of(); + if (initialPreference != null) { + initialContextWindows = initialPreference.getContextWindowSnapshot(); + } + contextWindowObservable = new WritableValue<>(initialContextWindows, Map.class); }); initializeEventHandlers(); @@ -119,6 +126,7 @@ private void initializeEventHandlers() { Map> byokModels = (Map>) modelsMap; saveRegisteredByokModels(byokModels); reconcileReasoningEfforts(); + reconcileContextWindows(); ensureRealm(() -> updateModelsForChatMode(currentChatMode)); } }; @@ -183,6 +191,7 @@ protected IStatus run(IProgressMonitor monitor) { fetchCopilotModels(); fetchByokModels(); reconcileReasoningEfforts(); + reconcileContextWindows(); ensureRealm(() -> { updateModelsForChatMode(currentChatMode); }); @@ -370,6 +379,10 @@ public void setActiveModel(String modelName) { .findFirst() .orElse(null); if (model != null) { + CopilotModel activeModel = getActiveModel(); + if (activeModel != null && activeModel.getModelKey().equals(model.getModelKey())) { + return; + } // Persist asynchronously to avoid deadlock: persistUserPreference() calls // persistence().get() which blocks waiting for the LSP listener thread. // If called on the UI thread while the listener is in syncExec, both threads @@ -534,6 +547,134 @@ private void reconcileReasoningEfforts() { } } + /** + * Returns the user-selected context-window size (a price tier's {@code maxContext} token count) for the given model, + * or {@code null} when the user has not made a selection. The persisted snapshot is kept in sync with the current + * model inventory by {@link #reconcileContextWindows()} after each model fetch, so this method is a pure lookup. + * + * @param model the model to query + * @return the selected context-window size, or {@code null} + */ + private Integer getSelectedContextWindow(CopilotModel model) { + UserPreference preference = getUserPreference(); + return preference != null ? preference.getContextWindow(model.getModelKey()) : null; + } + + /** + * Resolves the effective context-window option for the given model: the option matching the user's persisted + * selection when it is still offered, otherwise the model's default option (see + * {@link ModelUtils#resolveDefaultContextWindowOption(CopilotModel)}). Returns {@code null} for models that offer no + * selectable context-window options. + * + * @param model the model to query + * @return the effective context-window option, or {@code null} + */ + public ContextWindowOption resolveEffectiveContextWindowOption(CopilotModel model) { + if (model == null) { + return null; + } + Integer selectedValue = getSelectedContextWindow(model); + ContextWindowOption selected = ModelUtils.findContextWindowOption(model, selectedValue); + return selected != null ? selected : ModelUtils.resolveDefaultContextWindowOption(model); + } + + /** + * Resolves the effective context-window display size (e.g. {@code 1M}) to show in the model picker suffix for the + * given model, or {@code null} when the model offers no context-window options. + * + * @param model the model to query + * @return the formatted display size, or {@code null} + */ + public String resolveEffectiveContextWindowText(CopilotModel model) { + ContextWindowOption option = resolveEffectiveContextWindowOption(model); + return option == null ? null : ModelUtils.formatTokenCount(ModelUtils.getContextWindowDisplaySize(model, option)); + } + + /** + * Resolves the context-window size that should be forwarded to the language server for the given model: the user's + * explicit selection when present, otherwise the model's default tier. Returns {@code null} for models that do not + * expose a selectable context window (see {@link ModelUtils#supportsContextWindowSelection(CopilotModel)}), so the + * field is omitted for single-tier models and older servers are unaffected. + * + * @param model the model that will receive the request + * @return the {@code maxContext} token count, or {@code null} to omit + */ + public Integer resolveEffectiveContextWindow(CopilotModel model) { + if (!ModelUtils.supportsContextWindowSelection(model)) { + return null; + } + ContextWindowOption option = resolveEffectiveContextWindowOption(model); + return option == null ? null : Integer.valueOf(option.maxContext()); + } + + /** + * Persists the user-selected context-window size for the given model and updates dependent observers. + * + * @param model the model to update + * @param contextWindow the context-window size to store, or {@code null} to clear + */ + public void setSelectedContextWindow(CopilotModel model, Integer contextWindow) { + if (model == null) { + return; + } + UserPreference preference = getUserPreference(); + if (preference == null) { + return; + } + if (!preference.setContextWindow(model.getModelKey(), contextWindow)) { + return; + } + CompletableFuture.runAsync(this::persistUserPreference); + // Publish a fresh snapshot to drive bound picker re-renders. The actual rendering reads + // resolveEffectiveContextWindowText (which queries UserPreference), so this observable serves purely as a change + // signal. + ensureRealm(() -> contextWindowObservable.setValue(preference.getContextWindowSnapshot())); + } + + /** + * Reconciles the persisted context-window snapshot with the current model inventory + * ({@link #copilotModels} ∪ {@link #registeredByokModels}). Entries are kept only when the model still exists and + * the stored size still matches one of that model's advertised context-window options; everything else is dropped. + * The map is replaced atomically. + * + *

Skipped when the inventory is empty (e.g. the very first fetch has not produced results yet or both fetches + * failed) so a transient outage cannot wipe every stored selection. + */ + private void reconcileContextWindows() { + if (copilotModels.isEmpty() && registeredByokModels.isEmpty()) { + return; + } + UserPreference preference = getUserPreference(); + if (preference == null) { + return; + } + Map snapshot = preference.getContextWindowSnapshot(); + if (snapshot.isEmpty()) { + return; + } + Map inventory = new HashMap<>(); + for (CopilotModel model : copilotModels.values()) { + inventory.put(model.getModelKey(), model); + } + for (CopilotModel model : registeredByokModels.values()) { + inventory.put(model.getModelKey(), model); + } + Map reconciled = new HashMap<>(); + for (Map.Entry entry : snapshot.entrySet()) { + CopilotModel model = inventory.get(entry.getKey()); + if (model == null) { + continue; + } + if (ModelUtils.findContextWindowOption(model, entry.getValue()) != null) { + reconciled.put(entry.getKey(), entry.getValue()); + } + } + if (preference.setContextWindows(reconciled)) { + CompletableFuture.runAsync(this::persistUserPreference); + ensureRealm(() -> contextWindowObservable.setValue(preference.getContextWindowSnapshot())); + } + } + /** * Binds a {@link DropdownButton} to this service for model selection. The button displays model groups with per-item * tooltips and billing suffixes. @@ -553,6 +694,7 @@ public void bindModelPicker(final DropdownButton picker) { ISideEffect modelsSideEffect = ISideEffect.create(() -> { Map modelMap = this.modelObservable.getValue(); this.reasoningEffortObservable.getValue(); + this.contextWindowObservable.getValue(); if (picker.isDisposed() || modelMap.isEmpty()) { return Collections.emptyMap(); } @@ -560,9 +702,8 @@ public void bindModelPicker(final DropdownButton picker) { }, (Map modelMap) -> rebuildPickerItems(picker, modelMap)); // Active-model render path: only depends on the active model. The button-face text is read from the matching - // DropdownItem's selectedLabel (populated by ModelPickerGroupsBuilder with the effective effort), which is - // refreshed by modelsSideEffect above whenever the reasoning-effort observable changes. There is no need to - // track the effort observable here. + // DropdownItem's selectedLabel, which modelsSideEffect refreshes whenever the effective reasoning effort or + // context window changes. There is no need to track those observables here. ISideEffect activeModelSideEffect = ISideEffect.create(this.activeModelObservable::getValue, (CopilotModel activeModel) -> { if (activeModel == null || picker.isDisposed()) { @@ -596,7 +737,7 @@ private void rebuildPickerItems(DropdownButton picker, Map FeatureFlags flags = CopilotCore.getPlugin().getFeatureFlags(); boolean showByokManageOption = flags == null || flags.isByokEnabled(); picker.setItemGroups(ModelPickerGroupsBuilder.build(modelMap, showAddPremiumModelOption, showByokManageOption, - this::resolveEffectiveReasoningEffort)); + this::resolveEffectiveReasoningEffort, this::resolveEffectiveContextWindowText)); } /** diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java index e41c9e097..4d9b04fd2 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/Messages.java @@ -204,10 +204,13 @@ public final class Messages extends NLS { public static String model_billing_multiplier_variable; public static String model_preview_suffix; public static String model_hover_contextWindow; + public static String model_hover_contextWindow_title; public static String model_hover_cost; public static String model_hover_thinkingEffort; public static String model_hover_thinkingEffort_default_suffix; public static String model_hover_customModelInfo; + public static String model_contextWindow_default_description; + public static String model_contextWindow_longContext_description; public static String model_reasoningEffort_none; public static String model_reasoningEffort_low; public static String model_reasoningEffort_medium; diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties index 9878bc09f..f83d62044 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/i18n/messages.properties @@ -198,10 +198,13 @@ addToReference_addFile_title=Add File to Chat addToReference_addFolder_title=Add Folder to Chat model_hover_contextWindow=Context Window: +model_hover_contextWindow_title=Context Window model_hover_cost=Cost: model_hover_thinkingEffort=Thinking Effort model_hover_thinkingEffort_default_suffix={0} (default) model_hover_customModelInfo={0} is contributed by {1} using {2} +model_contextWindow_default_description=Default +model_contextWindow_longContext_description=Long context without compaction model_reasoningEffort_none=None model_reasoningEffort_low=Low model_reasoningEffort_medium=Medium diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/ModelHoverContentProvider.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/ModelHoverContentProvider.java index 011db3eac..0d561bd17 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/ModelHoverContentProvider.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/swt/ModelHoverContentProvider.java @@ -33,6 +33,7 @@ import com.microsoft.copilot.eclipse.ui.chat.services.ModelService; import com.microsoft.copilot.eclipse.ui.i18n.Messages; import com.microsoft.copilot.eclipse.ui.utils.ModelUtils; +import com.microsoft.copilot.eclipse.ui.utils.ModelUtils.ContextWindowOption; /** * Renders the full hover UI for model items in the model picker dropdown. The layout consists of the bold title header, @@ -44,10 +45,10 @@ public class ModelHoverContentProvider implements IDropdownItemHoverProvider { private static final int SECTION_SPACING = 3; private static final String POPUP_SECONDARY_TEXT_CLASS = "popup-secondary-text"; - /** Horizontal padding inside a thinking effort row, so the hover background has breathing room. */ - private static final int THINKING_EFFORT_ROW_H_PADDING = 4; - /** Vertical padding inside a thinking effort row, so the hover background has breathing room. */ - private static final int THINKING_EFFORT_ROW_V_PADDING = 2; + /** Horizontal padding inside a selectable option row, so the hover background has breathing room. */ + private static final int OPTION_ROW_H_PADDING = 4; + /** Vertical padding inside a selectable option row, so the hover background has breathing room. */ + private static final int OPTION_ROW_V_PADDING = 2; private final CopilotModel model; private final IStylingEngine stylingEngine; @@ -77,8 +78,8 @@ public void configureHover(Composite parent, DropdownItem item, Runnable closeRe addCustomModelInfoSection(parent, item.getLabel()); - addContextWindowSection(parent); addPricingSection(parent, model.getModelPickerPriceCategory()); + addContextWindowSection(parent, closeRequest); addThinkingEffortSection(parent, closeRequest); } @@ -120,7 +121,15 @@ private void addCustomModelInfoSection(Composite parent, String displayedName) { infoLabel.setLayoutData(gd); } - private void addContextWindowSection(Composite parent) { + private void addContextWindowSection(Composite parent, Runnable closeRequest) { + if (ModelUtils.supportsContextWindowSelection(model)) { + // Multiple price tiers -> let the user pick a context-window size, mirroring the thinking-effort section. + Composite optionsComposite = createOptionsSection(parent, Messages.model_hover_contextWindow_title); + populateContextWindowOptions(optionsComposite, ModelUtils.getContextWindowOptions(model), closeRequest); + return; + } + + // Single tier (or no tier metadata) -> show the context window as a static, non-selectable row. String contextWindowText = ModelUtils.getContextWindowText(model); if (StringUtils.isBlank(contextWindowText)) { return; @@ -130,6 +139,38 @@ private void addContextWindowSection(Composite parent) { addKeyValueRow(parent, Messages.model_hover_contextWindow, contextWindowText); } + private void populateContextWindowOptions(Composite optionsComposite, List options, + Runnable closeRequest) { + ModelService modelService = resolveModelService(); + // Show the user's selection when present, otherwise pre-mark the default so the hover always communicates which + // context-window size the request will use. + ContextWindowOption effective = modelService != null ? modelService.resolveEffectiveContextWindowOption(model) + : ModelUtils.resolveDefaultContextWindowOption(model); + for (ContextWindowOption option : options) { + boolean isSelected = effective != null && effective.maxContext() == option.maxContext(); + addContextWindowOption(optionsComposite, modelService, option, isSelected, closeRequest); + } + optionsComposite.requestLayout(); + } + + private void addContextWindowOption(Composite parent, ModelService modelService, ContextWindowOption option, + boolean isSelected, Runnable closeRequest) { + String displayText = ModelUtils.formatTokenCount(ModelUtils.getContextWindowDisplaySize(model, option)); + String description = ModelUtils.formatContextWindowDescription(option); + addSelectableOptionRow(parent, displayText, description, isSelected, () -> { + if (modelService == null) { + return; + } + // Persist the chosen size first, then activate this model so the picker button reflects the (model, size) pair + // the user just chose -- even when they clicked a size on a non-active model. + modelService.setSelectedContextWindow(model, option.maxContext()); + modelService.setActiveModel(model.getModelName()); + if (closeRequest != null) { + closeRequest.run(); + } + }); + } + private void addPricingSection(Composite parent, String priceCategory) { String costSymbols = ModelUtils.formatPriceCategory(priceCategory); if (StringUtils.isBlank(costSymbols)) { @@ -150,6 +191,20 @@ private void addThinkingEffortSection(Composite parent, Runnable closeRequest) { return; } + Composite options = createOptionsSection(parent, Messages.model_hover_thinkingEffort); + populateThinkingEffortOptions(options, efforts, closeRequest); + } + + /** + * Creates a titled options section shared by the context-window and thinking-effort selectors: a separator, a + * section composite carrying the secondary-text title, and an inner options composite that the caller populates + * with selectable rows via {@link #addSelectableOptionRow}. + * + * @param parent the hover composite to add the section to + * @param titleText the section title (rendered as secondary text) + * @return the inner composite that selectable option rows should be added to + */ + private Composite createOptionsSection(Composite parent, String titleText) { addSeparator(parent); Composite section = new Composite(parent, SWT.NONE); @@ -161,7 +216,7 @@ private void addThinkingEffortSection(Composite parent, Runnable closeRequest) { ((GridData) section.getLayoutData()).verticalIndent = SECTION_SPACING; section.setLayout(sectionLayout); - Label keyLabel = createSecondaryTextLabel(section, Messages.model_hover_thinkingEffort); + Label keyLabel = createSecondaryTextLabel(section, titleText); keyLabel.setLayoutData(new GridData(SWT.LEFT, SWT.NONE, true, false)); Composite options = new Composite(section, SWT.NONE); @@ -172,7 +227,7 @@ private void addThinkingEffortSection(Composite parent, Runnable closeRequest) { optionsLayout.verticalSpacing = 2; options.setLayout(optionsLayout); - populateThinkingEffortOptions(options, efforts, closeRequest); + return options; } private void populateThinkingEffortOptions(Composite options, List efforts, Runnable closeRequest) { @@ -200,12 +255,45 @@ private void addThinkingEffortOption(Composite parent, ModelService modelService return; } - // Three-column layout mirroring the model item row in DropdownPopup: a fixed-width leading icon column that - // reserves space for the selection check mark, the left-aligned effort label, and the right-aligned secondary - // description that grows to fill the remaining width. + String labelText = isDefault ? NLS.bind(Messages.model_hover_thinkingEffort_default_suffix, displayText) + : displayText; + String description = ModelUtils.formatReasoningEffortDescription(effort); + addSelectableOptionRow(parent, labelText, description, isSelected, () -> { + if (modelService == null) { + return; + } + // Persist the chosen effort first, then activate this model. Activating triggers the picker button + // to update its label/suffix so the dropdown control reflects the (model, effort) pair the user just + // chose -- even when they clicked an effort on a non-active model. + modelService.setSelectedReasoningEffort(model, effort); + modelService.setActiveModel(model.getModelName()); + // Close the entire dropdown (hover + main popup) via the host-provided callback so the user sees an + // immediate dismiss. Next time the dropdown opens, refreshBoundModelPickers (invoked from + // setSelectedReasoningEffort) has updated the model row's suffix to reflect the newly selected effort. + if (closeRequest != null) { + closeRequest.run(); + } + }); + } + + /** + * Renders a single selectable option row shared by the thinking-effort and context-window sections. The row uses a + * three-column layout mirroring the model item row in {@code DropdownPopup}: a fixed-width leading icon column that + * reserves space for the selection check mark, a left-aligned primary label, and an optional right-aligned secondary + * description that grows to fill the remaining width. The whole row participates in the shared hover/keyboard focus + * background and invokes {@code onSelect} on a left click. + * + * @param parent the composite to add the row to + * @param primaryText the primary (left-aligned) label text + * @param description the secondary (right-aligned) description, or {@code null}/blank to omit + * @param isSelected whether to show the leading check mark + * @param onSelect the action to run when the row is left-clicked + */ + private void addSelectableOptionRow(Composite parent, String primaryText, String description, boolean isSelected, + Runnable onSelect) { GridLayout rowLayout = new GridLayout(3, false); - rowLayout.marginWidth = THINKING_EFFORT_ROW_H_PADDING; - rowLayout.marginHeight = THINKING_EFFORT_ROW_V_PADDING; + rowLayout.marginWidth = OPTION_ROW_H_PADDING; + rowLayout.marginHeight = OPTION_ROW_V_PADDING; rowLayout.horizontalSpacing = 6; Composite row = new Composite(parent, SWT.NONE); row.setLayout(rowLayout); @@ -226,20 +314,17 @@ private void addThinkingEffortOption(Composite parent, ModelService modelService iconLabel.setImage(checkIcon); } - String labelText = isDefault ? NLS.bind(Messages.model_hover_thinkingEffort_default_suffix, displayText) - : displayText; Label optionLabel = new Label(row, SWT.NONE); - optionLabel.setText(labelText); + optionLabel.setText(primaryText); // Primary text color (default Label foreground); left-aligned in the middle column. optionLabel.setLayoutData(new GridData(SWT.LEFT, SWT.CENTER, false, false)); - String description = ModelUtils.formatReasoningEffortDescription(effort); Label descriptionLabel = null; if (StringUtils.isNotBlank(description)) { descriptionLabel = new Label(row, SWT.NONE); descriptionLabel.setText(description); // Right-aligned and grabs the remaining horizontal space so the description hugs the right edge of the - // hover popup while the effort label stays anchored to the left. + // hover popup while the primary label stays anchored to the left. descriptionLabel.setLayoutData(new GridData(SWT.RIGHT, SWT.CENTER, true, false)); setCssClass(descriptionLabel, POPUP_SECONDARY_TEXT_CLASS); } @@ -261,20 +346,10 @@ private void addThinkingEffortOption(Composite parent, ModelService modelService MouseAdapter clickHandler = new MouseAdapter() { @Override public void mouseDown(MouseEvent e) { - if (e.button != 1 || modelService == null) { + if (e.button != 1) { return; } - // Persist the chosen effort first, then activate this model. Activating triggers the picker button - // to update its label/suffix so the dropdown control reflects the (model, effort) pair the user just - // chose -- even when they clicked an effort on a non-active model. - modelService.setSelectedReasoningEffort(model, effort); - modelService.setActiveModel(model.getModelName()); - // Close the entire dropdown (hover + main popup) via the host-provided callback so the user sees an - // immediate dismiss. Next time the dropdown opens, refreshBoundModelPickers (invoked from - // setSelectedReasoningEffort) has updated the model row's suffix to reflect the newly selected effort. - if (closeRequest != null) { - closeRequest.run(); - } + onSelect.run(); } }; @@ -316,9 +391,9 @@ private Composite createKeyValueRow(Composite parent) { } /** - * Returns the cached check-mark image used to indicate the selected thinking effort row, lazily loaded on first - * access. The icon shares the asset used by the dropdown popup so the leading column lines up visually with the - * checkmarks shown next to selected model items. + * Returns the cached check-mark image used to indicate the selected option row (context window or thinking effort), + * lazily loaded on first access. The icon shares the asset used by the dropdown popup so the leading column lines + * up visually with the checkmarks shown next to selected model items. */ private static Image getCheckIcon() { return CopilotImages.getThemedImage(CopilotImages.IMG_DROPDOWN_COMPLETE_STATUS, diff --git a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtils.java b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtils.java index e022ecff5..32b1c893a 100644 --- a/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtils.java +++ b/com.microsoft.copilot.eclipse.ui/src/com/microsoft/copilot/eclipse/ui/utils/ModelUtils.java @@ -11,6 +11,7 @@ import org.apache.commons.lang3.StringUtils; import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel; +import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelBillingTokenPrices; 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; @@ -92,9 +93,11 @@ public static String formatBillingMultiplier(double multiplier) { * * @param model the model * @param reasoningEffort the effective reasoning effort to display, or {@code null} to omit + * @param contextWindowText the effective context-window size to display (e.g. the user-selected tier's display + * size), or {@code null} to fall back to the model's default context window * @return the suffix string, or an empty string if no suffix applies */ - public static String getModelSuffix(CopilotModel model, String reasoningEffort) { + public static String getModelSuffix(CopilotModel model, String reasoningEffort, String contextWindowText) { if (model == null) { return ""; } @@ -116,16 +119,18 @@ public static String getModelSuffix(CopilotModel model, String reasoningEffort) if (model.getBilling() != null && !model.getBilling().tokenBasedBillingEnabled()) { return formatBillingMultiplier(model.getBilling().multiplier()); } - return String.join(SUFFIX_PART_SEPARATOR, buildSuffixParts(model, reasoningEffort)); + return String.join(SUFFIX_PART_SEPARATOR, buildSuffixParts(model, reasoningEffort, contextWindowText)); } /** * 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 buildSuffixParts(CopilotModel model, String reasoningEffort) { + private static List buildSuffixParts(CopilotModel model, String reasoningEffort, String contextWindowText) { List parts = new ArrayList<>(); - addIfNotBlank(parts, getContextWindowText(model)); + // Prefer the caller-supplied effective context-window size (which reflects the user's tier selection) and fall + // back to the model's default context window when none is provided. + addIfNotBlank(parts, StringUtils.isNotBlank(contextWindowText) ? contextWindowText : 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. @@ -226,6 +231,138 @@ private static CopilotModelTokenPriceTier getDefaultTokenPriceTier(CopilotModel return model.getBilling().tokenPrices().defaultTier(); } + /** + * A selectable context-window size for a model, derived from one of its billing price tiers. + * + * @param maxContext the tier's max context (input) token threshold; the canonical value persisted for the + * selection and forwarded to the language server + * @param tier the billing price tier backing this option + * @param isDefault whether this option is the model's {@code default} price tier + */ + public record ContextWindowOption(int maxContext, CopilotModelTokenPriceTier tier, boolean isDefault) { + } + + /** + * Returns the selectable context-window sizes a model offers, one per distinct billing price-tier + * {@code maxContext} ({@code default}, {@code longContext}), in display order. When tiers have the same + * {@code maxContext}, the default tier takes precedence. Returns an empty list when the model carries no token-based + * pricing. When the {@code default} tier omits its own {@code maxContext}, falls back to the advertised + * {@code maxContextWindowTokens} so the default tier still has a size. + * + * @param model the model + * @return the ordered list of context-window options, possibly empty + */ + public static List getContextWindowOptions(CopilotModel model) { + if (model == null || model.getBilling() == null || model.getBilling().tokenPrices() == null) { + return List.of(); + } + CopilotModelBillingTokenPrices tokenPrices = model.getBilling().tokenPrices(); + Integer fallback = model.getCapabilities() != null && model.getCapabilities().limits() != null + ? model.getCapabilities().limits().maxContextWindowTokens() : null; + + List options = new ArrayList<>(); + CopilotModelTokenPriceTier defaultTier = tokenPrices.defaultTier(); + if (defaultTier != null) { + Integer max = defaultTier.maxContext() != null ? defaultTier.maxContext() : fallback; + if (max != null && max > 0) { + options.add(new ContextWindowOption(max, defaultTier, true)); + } + } + CopilotModelTokenPriceTier longContextTier = tokenPrices.longContext(); + if (longContextTier != null && longContextTier.maxContext() != null && longContextTier.maxContext() > 0) { + boolean duplicateMaxContext = options.stream() + .anyMatch(option -> option.maxContext() == longContextTier.maxContext()); + if (!duplicateMaxContext) { + options.add(new ContextWindowOption(longContextTier.maxContext(), longContextTier, false)); + } + } + return options; + } + + /** + * Returns the user-facing display size for a context-window option: when the tier advertises its own input limit + * ({@code tier.maxContext}), the full window is that limit plus the model's max output tokens; otherwise the option + * fell back to the advertised {@code maxContextWindowTokens} (already a full-window figure) and is returned as-is. + * + * @param model the model that owns the option + * @param option the context-window option + * @return the display size in tokens + */ + public static int getContextWindowDisplaySize(CopilotModel model, ContextWindowOption option) { + Integer maxOutput = model.getCapabilities() != null && model.getCapabilities().limits() != null + ? model.getCapabilities().limits().maxOutputTokens() : null; + int output = maxOutput == null ? 0 : maxOutput; + return option.tier().maxContext() != null ? option.maxContext() + output : option.maxContext(); + } + + /** + * Returns whether the user can select among multiple context-window sizes for the model. True only when the model + * advertises more than one distinct context-window size, so a single-tier model keeps its static, non-selectable + * context-window row. + * + * @param model the model + * @return {@code true} when a selectable context-window UI should be shown + */ + public static boolean supportsContextWindowSelection(CopilotModel model) { + return getContextWindowOptions(model).size() >= 2; + } + + /** + * Returns the model's default context-window option to use when the user has not made a selection: the + * {@code default} tier, falling back to the first advertised option. Returns {@code null} when the model offers no + * options. + * + * @param model the model + * @return the default option, or {@code null} when the model has no options + */ + public static ContextWindowOption resolveDefaultContextWindowOption(CopilotModel model) { + List options = getContextWindowOptions(model); + if (options.isEmpty()) { + return null; + } + for (ContextWindowOption option : options) { + if (option.isDefault()) { + return option; + } + } + return options.get(0); + } + + /** + * Returns the context-window option whose {@code maxContext} matches the given persisted value, or {@code null} + * when {@code maxContext} is {@code null} or no longer offered by the model. + * + * @param model the model + * @param maxContext the persisted {@code maxContext} value, or {@code null} + * @return the matching option, or {@code null} + */ + public static ContextWindowOption findContextWindowOption(CopilotModel model, Integer maxContext) { + if (maxContext == null) { + return null; + } + for (ContextWindowOption option : getContextWindowOptions(model)) { + if (option.maxContext() == maxContext) { + return option; + } + } + return null; + } + + /** + * Returns the localized secondary description for a context-window option: {@code Default} for the default tier and + * a "long context" description for the long-context tier. Returns {@code null} when {@code option} is {@code null}. + * + * @param option the context-window option + * @return the localized description, or {@code null} + */ + public static String formatContextWindowDescription(ContextWindowOption option) { + if (option == null) { + return null; + } + return option.isDefault() ? Messages.model_contextWindow_default_description + : Messages.model_contextWindow_longContext_description; + } + /** * Formats a token count into a compact human-readable string (e.g. 128K, 1M, 1.5M). *