Skip to content

Commit

Permalink
feat: integrate Navie with GitHub Copilot
Browse files Browse the repository at this point in the history
  • Loading branch information
jansorg committed Dec 19, 2024
1 parent 739c240 commit f08ff35
Show file tree
Hide file tree
Showing 34 changed files with 7,274 additions and 7,060 deletions.
5,188 changes: 1,404 additions & 3,784 deletions appland-navie/dist/main.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion appland-navie/dist/main.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion appland-navie/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"typescript": "^5.6.2"
},
"dependencies": {
"@appland/components": "^4.40.1",
"@appland/components": "^4.42.0",
"highlight.js": "^11.9.0",
"url": "^0.11",
"vue": "^2.7",
Expand Down
8,185 changes: 4,913 additions & 3,272 deletions appland-navie/yarn.lock

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,10 @@ project(":") {
pluginModule(implementation(project(":plugin-gradle")))
pluginModule(implementation(project(":plugin-java")))
pluginModule(implementation(project(":plugin-maven")))
pluginModule(implementation(project(":plugin-copilot")))

// adding this for runIde support
plugin("com.github.copilot", prop("copilotPluginVersion"))

pluginVerifier()
zipSigner()
Expand Down Expand Up @@ -400,6 +404,14 @@ project(":") {
}
}

project(":plugin-copilot") {
dependencies {
implementation(project(":plugin-core"))

implementation("com.knuddels:jtokkit:1.1.0")
}
}

project(":plugin-java") {
dependencies {
implementation(project(":plugin-core"))
Expand Down
3 changes: 3 additions & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ ideVersion=2023.1
#ideVersion=2024.2
#ideVersion=243.21155.17

copilotPluginVersion=1.5.30-231
#copilotPluginVersion=1.5.30-242

kotlin.stdlib.default.dependency=false

org.gradle.jvmargs=-Dfile.encoding=UTF-8
Expand Down
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));
}
}
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 plugin-copilot/src/main/java/appland/copilotChat/CopilotService.java
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();
}
}
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);
}
}
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() {
}
}
Loading

0 comments on commit f08ff35

Please sign in to comment.