From 14a2ab32a9f773b34dd3433c82b27a96d6880439 Mon Sep 17 00:00:00 2001 From: Simon Massa Date: Tue, 19 Aug 2025 13:29:39 +0200 Subject: [PATCH 1/4] for api requests after authentication, use url provided by service ('endpoints.api') --- lua/CopilotChat/config/providers.lua | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index b2a03eea..2666e303 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -205,11 +205,14 @@ end ---@field prepare_input nil|fun(inputs:table, opts:CopilotChat.config.providers.Options):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 = utils.curl_get('https://api.github.com/copilot_internal/v2/token', { json_response = true, @@ -222,6 +225,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 + return { ['Authorization'] = 'Bearer ' .. response.body.token, ['Editor-Version'] = EDITOR_VERSION, @@ -283,6 +300,7 @@ M.copilot = { get_models = function(headers) local response, err = utils.curl_get('https://api.githubcopilot.com/models', { + local response, err = utils.curl_get(M.endpoints_api .. '/models', { json_response = true, headers = headers, }) @@ -322,7 +340,7 @@ M.copilot = { for _, model in ipairs(models) do if not model.policy then - utils.curl_post('https://api.githubcopilot.com/models/' .. model.id .. '/policy', { + utils.curl_post(M.endpoints_api .. '/models/' .. model.id .. '/policy', { headers = headers, json_request = true, body = { state = 'enabled' }, @@ -448,7 +466,7 @@ M.copilot = { end, get_url = function() - return 'https://api.githubcopilot.com/chat/completions' + return M.endpoints_api .. '/chat/completions' end, } From 6aa43e06e18c80fdb67325387e46d74d51726458 Mon Sep 17 00:00:00 2001 From: Simon Massa Date: Tue, 19 Aug 2025 13:30:46 +0200 Subject: [PATCH 2/4] consider authentication failure as critical error, report with appropriate message to user in chat pane --- lua/CopilotChat/client.lua | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index f2dba5b0..0affaa91 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -250,19 +250,21 @@ 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_or_err = pcall(self.authenticate, self, provider_name) if not ok then - 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_or_err = pcall(provider.get_models, headers_or_err) if not ok then - 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_err or {} end)) do model.provider = provider_name From f9fa41da550ef5a390d5b4486b0e3a40e4385f7e Mon Sep 17 00:00:00 2001 From: Simon Massa Date: Tue, 19 Aug 2025 13:32:00 +0200 Subject: [PATCH 3/4] whitespace fix --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 62d03ee5..a07c21ca 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ When you use `@copilot`, the LLM can call functions like `glob`, `file`, `gitdif | - | `gh` | Show help message | > [!WARNING] -> Some plugins (e.g. `copilot.vim`) may also map common keys like `` in insert mode. +> Some plugins (e.g. `copilot.vim`) may also map common keys like `` in insert mode. > To avoid conflicts, disable Copilot's default `` mapping with: > > ```lua From 449d3a4d698a06fb8d97693fffc9d5b4b8ff15fd Mon Sep 17 00:00:00 2001 From: Simon Massa Date: Tue, 19 Aug 2025 13:33:51 +0200 Subject: [PATCH 4/4] make default github provider configureable w/r to api endpoints (allow connecting to other github instances, e.g. ghec) --- README.md | 15 +++++++++++ lua/CopilotChat/config.lua | 5 ++++ lua/CopilotChat/config/providers.lua | 33 +++++++++++++++++------- lua/CopilotChat/utils.lua | 38 ++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a07c21ca..86cda7dd 100644 --- a/README.md +++ b/README.md @@ -404,6 +404,21 @@ Add custom AI providers: - `copilot` - GitHub Copilot (default) - `github_models` - GitHub Marketplace 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/config.lua b/lua/CopilotChat/config.lua index a3fce3a9..89d407ef 100644 --- a/lua/CopilotChat/config.lua +++ b/lua/CopilotChat/config.lua @@ -49,6 +49,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) @@ -102,6 +104,9 @@ return { 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 2666e303..186050b4 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -2,9 +2,22 @@ local constants = require('CopilotChat.constants') local notify = require('CopilotChat.notify') local utils = require('CopilotChat.utils') local plenary_utils = require('plenary.async.util') +local log = require('plenary.log') 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() @@ -50,7 +63,7 @@ end ---@return string local function github_device_flow(tag, client_id, scope) local function request_device_code() - local res = utils.curl_post('https://github.com/login/device/code', { + local res = utils.curl_post('https://' .. MC.config.github_instance_url .. '/login/device/code', { body = { client_id = client_id, scope = scope, @@ -66,7 +79,7 @@ local function github_device_flow(tag, client_id, scope) while true do plenary_utils.sleep(interval * 1000) - local res = utils.curl_post('https://github.com/login/oauth/access_token', { + local res = utils.curl_post('https://' .. MC.config.github_instance_url .. '/login/oauth/access_token', { body = { client_id = client_id, device_code = device_code, @@ -146,7 +159,7 @@ 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, MC.config.github_instance_url) and value and value.oauth_token then return set_token(tag, value.oauth_token, false) end end @@ -173,7 +186,7 @@ local function get_github_models_token(tag) -- loading token from gh cli if available if vim.fn.executable('gh') == 0 then - 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 @@ -214,10 +227,12 @@ M.copilot = { endpoints_api = '', get_headers = function() - local response, err = utils.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, { json_response = true, headers = { - ['Authorization'] = 'Token ' .. get_github_copilot_token('github_copilot'), + ['Authorization'] = 'Token ' .. get_github_copilot_token(MC.config.github_instance_api_url), }, }) @@ -249,10 +264,10 @@ M.copilot = { end, get_info = function(headers) - local response, err = utils.curl_get('https://api.github.com/copilot_internal/user', { + local response, err = utils.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(MC.config.github_instance_url), }, }) @@ -299,7 +314,7 @@ M.copilot = { end, get_models = function(headers) - local response, err = utils.curl_get('https://api.githubcopilot.com/models', { + log.info('getting models .. headers: ' .. utils.to_string(headers)) local response, err = utils.curl_get(M.endpoints_api .. '/models', { json_response = true, headers = headers, diff --git a/lua/CopilotChat/utils.lua b/lua/CopilotChat/utils.lua index b9d1ade0..f0c80ed2 100644 --- a/lua/CopilotChat/utils.lua +++ b/lua/CopilotChat/utils.lua @@ -450,6 +450,44 @@ M.curl_post = async.wrap(function(url, opts, callback) curl.post(url, args) end, 3) +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 + local function filter_files(files, max_count) local filetype = require('plenary.filetype')