-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: integrate Navie with GitHub Copilot
- Loading branch information
Showing
34 changed files
with
7,274 additions
and
7,060 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
30 changes: 30 additions & 0 deletions
30
plugin-copilot/src/main/java/appland/copilotChat/CopilotAppMapEnvProvider.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
package appland.copilotChat; | ||
|
||
import appland.cli.AppLandCliEnvProvider; | ||
import com.intellij.ide.plugins.PluginManager; | ||
|
||
import java.util.Map; | ||
|
||
/** | ||
* Extends the environment setup of AppMap CLI commands with the values to use this plugin's GitHub Copilot integration. | ||
* <p> | ||
* We're not using an optional plugin dependency to avoid complicating our build setup. | ||
*/ | ||
public class CopilotAppMapEnvProvider implements AppLandCliEnvProvider { | ||
@Override | ||
public Map<String, String> getEnvironment() { | ||
if (!isGitHubCopilotEnabled()) { | ||
return Map.of(); | ||
} | ||
|
||
return Map.of( | ||
"OPENAI_BASE_URL", NavieCopilotChatRequestHandler.getBaseUrl(), | ||
"APPMAP_NAVIE_COMPLETION_BACKEND", "openai", | ||
"OPENAI_API_KEY", CopilotService.RandomIdeSessionId | ||
); | ||
} | ||
|
||
private static boolean isGitHubCopilotEnabled() { | ||
return PluginManager.getLoadedPlugins().stream().anyMatch(plugin -> plugin.isEnabled() && plugin.getPluginId().equals(CopilotService.CopilotPluginId)); | ||
} | ||
} |
134 changes: 134 additions & 0 deletions
134
plugin-copilot/src/main/java/appland/copilotChat/CopilotChatSession.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,134 @@ | ||
package appland.copilotChat; | ||
|
||
import appland.copilotChat.copilot.*; | ||
import appland.utils.GsonUtils; | ||
import com.google.gson.annotations.SerializedName; | ||
import com.intellij.util.Urls; | ||
import com.intellij.util.io.HttpRequests; | ||
import org.jetbrains.annotations.NotNull; | ||
import org.jetbrains.annotations.Nullable; | ||
|
||
import java.io.BufferedReader; | ||
import java.io.IOException; | ||
import java.net.URLConnection; | ||
import java.util.List; | ||
import java.util.Map; | ||
import java.util.UUID; | ||
|
||
public final class CopilotChatSession { | ||
static final CharSequence OPEN_AI_ORGANIZATION = "github-copilot"; | ||
static final CharSequence OPEN_AI_VERSION = "2020-10-01"; | ||
|
||
private final @NotNull String endpoint; | ||
private final @NotNull Map<@NotNull String, @NotNull String> baseHeaders; | ||
|
||
public CopilotChatSession(@NotNull String endpoint, @NotNull Map<@NotNull String, @NotNull String> baseHeaders) { | ||
this.endpoint = endpoint; | ||
this.baseHeaders = baseHeaders; | ||
} | ||
|
||
public List<CopilotModelDefinition> loadModels() throws IOException { | ||
// fixme refresh Copilot token if needed | ||
|
||
record ModelsResponse(@SerializedName("data") CopilotModelDefinition[] models) { | ||
} | ||
|
||
var modelsEndpoint = Urls.newFromEncoded(endpoint + "/models"); | ||
var response = HttpRequests.request(modelsEndpoint.toExternalForm()) | ||
.accept("application/json") | ||
.tuner(connection -> applyHeaders(connection, baseHeaders)) | ||
.isReadResponseOnError(true) | ||
.readString(); | ||
return List.of(GsonUtils.GSON.fromJson(response, ModelsResponse.class).models); | ||
} | ||
|
||
public void ask(@NotNull CopilotChatResponseListener responseListener, | ||
@NotNull CopilotModelDefinition model, | ||
@NotNull List<CopilotChatRequest.Message> messages, | ||
@Nullable Double temperature, | ||
@Nullable Double topP, | ||
@Nullable Integer n) throws IOException { | ||
// fixme refresh Copilot token if needed | ||
|
||
var maxOutputTokens = model.capabilities().limits().get(CopilotCapabilityLimit.MaxOutputTokens); | ||
|
||
var appliedTemp = temperature != null ? temperature : 0.1; | ||
var copilotRequest = new CopilotChatRequest(model.id(), messages, maxOutputTokens, true, appliedTemp, topP, n); | ||
|
||
var chatEndpoint = Urls.newFromEncoded(endpoint + "/chat/completions"); | ||
HttpRequests.post(chatEndpoint.toExternalForm(), "application/json") | ||
.tuner(connection -> { | ||
applyHeaders(connection, baseHeaders); | ||
connection.setRequestProperty("openai-intent", "conversation-panel"); | ||
connection.setRequestProperty("openai-organization", "github-copilot"); | ||
connection.setRequestProperty("x-request-id", requestId()); | ||
}) | ||
.isReadResponseOnError(true) | ||
.connect(httpRequest -> { | ||
httpRequest.write(CopilotService.gson.toJson(copilotRequest)); | ||
readServerEvents(httpRequest.getReader(), responseListener); | ||
return null; | ||
}); | ||
} | ||
|
||
private static void readServerEvents(@NotNull BufferedReader response, | ||
@NotNull CopilotChatResponseListener responseListener) throws IOException { | ||
var pendingEventData = new StringBuilder(); | ||
try (response) { | ||
do { | ||
var line = response.readLine(); | ||
if (line == null) { | ||
break; | ||
} | ||
|
||
if (line.startsWith("data:")) { | ||
pendingEventData.append(line.substring("data:".length()).trim()); | ||
} else if (line.isEmpty()) { | ||
var jsonString = pendingEventData.toString(); | ||
pendingEventData.setLength(0); | ||
|
||
var chunkText = jsonString.trim(); | ||
if ("[DONE]".equals(chunkText)) { | ||
responseListener.end(); | ||
break; | ||
} | ||
|
||
processSSEChunk(responseListener, chunkText); | ||
} | ||
} while (true); | ||
} | ||
} | ||
|
||
private static void processSSEChunk(@NotNull CopilotChatResponseListener responseListener, | ||
@NotNull String sseData) { | ||
var chunk = CopilotService.gson.fromJson(sseData, CopilotChatCompletionsStreamChunk.class); | ||
|
||
var id = chunk.id(); | ||
var modelName = chunk.model(); | ||
var created = chunk.created(); | ||
for (var choice : chunk.choices()) { | ||
responseListener.onChatResponse(id, modelName, created, choice); | ||
} | ||
} | ||
|
||
private void applyHeaders(URLConnection connection, Map<String, String> headers) { | ||
for (var entry : headers.entrySet()) { | ||
connection.addRequestProperty(entry.getKey(), entry.getValue()); | ||
} | ||
} | ||
|
||
public static @NotNull String requestId() { | ||
return UUID.randomUUID().toString(); | ||
} | ||
|
||
// dummy ID to send as system_fingerprint property to OpenAI/Navie | ||
public static @NotNull String systemFingerprint = UUID.randomUUID().toString(); | ||
|
||
private record CopilotChatCompletionsStreamChunk( | ||
@SerializedName("id") @NotNull String id, | ||
@SerializedName("model") @NotNull String model, | ||
@SerializedName("created") long created, | ||
@SerializedName("choices") List<@NotNull CopilotChatResponseChoice> choices | ||
) { | ||
} | ||
} |
130 changes: 130 additions & 0 deletions
130
plugin-copilot/src/main/java/appland/copilotChat/CopilotService.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,130 @@ | ||
package appland.copilotChat; | ||
|
||
import appland.copilotChat.copilot.CopilotEndpoint; | ||
import appland.copilotChat.copilot.CopilotToken; | ||
import appland.utils.SystemProperties; | ||
import com.esotericsoftware.kryo.kryo5.util.Null; | ||
import com.google.gson.Gson; | ||
import com.google.gson.GsonBuilder; | ||
import com.google.gson.JsonObject; | ||
import com.intellij.openapi.application.ApplicationInfo; | ||
import com.intellij.openapi.application.ApplicationManager; | ||
import com.intellij.openapi.components.Service; | ||
import com.intellij.openapi.diagnostic.Logger; | ||
import com.intellij.openapi.extensions.PluginId; | ||
import com.intellij.util.io.HttpRequests; | ||
import com.knuddels.jtokkit.Encodings; | ||
import com.knuddels.jtokkit.api.Encoding; | ||
import com.knuddels.jtokkit.api.EncodingRegistry; | ||
import com.knuddels.jtokkit.api.EncodingType; | ||
import org.jetbrains.annotations.NotNull; | ||
import org.jetbrains.annotations.Nullable; | ||
|
||
import java.io.IOException; | ||
import java.nio.file.Files; | ||
import java.nio.file.Path; | ||
import java.util.Map; | ||
import java.util.UUID; | ||
|
||
@Service(Service.Level.APP) | ||
public final class CopilotService { | ||
public static CopilotService getInstance() { | ||
return ApplicationManager.getApplication().getService(CopilotService.class); | ||
} | ||
|
||
private static final Logger LOG = Logger.getInstance(CopilotService.class); | ||
|
||
public static PluginId CopilotPluginId = PluginId.getId("com.github.copilot"); | ||
|
||
private final @NotNull String machineId = UUID.randomUUID().toString(); | ||
|
||
// random ID to protect requests to our endpoint accepting requests from Navie | ||
public static final @NotNull String RandomIdeSessionId = UUID.randomUUID().toString(); | ||
|
||
final static Gson gson = new GsonBuilder().create(); | ||
|
||
private final EncodingRegistry registry = Encodings.newDefaultEncodingRegistry(); | ||
|
||
public @NotNull Encoding loadTokenizer(@NotNull String tokenizerName) { | ||
var encodingType = EncodingType.fromName(tokenizerName).orElseThrow(); | ||
return registry.getEncoding(encodingType); | ||
} | ||
|
||
public @NotNull CopilotChatSession createChat() throws IOException { | ||
var githubToken = loadGitHubOAuthToken(); | ||
var sessionId = UUID.randomUUID().toString(); | ||
|
||
var response = HttpRequests.request("https://api.github.com/copilot_internal/v2/token") | ||
.accept("application/json") | ||
.isReadResponseOnError(true) | ||
.gzip(true) | ||
.tuner(connection -> { | ||
connection.addRequestProperty("Authorization", "Bearer " + githubToken); | ||
}).readString(); | ||
|
||
var copilotToken = gson.fromJson(response, CopilotToken.class); | ||
var apiEndpoint = copilotToken.endpoints().getOrDefault(CopilotEndpoint.API, "https://api.githubcopilot.com"); | ||
|
||
var baseHeaders = Map.of( | ||
// fixme make the token updatable because it may expire | ||
"authorization", "Bearer " + copilotToken.token(), | ||
"copilot-language-server-version", GitHubCopilot.LANGUAGE_SERVER_VERSION, | ||
"editor-plugin-version", GitHubCopilot.GITHUB_COPILOT_PLUGIN_VERSION, | ||
"editor-version", getCopilotEditorVersion(), | ||
"user-agent", GitHubCopilot.USER_AGENT, | ||
"x-github-api-version", GitHubCopilot.GITHUB_API_VERSION, | ||
"vscode-machineid", machineId, | ||
"vscode-sessionid", sessionId | ||
); | ||
return new CopilotChatSession(apiEndpoint, baseHeaders); | ||
} | ||
|
||
private @Nullable String loadGitHubOAuthToken() { | ||
var configPath = findGitHubCopilotConfig(); | ||
if (configPath == null) { | ||
LOG.debug("Unable to find an existing GitHub Copilot configuration file."); | ||
return null; | ||
} | ||
|
||
try { | ||
var config = gson.fromJson(Files.readString(configPath), JsonObject.class); | ||
for (var entry : config.asMap().entrySet()) { | ||
if (entry.getKey().contains("github.com")) { | ||
return entry.getValue().getAsJsonObject().get("oauth_token").getAsString(); | ||
} | ||
} | ||
|
||
return null; | ||
} catch (IOException e) { | ||
LOG.debug("Failed to read GitHub Copilot config: " + configPath, e); | ||
return null; | ||
} | ||
} | ||
|
||
private static @Null Path findGitHubCopilotConfig() { | ||
var basePath = Path.of(SystemProperties.getUserHome(), ".config", "github-copilot"); | ||
if (!Files.isDirectory(basePath)) { | ||
return null; | ||
} | ||
|
||
var hosts = basePath.resolve("hosts.json"); | ||
if (Files.isRegularFile(hosts)) { | ||
return hosts; | ||
} | ||
|
||
var apps = basePath.resolve("apps.json"); | ||
if (Files.isRegularFile(apps)) { | ||
return apps; | ||
} | ||
|
||
return null; | ||
} | ||
|
||
private static @NotNull String getCopilotEditorVersion() { | ||
var applicationInfo = ApplicationInfo.getInstance(); | ||
return "JetBrains-" | ||
+ applicationInfo.getBuild().getProductCode() | ||
+ "/" | ||
+ applicationInfo.getBuild().withoutProductCode().asString(); | ||
} | ||
} |
33 changes: 33 additions & 0 deletions
33
plugin-copilot/src/main/java/appland/copilotChat/CopilotToOpenAICompletionsConverter.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
package appland.copilotChat; | ||
|
||
import appland.copilotChat.copilot.CopilotChatResponseChoice; | ||
import appland.copilotChat.copilot.CopilotChatResponseListener; | ||
import appland.copilotChat.openAI.OpenAIChatResponseChoice; | ||
import appland.copilotChat.openAI.OpenAIChatResponseChunk; | ||
import org.jetbrains.annotations.NotNull; | ||
|
||
import java.util.List; | ||
|
||
/** | ||
* Takes items from the stream of GitHub Copilot completions and send them out as OpenAI-compatible completion stream items. | ||
*/ | ||
abstract class CopilotToOpenAICompletionsConverter implements CopilotChatResponseListener { | ||
protected abstract void onNewChunk(@NotNull OpenAIChatResponseChunk choice); | ||
|
||
@Override | ||
public void onChatResponse(@NotNull String id, | ||
@NotNull String model, | ||
long created, | ||
@NotNull CopilotChatResponseChoice item) { | ||
var delta = new OpenAIChatResponseChunk.Delta(item.delta().role(), item.delta().content()); | ||
var choice = new OpenAIChatResponseChoice(item.index(), delta, item.finishReason()); | ||
var chunk = new OpenAIChatResponseChunk(id, | ||
"chat-completion-chunk", | ||
created, | ||
model, | ||
CopilotChatSession.systemFingerprint, | ||
List.of(choice)); | ||
|
||
onNewChunk(chunk); | ||
} | ||
} |
17 changes: 17 additions & 0 deletions
17
plugin-copilot/src/main/java/appland/copilotChat/GitHubCopilot.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
package appland.copilotChat; | ||
|
||
/** | ||
* Constants for our GitHub Copilot integration. | ||
* If the integration stops working with the remote API, we may have to update these values. | ||
* The values are fetched from chat completion requests send by the JetBrains Copilot plugin, | ||
* e.g. using a mitmweb proxy. | ||
*/ | ||
final class GitHubCopilot { | ||
public static final String LANGUAGE_SERVER_VERSION = "1.244.0"; | ||
public static final String GITHUB_COPILOT_PLUGIN_VERSION = "copilot-intellij/1.5.29.7524"; | ||
public static final String GITHUB_API_VERSION = "2023-07-07"; | ||
public static final String USER_AGENT = "GithubCopilot/1.244.0"; | ||
|
||
private GitHubCopilot() { | ||
} | ||
} |
Oops, something went wrong.