Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

package com.microsoft.copilot.eclipse.core.lsp.protocol;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;

import org.junit.jupiter.api.Test;

import com.google.gson.Gson;
import com.microsoft.copilot.eclipse.core.lsp.protocol.CopilotModel.CopilotModelCustomModel;

/**
* Tests for {@link CopilotModel}, focusing on the {@code customModel} metadata that carries organization- and
* enterprise-contributed custom (BYOK) models exposed through {@code copilot/models}.
*/
class CopilotModelTests {

private final Gson gson = new Gson();

@Test
void testDeserialize_populatesCustomModelMetadata() {
String json = """
{
"modelFamily": "custom",
"modelName": "Sonnet (Org)",
"id": "claude-sonnet-org",
"scopes": ["chat-panel", "agent-panel"],
"customModel": {
"keyName": "Contoso Azure Key",
"ownerName": "Contoso",
"ownerType": "organization",
"provider": "azure"
}
}
""";

CopilotModel model = gson.fromJson(json, CopilotModel.class);

assertNotNull(model.getCustomModel());
assertEquals("Contoso Azure Key", model.getCustomModel().keyName());
assertEquals("Contoso", model.getCustomModel().ownerName());
assertEquals("organization", model.getCustomModel().ownerType());
assertEquals("azure", model.getCustomModel().provider());
}

@Test
void testDeserialize_customModelAbsentIsNull() {
String json = """
{
"modelFamily": "gpt-4o",
"modelName": "GPT-4o",
"id": "gpt-4o",
"scopes": ["chat-panel"]
}
""";

CopilotModel model = gson.fromJson(json, CopilotModel.class);

assertNull(model.getCustomModel());
}

@Test
void testEqualsAndHashCode_accountForCustomModel() {
CopilotModel base = new CopilotModel();
base.setId("claude-sonnet-org");
base.setModelName("Sonnet (Org)");
base.setCustomModel(new CopilotModelCustomModel("Contoso Azure Key", "Contoso", "organization", "azure"));

CopilotModel same = new CopilotModel();
same.setId("claude-sonnet-org");
same.setModelName("Sonnet (Org)");
same.setCustomModel(new CopilotModelCustomModel("Contoso Azure Key", "Contoso", "organization", "azure"));

CopilotModel differentOwner = new CopilotModel();
differentOwner.setId("claude-sonnet-org");
differentOwner.setModelName("Sonnet (Org)");
differentOwner.setCustomModel(new CopilotModelCustomModel("Contoso Azure Key", "Fabrikam", "organization",
"azure"));

assertEquals(base, same);
assertEquals(base.hashCode(), same.hashCode());
assertNotEquals(base, differentOwner);
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 6 additions & 6 deletions com.microsoft.copilot.eclipse.core/copilot-agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@
"postinstall": "node copy-binaries.js"
},
"dependencies": {
"@github/copilot-language-server": "1.502.5",
"@github/copilot-language-server-win32-x64": "1.502.5",
"@github/copilot-language-server-darwin-x64": "1.502.5",
"@github/copilot-language-server-darwin-arm64": "1.502.5",
"@github/copilot-language-server-linux-x64": "1.502.5",
"@github/copilot-language-server-linux-arm64": "1.502.5"
"@github/copilot-language-server": "1.509.5",
"@github/copilot-language-server-win32-x64": "1.509.5",
"@github/copilot-language-server-darwin-x64": "1.509.5",
"@github/copilot-language-server-darwin-arm64": "1.509.5",
"@github/copilot-language-server-linux-x64": "1.509.5",
"@github/copilot-language-server-linux-arm64": "1.509.5"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -708,19 +708,15 @@ private String getModelName(CopilotModel activeModel) {
}

/**
* Builds the {@link ModelInfo} payload to forward with chat requests. Returns {@code null} when no reasoning effort
* is available so we do not send redundant {@code id}/{@code providerName} fields ahead of the future migration
* away from the legacy {@code model}/{@code modelProviderName} fields. Today the language server only consumes
* {@code modelInfo.reasoningEffort}, so suppressing the payload when there is nothing meaningful to forward keeps
* the protocol surface minimal and avoids implicit behaviour changes if the server starts honouring id/providerName
* before the client migration lands.
* Builds the {@link ModelInfo} payload for chat requests. Always sends the concrete model id (which the server
* 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) {
if (StringUtils.isBlank(reasoningEffort)) {
if (activeModel == null || StringUtils.isBlank(activeModel.getId())) {
return null;
}
String id = activeModel != null ? activeModel.getId() : null;
String providerName = activeModel != null ? activeModel.getProviderName() : null;
return new ModelInfo(id, providerName, reasoningEffort, null);
String effort = StringUtils.isBlank(reasoningEffort) ? null : reasoningEffort;
return new ModelInfo(activeModel.getId(), activeModel.getProviderName(), effort, null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public class CopilotModel {
private boolean isChatFallback;
private CopilotModelCapabilities capabilities;
private CopilotModelBilling billing;
private CopilotModelCustomModel customModel;
private String degradationReason;
private String providerName;
private String modelPickerCategory;
Expand Down Expand Up @@ -158,6 +159,28 @@ public String toString() {
}
}

/**
* Metadata describing a custom (BYOK) model that is contributed to the user by an organization or enterprise. When
* present, the model is served through a key that the owner configured, so it is surfaced in the model picker
* automatically without the user having to register their own API key.
*
* @param keyName the display name of the API key the model is served through
* @param ownerName the name of the organization, enterprise, or user that contributed the model
* @param ownerType the type of the owner (e.g. {@code organization}, {@code enterprise}, {@code user})
* @param provider the underlying model provider (e.g. {@code azure}, {@code openai})
*/
public record CopilotModelCustomModel(String keyName, String ownerName, String ownerType, String provider) {
@Override
public String toString() {
ToStringBuilder builder = new ToStringBuilder(this);
builder.append("keyName", keyName);
builder.append("ownerName", ownerName);
builder.append("ownerType", ownerType);
builder.append("provider", provider);
return builder.toString();
}
}

public String getModelFamily() {
return modelFamily;
}
Expand Down Expand Up @@ -246,6 +269,14 @@ public void setBilling(CopilotModelBilling billing) {
this.billing = billing;
}

public CopilotModelCustomModel getCustomModel() {
return customModel;
}

public void setCustomModel(CopilotModelCustomModel customModel) {
this.customModel = customModel;
}

public String getDegradationReason() {
return degradationReason;
}
Expand Down Expand Up @@ -300,6 +331,7 @@ public boolean equals(Object obj) {
}
CopilotModel other = (CopilotModel) obj;
return Objects.equals(billing, other.billing) && Objects.equals(capabilities, other.capabilities)
&& Objects.equals(customModel, other.customModel)
&& Objects.equals(degradationReason, other.degradationReason) && Objects.equals(id, other.id)
&& isChatDefault == other.isChatDefault && isChatFallback == other.isChatFallback
&& Objects.equals(modelFamily, other.modelFamily) && Objects.equals(modelName, other.modelName)
Expand All @@ -312,8 +344,9 @@ public boolean equals(Object obj) {

@Override
public int hashCode() {
return Objects.hash(billing, capabilities, degradationReason, id, isChatDefault, isChatFallback, modelFamily,
modelName, modelPickerCategory, modelPickerPriceCategory, modelPolicy, preview, providerName, scopes, vendor);
return Objects.hash(billing, capabilities, customModel, degradationReason, id, isChatDefault, isChatFallback,
modelFamily, modelName, modelPickerCategory, modelPickerPriceCategory, modelPolicy, preview, providerName,
scopes, vendor);
}

@Override
Expand All @@ -330,6 +363,7 @@ public String toString() {
builder.append("isChatFallback", isChatFallback);
builder.append("capabilities", capabilities);
builder.append("billing", billing);
builder.append("customModel", customModel);
builder.append("degradationReason", degradationReason);
builder.append("providerName", providerName);
builder.append("modelPickerCategory", modelPickerCategory);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
* Optional model metadata associated with a conversation turn. Mirrors the {@code modelInfo} field on the
* {@code conversation/create} and {@code conversation/turn} LSP requests and responses.
*
* <p>The {@code id} and {@code providerName} fields are reserved for a future migration away from the legacy
* {@code model} / {@code modelProviderName} fields and should not be relied upon today. The {@code reasoningEffort}
* field carries the user-selected reasoning effort level (e.g. {@code low}, {@code medium}, {@code high}) when the
* model surfaces selectable effort levels.
* <p>The language server resolves the turn's model from {@code id} (and {@code providerName} for BYOK models) in
* preference to the legacy {@code model} / {@code modelProviderName} request fields, so {@code id} should carry the
* concrete model id of the user-selected model. The {@code reasoningEffort} field carries the user-selected reasoning
* effort level (e.g. {@code low}, {@code medium}, {@code high}) when the model surfaces selectable effort levels.
*
* @param id model identifier (optional)
* @param providerName provider name (optional)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
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.CopilotScope;
import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokModel;
import com.microsoft.copilot.eclipse.core.lsp.protocol.byok.ByokModelCapabilities;
Expand Down Expand Up @@ -150,6 +151,27 @@ void testSupportsReasoningEffortLevel_falseWhenCapabilitiesMissing() {
assertFalse(ModelUtils.supportsReasoningEffortLevel(null));
}

@Test
void testGetModelSuffix_customModelUsesProvider() {
// Organization-contributed custom models arrive without a providerName but carry their provider in the
// custom-model metadata; the suffix should surface that provider like a BYOK model.
CopilotModel model = new CopilotModel();
model.setModelName("Sonnet (Org)");
model.setCustomModel(new CopilotModelCustomModel("Contoso Azure Key", "Contoso", "organization", "Azure"));

assertEquals("Azure", ModelUtils.getModelSuffix(model, null));
Comment thread
xinyi-gong marked this conversation as resolved.
}

@Test
void testGetModelSuffix_providerNameTakesPrecedenceOverCustomModel() {
CopilotModel model = new CopilotModel();
model.setModelName("GPT-4o");
model.setProviderName("OpenAI");
model.setCustomModel(new CopilotModelCustomModel("Key", "Contoso", "organization", "Azure"));

assertEquals("OpenAI", ModelUtils.getModelSuffix(model, null));
}

@Test
void testIsAutoModel() {
CopilotModel auto = new CopilotModel();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ public static List<DropdownItemGroup> build(Map<String, CopilotModel> modelMap,
List<CopilotModel> customModels = new ArrayList<>();

for (CopilotModel model : modelMap.values()) {
if (model.getProviderName() != null) {
if (model.getProviderName() != null || model.getCustomModel() != null) {
customModels.add(model);
} else if (model.getBilling() != null) {
if (model.getBilling().isPremium()) {
Expand Down
Loading
Loading