From 62e8b5b92824b9f75fe1544ff28e2f08ad3b7188 Mon Sep 17 00:00:00 2001 From: Mihamina RKTMB Date: Sat, 11 Oct 2025 23:45:42 +0300 Subject: [PATCH 1/6] feat(provider): Fix #1442 - Support OpenAI Responses API for models advertizing supporting only it --- lua/CopilotChat/client.lua | 1 + lua/CopilotChat/config/providers.lua | 239 ++++++++++++++++++++++++++- 2 files changed, 238 insertions(+), 2 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 93e1c91d..40f89873 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -52,6 +52,7 @@ ---@field streaming boolean? ---@field tools boolean? ---@field reasoning boolean? +---@field supported_endpoints table? local log = require('plenary.log') local constants = require('CopilotChat.constants') diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 5d0126e6..8a21bdac 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -196,6 +196,36 @@ local function get_github_models_token(tag) return github_device_flow(tag, '178c6fc778ccc68e1d6a', 'read:user copilot') end +--- Helper function to extract text content from Responses API output parts +---@param parts table Array of content parts from Responses API +---@return string The concatenated text content +local function extract_text_from_parts(parts) + local content = '' + if not parts or type(parts) ~= 'table' then + return content + end + + for _, part in ipairs(parts) do + if type(part) == 'table' then + -- Handle different content types from Responses API + if part.type == 'output_text' or part.type == 'text' then + content = content .. (part.text or '') + elseif part.output_text then + -- Handle nested output_text + if type(part.output_text) == 'string' then + content = content .. part.output_text + elseif type(part.output_text) == 'table' and part.output_text.text then + content = content .. part.output_text.text + end + end + elseif type(part) == 'string' then + content = content .. part + end + end + + return content +end + ---@class CopilotChat.config.providers.Options ---@field model CopilotChat.client.Model ---@field temperature number? @@ -308,6 +338,7 @@ M.copilot = { return model.capabilities.type == 'chat' and model.model_picker_enabled end) :map(function(model) + local supported_endpoints = model.supported_endpoints return { id = model.id, name = model.name, @@ -318,6 +349,7 @@ M.copilot = { tools = model.capabilities.supports.tool_calls, policy = not model['policy'] or model['policy']['state'] == 'enabled', version = model.version, + supported_endpoints = supported_endpoints, } end) :totable() @@ -347,6 +379,52 @@ M.copilot = { prepare_input = function(inputs, opts) local is_o1 = vim.startswith(opts.model.id, 'o1') + -- Check if this model supports only the /responses endpoint + local use_responses_api = opts.model.supported_endpoints + and #opts.model.supported_endpoints == 1 + and opts.model.supported_endpoints[1] == '/responses' + + if use_responses_api then + -- Prepare input for Responses API + local instructions = nil + local input_messages = {} + + for _, msg in ipairs(inputs) do + if msg.role == constants.ROLE.SYSTEM then + -- Combine system messages as instructions + if instructions then + instructions = instructions .. '\n\n' .. msg.content + else + instructions = msg.content + end + else + -- Include the message in the input array + table.insert(input_messages, { + role = msg.role, + content = msg.content, + }) + end + end + + -- The Responses API expects the input field to be an array of message objects + local out = { + model = opts.model.id, + -- Always request streaming for Responses API (honor model.streaming or default to true) + stream = opts.model.streaming ~= false, + input = input_messages, + } + + -- Add instructions if we have any system messages + if instructions then + out.instructions = instructions + end + + -- Note: temperature is not supported by Responses API, so we don't include it + + return out + end + + -- Original Chat Completion API logic inputs = vim.tbl_map(function(input) local output = { role = input.role, @@ -411,7 +489,152 @@ M.copilot = { return out end, - prepare_output = function(output) + prepare_output = function(output, opts) + -- Check if this is a Responses API response + local use_responses_api = opts + and opts.model + and opts.model.supported_endpoints + and #opts.model.supported_endpoints == 1 + and opts.model.supported_endpoints[1] == '/responses' + + if use_responses_api then + -- Handle Responses API output format + local content = '' + local reasoning = '' + local finish_reason = nil + local total_tokens = 0 + + -- Check for error in response + if output.error then + -- Surface the error as a finish reason to stop processing + local error_msg = output.error + if type(error_msg) == 'table' then + error_msg = error_msg.message or vim.inspect(error_msg) + end + return { + content = '', + reasoning = '', + finish_reason = 'error: ' .. tostring(error_msg), + total_tokens = nil, + tool_calls = {}, + } + end + + if output.type then + -- This is a streaming response from Responses API + if output.type == 'response.created' or output.type == 'response.in_progress' then + -- In-progress events, we don't have content yet + return { + content = '', + reasoning = '', + finish_reason = nil, + total_tokens = nil, + tool_calls = {}, + } + elseif output.type == 'response.completed' then + -- Completed response + local response = output.response + if response then + -- Extract content from the output array + if response.output and #response.output > 0 then + for _, msg in ipairs(response.output) do + if msg.content and #msg.content > 0 then + content = content .. extract_text_from_parts(msg.content) + end + end + end + + -- Extract reasoning if available + if response.reasoning and response.reasoning.summary then + reasoning = response.reasoning.summary + end + + -- Extract usage information + if response.usage then + total_tokens = response.usage.total_tokens + end + + finish_reason = 'stop' + end + elseif output.type == 'response.content.delta' or output.type == 'response.output_text.delta' then + -- Streaming content delta + if output.delta then + if type(output.delta) == 'string' then + content = output.delta + elseif type(output.delta) == 'table' then + if output.delta.content then + content = output.delta.content + elseif output.delta.output_text then + content = extract_text_from_parts({ output.delta.output_text }) + elseif output.delta.text then + content = output.delta.text + end + end + end + elseif output.type == 'response.delta' then + -- Handle response.delta with nested output_text + if output.delta and output.delta.output_text then + content = extract_text_from_parts({ output.delta.output_text }) + end + elseif output.type == 'response.content.done' or output.type == 'response.output_text.done' then + -- Terminal content event; keep streaming open until response.completed provides usage info + finish_reason = nil + elseif output.type == 'response.error' then + -- Handle error event + local error_msg = output.error + if type(error_msg) == 'table' then + error_msg = error_msg.message or vim.inspect(error_msg) + end + finish_reason = 'error: ' .. tostring(error_msg) + end + elseif output.response then + -- Non-streaming response or final response + local response = output.response + + -- Check for error in the response object + if response.error then + local error_msg = response.error + if type(error_msg) == 'table' then + error_msg = error_msg.message or vim.inspect(error_msg) + end + return { + content = '', + reasoning = '', + finish_reason = 'error: ' .. tostring(error_msg), + total_tokens = nil, + tool_calls = {}, + } + end + + if response.output and #response.output > 0 then + for _, msg in ipairs(response.output) do + if msg.content and #msg.content > 0 then + content = content + extract_text_from_parts(msg.content) + end + end + end + + if response.reasoning and response.reasoning.summary then + reasoning = response.reasoning.summary + end + + if response.usage then + total_tokens = response.usage.total_tokens + end + + finish_reason = response.status == 'completed' and 'stop' or nil + end + + return { + content = content, + reasoning = reasoning, + finish_reason = finish_reason, + total_tokens = total_tokens, + tool_calls = {}, -- Responses API doesn't support tools yet + } + end + + -- Original Chat Completion API logic local tool_calls = {} local choice @@ -458,7 +681,19 @@ M.copilot = { } end, - get_url = function() + get_url = function(opts) + -- Check if this model supports only the /responses endpoint + local use_responses_api = opts + and opts.model + and opts.model.supported_endpoints + and #opts.model.supported_endpoints == 1 + and opts.model.supported_endpoints[1] == '/responses' + + if use_responses_api then + return 'https://api.githubcopilot.com/responses' + end + + -- Default to Chat Completion API return 'https://api.githubcopilot.com/chat/completions' end, } From 624cf5ceb590eef7e41f8de30f34a8108ca165f3 Mon Sep 17 00:00:00 2001 From: Mihamina RKTMB Date: Sun, 12 Oct 2025 11:01:09 +0300 Subject: [PATCH 2/6] fix(provider): Fix #1442 - Comply to code review --- lua/CopilotChat/config/providers.lua | 39 ++++++++++------------------ 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 8a21bdac..525ff92c 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -338,7 +338,10 @@ M.copilot = { return model.capabilities.type == 'chat' and model.model_picker_enabled end) :map(function(model) - local supported_endpoints = model.supported_endpoints + local supported_endpoints = model.supported_endpoints or {} + -- Pre-compute whether this model uses the Responses API + local use_responses = vim.tbl_contains(supported_endpoints, '/responses') + return { id = model.id, name = model.name, @@ -349,7 +352,7 @@ M.copilot = { tools = model.capabilities.supports.tool_calls, policy = not model['policy'] or model['policy']['state'] == 'enabled', version = model.version, - supported_endpoints = supported_endpoints, + use_responses = use_responses, } end) :totable() @@ -379,12 +382,8 @@ M.copilot = { prepare_input = function(inputs, opts) local is_o1 = vim.startswith(opts.model.id, 'o1') - -- Check if this model supports only the /responses endpoint - local use_responses_api = opts.model.supported_endpoints - and #opts.model.supported_endpoints == 1 - and opts.model.supported_endpoints[1] == '/responses' - - if use_responses_api then + -- Check if this model uses the Responses API + if opts.model.use_responses then -- Prepare input for Responses API local instructions = nil local input_messages = {} @@ -490,14 +489,8 @@ M.copilot = { end, prepare_output = function(output, opts) - -- Check if this is a Responses API response - local use_responses_api = opts - and opts.model - and opts.model.supported_endpoints - and #opts.model.supported_endpoints == 1 - and opts.model.supported_endpoints[1] == '/responses' - - if use_responses_api then + -- Check if this model uses the Responses API + if opts and opts.model and opts.model.use_responses then -- Handle Responses API output format local content = '' local reasoning = '' @@ -609,7 +602,7 @@ M.copilot = { if response.output and #response.output > 0 then for _, msg in ipairs(response.output) do if msg.content and #msg.content > 0 then - content = content + extract_text_from_parts(msg.content) + content = content .. extract_text_from_parts(msg.content) end end end @@ -682,14 +675,8 @@ M.copilot = { end, get_url = function(opts) - -- Check if this model supports only the /responses endpoint - local use_responses_api = opts - and opts.model - and opts.model.supported_endpoints - and #opts.model.supported_endpoints == 1 - and opts.model.supported_endpoints[1] == '/responses' - - if use_responses_api then + -- Check if this model uses the Responses API + if opts and opts.model and opts.model.use_responses then return 'https://api.githubcopilot.com/responses' end @@ -732,6 +719,7 @@ M.github_models = { tools = vim.tbl_contains(model.capabilities, 'tool-calling'), reasoning = vim.tbl_contains(model.capabilities, 'reasoning'), version = model.version, + use_responses = false, -- GitHub Models don't use Responses API } end) :totable() @@ -746,3 +734,4 @@ M.github_models = { } return M + From 05718afa55da8d1738395d849c8298d979e6b388 Mon Sep 17 00:00:00 2001 From: Mihamina RKTMB Date: Sun, 12 Oct 2025 11:01:38 +0300 Subject: [PATCH 3/6] fix(provider): Fix #1442 - Comply to code review --- lua/CopilotChat/client.lua | 1 - lua/CopilotChat/config/providers.lua | 1 - 2 files changed, 2 deletions(-) diff --git a/lua/CopilotChat/client.lua b/lua/CopilotChat/client.lua index 40f89873..93e1c91d 100644 --- a/lua/CopilotChat/client.lua +++ b/lua/CopilotChat/client.lua @@ -52,7 +52,6 @@ ---@field streaming boolean? ---@field tools boolean? ---@field reasoning boolean? ----@field supported_endpoints table? local log = require('plenary.log') local constants = require('CopilotChat.constants') diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 525ff92c..2084dad0 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -734,4 +734,3 @@ M.github_models = { } return M - From 1e935ab71597771708f522076be1bc9adec2eecc Mon Sep 17 00:00:00 2001 From: Mihamina RKTMB Date: Sun, 12 Oct 2025 11:05:56 +0300 Subject: [PATCH 4/6] fix(provider): Fix #1442 - Comply to code review --- lua/CopilotChat/config/providers.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 2084dad0..611c2051 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -719,7 +719,6 @@ M.github_models = { tools = vim.tbl_contains(model.capabilities, 'tool-calling'), reasoning = vim.tbl_contains(model.capabilities, 'reasoning'), version = model.version, - use_responses = false, -- GitHub Models don't use Responses API } end) :totable() From acfafcfc5e31acffc04d11a3118101c06fa1a5a1 Mon Sep 17 00:00:00 2001 From: Mihamina RKTMB Date: Sun, 12 Oct 2025 12:16:05 +0300 Subject: [PATCH 5/6] fix(provider): Fix #1442 - Avoid double printing --- lua/CopilotChat/config/providers.lua | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index 611c2051..a419586b 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -525,30 +525,25 @@ M.copilot = { tool_calls = {}, } elseif output.type == 'response.completed' then - -- Completed response + -- Completed response: do NOT resend content here to avoid duplication. + -- Only signal finish and capture usage/reasoning. local response = output.response if response then - -- Extract content from the output array - if response.output and #response.output > 0 then - for _, msg in ipairs(response.output) do - if msg.content and #msg.content > 0 then - content = content .. extract_text_from_parts(msg.content) - end - end - end - - -- Extract reasoning if available if response.reasoning and response.reasoning.summary then reasoning = response.reasoning.summary end - - -- Extract usage information if response.usage then total_tokens = response.usage.total_tokens end - finish_reason = 'stop' end + return { + content = '', + reasoning = reasoning, + finish_reason = finish_reason, + total_tokens = total_tokens, + tool_calls = {}, + } elseif output.type == 'response.content.delta' or output.type == 'response.output_text.delta' then -- Streaming content delta if output.delta then From 4bd939f76973b97e6172ca14f81fa9b908155dde Mon Sep 17 00:00:00 2001 From: Mihamina RKTMB Date: Sun, 12 Oct 2025 13:41:19 +0300 Subject: [PATCH 6/6] fix(provider): Fix #1442 - Responses API do support tool calls --- lua/CopilotChat/config/providers.lua | 53 +++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/lua/CopilotChat/config/providers.lua b/lua/CopilotChat/config/providers.lua index a419586b..e5f9b603 100644 --- a/lua/CopilotChat/config/providers.lua +++ b/lua/CopilotChat/config/providers.lua @@ -418,6 +418,19 @@ M.copilot = { out.instructions = instructions end + -- Add tools for Responses API if available + if opts.tools and opts.model.tools then + out.tools = vim.tbl_map(function(tool) + return { + type = 'function', + name = tool.name, + description = tool.description, + parameters = tool.schema, + strict = true, + } + end, opts.tools) + end + -- Note: temperature is not supported by Responses API, so we don't include it return out @@ -496,6 +509,7 @@ M.copilot = { local reasoning = '' local finish_reason = nil local total_tokens = 0 + local tool_calls = {} -- Check for error in response if output.error then @@ -574,6 +588,31 @@ M.copilot = { error_msg = error_msg.message or vim.inspect(error_msg) end finish_reason = 'error: ' .. tostring(error_msg) + elseif output.type == 'response.tool_call.delta' then + -- Handle tool call delta events + if output.delta and output.delta.tool_calls then + for _, tool_call in ipairs(output.delta.tool_calls) do + local id = tool_call.id or ('tooluse_' .. (tool_call.index or 1)) + local existing_call = nil + for _, tc in ipairs(tool_calls) do + if tc.id == id then + existing_call = tc + break + end + end + if not existing_call then + table.insert(tool_calls, { + id = id, + index = tool_call.index or #tool_calls + 1, + name = tool_call.name or '', + arguments = tool_call.arguments or '', + }) + else + -- Append arguments + existing_call.arguments = existing_call.arguments .. (tool_call.arguments or '') + end + end + end end elseif output.response then -- Non-streaming response or final response @@ -599,6 +638,18 @@ M.copilot = { if msg.content and #msg.content > 0 then content = content .. extract_text_from_parts(msg.content) end + -- Extract tool calls from output messages + if msg.tool_calls then + for i, tool_call in ipairs(msg.tool_calls) do + local id = tool_call.id or ('tooluse_' .. i) + table.insert(tool_calls, { + id = id, + index = tool_call.index or i, + name = tool_call.name or '', + arguments = tool_call.arguments or '', + }) + end + end end end @@ -618,7 +669,7 @@ M.copilot = { reasoning = reasoning, finish_reason = finish_reason, total_tokens = total_tokens, - tool_calls = {}, -- Responses API doesn't support tools yet + tool_calls = tool_calls, } end