From dd2b3974b47de48a9c7b1c2f1a6d980c242a8f26 Mon Sep 17 00:00:00 2001 From: vistdn Date: Tue, 4 Aug 2026 16:30:44 +0200 Subject: [PATCH] Enable access to GitHub enterprise (ghe) solutions --- README.md | 17 ++++++- lua/CopilotChat/client.lua | 17 ++++--- lua/CopilotChat/config.lua | 5 +++ lua/CopilotChat/config/providers.lua | 67 +++++++++++++++++++++++----- lua/CopilotChat/utils.lua | 38 ++++++++++++++++ 5 files changed, 128 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 90d47c9b..b1254c9a 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ EOF **💡 Pro tip:** After typing `#`, `@`, `#buffer:`, or `#file:`, press `` to see available options. This is the fastest way to work! > [!NOTE] -> **Tab key not working?** Some plugins (e.g. `copilot.vim`) also map `` in insert mode. +> **Tab key not working?** Some plugins (e.g. `copilot.vim`) also map `` in insert mode. > To fix conflicts, disable the other plugin's `` mapping: > > ```lua @@ -462,6 +462,21 @@ Add custom AI providers: - `copilot` - GitHub Copilot (default) - `github_models` - GitHub Models (disabled by default) +## Github Enterprise + +If your employer provides access to Copilot via a Github Enterprise instance ("GHEC") you can provide the respective URLs with the following config keys: + +```lua +{ + -- github instance main address w/o protocol prefix, default: "github.com" (without "https://"). E.g. a github-enterprise address might look like this: "mycorp.ghe.com" + github_instance_url = 'mycorp.ghe.com', + -- github instance api address w/o protocol prefix, default: "api.github.com" (without "https://"). E.g.: "api.mycorp.ghe.com" + github_instance_api_url = 'api.mycorp.ghe.com', +} +``` + +(These keys are used in the default Copilot "provider", this is an alternative to defining a full custom provider) + # API Reference ## Core diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 473690aa..c6055984 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -235,19 +235,26 @@ function Client:models() ipairs(get_cached(self.provider_cache[provider_name], 'models', function() notify.publish(notify.STATUS, 'Fetching models from ' .. provider_name) - local ok, headers = pcall(self.authenticate, self, provider_name) + -- local ok, headers = pcall(self.authenticate, self, provider_name) + local ok, headers_or_err = pcall(self.authenticate, self, provider_name) if not ok then - log.warn('Failed to authenticate with ' .. provider_name .. ': ' .. headers) + -- log.warn('Failed to authenticate with ' .. provider_name .. ': ' .. headers) + log.error('Failed to authenticate with ' .. provider_name .. ': ' .. headers_or_err) + error(headers_or_err) return {} end - local ok, models = pcall(provider.get_models, headers) + -- local ok, models = pcall(provider.get_models, headers) + local ok, models_or_err = pcall(provider.get_models, headers_or_err) if not ok then - log.warn('Failed to fetch models from ' .. provider_name .. ': ' .. models) + -- log.warn('Failed to fetch models from ' .. provider_name .. ': ' .. models) + log.error('Failed to fetch models from ' .. provider_name .. ': ' .. models_or_err) + error(models_or_err) return {} end - return models or {} + -- return models or {} + return models_or_err or {} end)) do model.provider = provider_name diff --git a/lua/CopilotChat/config.lua b/lua/CopilotChat/config.lua index 96c584f3..bbc6fb6a 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -55,6 +55,8 @@ ---@field functions table? ---@field prompts table? ---@field mappings CopilotChat.config.mappings? +---@field github_instance_url string? +---@field github_instance_api_url string? return { -- Shared config starts here (can be passed to functions at runtime and configured via setup function) @@ -117,6 +119,9 @@ return { selection = 'visual', -- Selection source chat_autocomplete = true, -- Enable chat autocompletion (when disabled, requires manual `mappings.complete` trigger) + github_instance_url = 'github.com', -- github instance main address w/o protocol prefix (without "https://"). E.g. a github-enterprise address might look like this: "mycorp.ghe.com" + github_instance_api_url = 'api.github.com', -- github instance api address w/o protocol prefix (without "https://"). E.g.: "api.mycorp.ghe.com" + log_path = vim.fn.stdpath('state') .. '/CopilotChat.log', -- Default path to log file history_path = vim.fn.stdpath('data') .. '/copilotchat_history', -- Default path to stored history diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 79d8c9bd..7b74f2a0 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -8,6 +8,18 @@ local files = require('CopilotChat.utils.files') local EDITOR_VERSION = 'Neovim/' .. vim.version().major .. '.' .. vim.version().minor .. '.' .. vim.version().patch +---@class CopilotChat +---@field config CopilotChat.config.Config +---@field chat CopilotChat.ui.chat.Chat +local MC = setmetatable({}, { + __index = function(t, key) + if key == 'config' then + return require('CopilotChat.config') + end + return rawget(t, key) + end, +}) + local token_cache = nil local unsaved_token_cache = {} local function load_tokens() @@ -57,7 +69,9 @@ end ---@return string local function github_device_flow(tag, client_id, scope) local function request_device_code() - local res = curl.post('https://github.com/login/device/code', { + -- local res = curl.post('https://github.com/login/device/code', { + -- local res = utils.curl_post('https://' .. MC.config.github_instance_url .. '/login/device/code', { + local res = curl.post('https://' .. MC.config.github_instance_url .. '/login/device/code', { body = { client_id = client_id, scope = scope, @@ -72,7 +86,9 @@ local function github_device_flow(tag, client_id, scope) local function poll_for_token(device_code, interval) plenary_utils.sleep(interval * 1000) - local res = curl.post('https://github.com/login/oauth/access_token', { + -- local res = curl.post('https://github.com/login/oauth/access_token', { + -- local res = utils.curl_post('https://' .. MC.config.github_instance_url .. '/login/oauth/access_token', { + local res = curl.post('https://' .. MC.config.github_instance_url .. '/login/oauth/access_token', { json_response = true, body = { client_id = client_id, @@ -157,7 +173,8 @@ local function get_github_copilot_token(tag) local parsed_data = utils.json_decode(file_data) if parsed_data then for key, value in pairs(parsed_data) do - if string.find(key, 'github.com') and value and value.oauth_token then + -- if string.find(key, 'github.com') and value and value.oauth_token then + if string.find(key, MC.config.github_instance_url) and value and value.oauth_token then return set_token(tag, value.oauth_token, false) end end @@ -184,7 +201,8 @@ local function get_github_models_token(tag) -- loading token from gh cli if available if vim.fn.executable('gh') == 1 then - local result = utils.system({ 'gh', 'auth', 'token', '-h', 'github.com' }) + -- local result = utils.system({ 'gh', 'auth', 'token', '-h', 'github.com' }) + local result = utils.system({ 'gh', 'auth', 'token', '-h', MC.config.github_instance_url }) if result and result.code == 0 and result.stdout then local gh_token = vim.trim(result.stdout) if gh_token ~= '' and not gh_token:find('no oauth token') then @@ -534,16 +552,24 @@ end ---@field prepare_input nil|fun(inputs:CopilotChat.client.Message[], opts:CopilotChat.config.providers.Options):table,table? ---@field prepare_output nil|fun(output:table, opts:CopilotChat.config.providers.Options):CopilotChat.config.providers.Output ---@field get_url nil|fun(opts:CopilotChat.config.providers.Options):string +---@field endpoints_api string? ---@type table local M = {} M.copilot = { + endpoints_api = '', + get_headers = function() - local response, err = curl.get('https://api.github.com/copilot_internal/v2/token', { + -- local response, err = curl.get('https://api.github.com/copilot_internal/v2/token', { + local url = 'https://' .. MC.config.github_instance_api_url .. '/copilot_internal/v2/token' + log.debug('get headers - get ' .. url) + -- local response, err = utils.curl_get(url, { + local response, err = curl.get(url, { json_response = true, headers = { - ['Authorization'] = 'Token ' .. get_github_copilot_token('github_copilot'), + -- ['Authorization'] = 'Token ' .. get_github_copilot_token('github_copilot'), + ['Authorization'] = 'Token ' .. get_github_copilot_token(MC.config.github_instance_api_url), }, }) @@ -551,6 +577,20 @@ M.copilot = { error(err) end + if response.body and response.body.endpoints and response.body.endpoints.api then + log.info('get_headers ok, authenticated. Use api endpoint: ' .. response.body.endpoints.api) + M.endpoints_api = response.body.endpoints.api + else + log.error( + 'get_headers authenticated, but missing key "endpoints.api" in server response. response: ' + .. utils.to_string(response) + ) + error( + 'get_headers authenticated, but missing key "endpoints.api" in server response. Check log for details: ' + .. MC.config.log_path + ) + end + -- Resolve the base URL from the token response so that business/enterprise -- accounts using *.business.githubcopilot.com are handled automatically. local base_url = resolve_copilot_base_url(response.body) @@ -569,10 +609,13 @@ M.copilot = { end, get_info = function() - local response, err = curl.get('https://api.github.com/copilot_internal/user', { + -- local response, err = curl.get('https://api.github.com/copilot_internal/user', { + -- local response, err = utils.curl_get('https://' .. MC.config.github_instance_url .. '/copilot_internal/user', { + local response, err = curl.get('https://' .. MC.config.github_instance_url .. '/copilot_internal/user', { json_response = true, headers = { - ['Authorization'] = 'Token ' .. get_github_copilot_token('github_copilot'), + -- ['Authorization'] = 'Token ' .. get_github_copilot_token('github_copilot'), + ['Authorization'] = 'Token ' .. get_github_copilot_token(MC.config.github_instance_url), }, }) @@ -774,7 +817,10 @@ M.github_models = { end, get_models = function(headers) - local response, err = curl.get('https://models.github.ai/catalog/models', { + -- local response, err = curl.get('https://models.github.ai/catalog/models', { + log.info('getting models .. headers: ' .. utils.to_string(headers)) + -- local response, err = utils.curl_get(M.endpoints_api .. '/models', { + local response, err = curl.get(M.endpoints_api .. '/models', { json_response = true, headers = headers, }) @@ -805,7 +851,8 @@ M.github_models = { prepare_output = M.copilot.prepare_output, get_url = function() - return 'https://models.github.ai/inference/chat/completions' + -- return 'https://models.github.ai/inference/chat/completions' + return M.endpoints_api .. '/chat/completions' end, } diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index cbdced39..02146218 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -246,4 +246,42 @@ function M.split_lines(text) return vim.split(text, '\r?\n', { trimempty = false }) end +function M.to_string(tbl) + -- credit: http://lua-users.org/wiki/TableSerialization (universal tostring) + local function table_print(tt, indent, done) + done = done or {} + indent = indent or 0 + if type(tt) == 'table' then + local sb = {} + for key, value in pairs(tt) do + table.insert(sb, string.rep(' ', indent)) -- indent it + if type(value) == 'table' and not done[value] then + done[value] = true + table.insert(sb, key .. ' = {\n') + table.insert(sb, table_print(value, indent + 2, done)) + table.insert(sb, string.rep(' ', indent)) -- indent it + table.insert(sb, '}\n') + elseif 'number' == type(key) then + table.insert(sb, string.format('"%s"\n', tostring(value))) + else + table.insert(sb, string.format('%s = "%s"\n', tostring(key), tostring(value))) + end + end + return table.concat(sb) + else + return tt .. '\n' + end + end + + if 'nil' == type(tbl) then + return tostring(nil) + elseif 'table' == type(tbl) then + return table_print(tbl) + elseif 'string' == type(tbl) then + return tbl + else + return tostring(tbl) + end +end + return M