Skip to content

Commit 7686e3a

Browse files
authored
feat(adapters): add support for Gemini 3 in Copilot (#2419)
Closes #2413
1 parent b7dc2b1 commit 7686e3a

File tree

9 files changed

+206
-13
lines changed

9 files changed

+206
-13
lines changed

lua/codecompanion/adapters/http/copilot/init.lua

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,11 +164,99 @@ return {
164164
self.headers["X-Initiator"] = "agent"
165165
end
166166

167-
return handlers(self).form_messages(self, messages)
167+
local result = handlers(self).form_messages(self, messages)
168+
169+
-- Transform reasoning data and merge consecutive LLM messages for Copilot API
170+
if result.messages then
171+
local merged = {}
172+
local i = 1
173+
while i <= #result.messages do
174+
local current = result.messages[i]
175+
176+
-- Transform reasoning data
177+
if current.reasoning then
178+
if current.reasoning.content then
179+
current.reasoning_text = current.reasoning.content
180+
end
181+
if current.reasoning.opaque then
182+
current.reasoning_opaque = current.reasoning.opaque
183+
end
184+
current.reasoning = nil
185+
end
186+
187+
-- Check if next message is also from LLM and has tool_calls but no content
188+
-- This indicates tool calls that should be merged with the previous message
189+
if
190+
i < #result.messages
191+
and result.messages[i + 1].role == current.role
192+
and result.messages[i + 1].tool_calls
193+
and not result.messages[i + 1].content
194+
then
195+
-- Merge tool_calls from next message into current
196+
current.tool_calls = result.messages[i + 1].tool_calls
197+
i = i + 1 -- Skip the next message since we merged it
198+
end
199+
200+
table.insert(merged, current)
201+
i = i + 1
202+
end
203+
result.messages = merged
204+
end
205+
206+
return result
168207
end,
169208
form_tools = function(self, tools)
170209
return handlers(self).form_tools(self, tools)
171210
end,
211+
form_reasoning = function(self, data)
212+
local content = vim
213+
.iter(data)
214+
:map(function(item)
215+
return item.content
216+
end)
217+
:filter(function(content)
218+
return content ~= nil
219+
end)
220+
:join("")
221+
222+
local opaque
223+
for _, item in ipairs(data) do
224+
if item.opaque then
225+
opaque = item.opaque
226+
break
227+
end
228+
end
229+
230+
return {
231+
content = content,
232+
opaque = opaque,
233+
}
234+
end,
235+
---Copilot with Gemini 3 provides reasoning data that must be sent back in responses
236+
---@param self CodeCompanion.HTTPAdapter
237+
---@param data table
238+
---@return table
239+
parse_message_meta = function(self, data)
240+
local extra = data.extra
241+
if not extra then
242+
return data
243+
end
244+
245+
if extra.reasoning_text then
246+
data.output.reasoning = data.output.reasoning or {}
247+
data.output.reasoning.content = extra.reasoning_text
248+
end
249+
if extra.reasoning_opaque then
250+
data.output.reasoning = data.output.reasoning or {}
251+
data.output.reasoning.opaque = extra.reasoning_opaque
252+
end
253+
254+
if data.output.content == "" then
255+
data.output.content = nil
256+
end
257+
258+
return data
259+
end,
172260
tokens = function(self, data)
173261
if data and data ~= "" then
174262
local data_mod = adapter_utils.clean_streamed_data(data)

lua/codecompanion/adapters/http/openai.lua

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -148,12 +148,19 @@ return {
148148
end
149149
end
150150

151-
return {
151+
local result = {
152152
role = m.role,
153153
content = m.content,
154154
tool_calls = tool_calls,
155155
tool_call_id = m.tools and m.tools.call_id or nil,
156156
}
157+
158+
-- Adapter's like Copilot have reasoning fields that must be preserved
159+
if m.reasoning then
160+
result.reasoning = m.reasoning
161+
end
162+
163+
return result
157164
end)
158165
:totable()
159166

@@ -302,12 +309,12 @@ return {
302309
end
303310

304311
return {
305-
extra = find_extra_fields(delta),
312+
status = "success",
306313
output = {
307314
role = delta.role,
308315
content = delta.content,
309316
},
310-
status = "success",
317+
extra = find_extra_fields(delta),
311318
}
312319
end,
313320

lua/codecompanion/strategies/chat/init.lua

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,7 +1014,7 @@ function Chat:_submit_http(payload)
10141014
end
10151015

10161016
local result = adapters.call_handler(adapter, "parse_chat", data, tools)
1017-
1017+
-- TODO: Rename this to be `parse_extra` for clarity
10181018
local parse_meta = adapters.get_handler(adapter, "parse_meta")
10191019
if result and result.extra and type(parse_meta) == "function" then
10201020
result = parse_meta(adapter, result)
@@ -1029,7 +1029,7 @@ function Chat:_submit_http(payload)
10291029
end
10301030
if result.output.reasoning then
10311031
table.insert(reasoning, result.output.reasoning)
1032-
if config.display.chat.show_reasoning then
1032+
if config.display.chat.show_reasoning and result.output.reasoning.content then
10331033
self:add_buf_message({
10341034
role = config.constants.LLM_ROLE,
10351035
content = result.output.reasoning.content,
@@ -1218,10 +1218,8 @@ function Chat:done(output, reasoning, tools, meta, opts)
12181218
if vim.iter(reasoning):any(function(item)
12191219
return item and type(item) ~= "string"
12201220
end) then
1221-
-- `reasoning` contains non-trivial data structure (table, etc.). Invoke the corresponding handler.
12221221
reasoning_content = adapters.call_handler(self.adapter, "build_reasoning", reasoning)
12231222
else
1224-
-- reasoning is all string. Simply concat them.
12251223
reasoning_content = table.concat(reasoning, "")
12261224
end
12271225
end

tests/adapters/http/stubs/copilot_no_streaming.txt renamed to tests/adapters/http/copilot/stubs/copilot_no_streaming.txt

File renamed without changes.
File renamed without changes.

tests/adapters/http/stubs/copilot_tools_no_streaming.txt renamed to tests/adapters/http/copilot/stubs/copilot_tools_no_streaming.txt

File renamed without changes.

tests/adapters/http/stubs/copilot_tools_streaming.txt renamed to tests/adapters/http/copilot/stubs/copilot_tools_streaming.txt

File renamed without changes.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Discovering Update Scope**\n\nI'm currently trying to ascertain the extent of the requested update. I need to locate `quotes.lua` within the repository and understand its structure before implementing any modifications. The user's vague instructions necessitate a deeper dive into the file's content to determine the precise nature of the update.\n\n\n"}}],"created":1763682744,"id":"takfaZb8F-DrlOoPh4G-2AU","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}
2+
data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Commencing File Search**\n\nI've initiated the file search. My current focus is solely on locating `quotes.lua` within the repository. Once found, I'll analyze its structure and content. This initial step is critical to comprehend the file's current state and inform my subsequent actions.\n\n\n"}}],"created":1763682744,"id":"takfaZb8F-DrlOoPh4G-2AU","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}
3+
data: {"choices":[{"index":0,"delta":{"content":"I will search for the `quotes.lua` file to locate it within the repository.\n","role":"assistant"}}],"created":1763682744,"id":"takfaZb8F-DrlOoPh4G-2AU","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}
4+
data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{\"query\":\"**/quotes.lua\"}","name":"file_search"},"id":"call_MHxoeW9qWnVicVd6R0FkMFZ3UWw","index":0,"type":"function"}],"reasoning_opaque":"lgxMQq0m/J6cVjsaH8bbfhxHtAvK4Y+BfAp1pc+7cey9LclSxRqdco0qW23dYbFsXT0VmYKGHPstGz0rhPDPLFL833TJhOmhLMtv3Ca5zmYNYWsBg/dgrAi1uPQOat0a+d+vq+FrLKf3E8XmoE2PkKkufLWWhKLDn8VP59IJZ6vnyYcxlHB+wAYzcP0SjxhSK9Q0m4WIBSvwySe1UBjpeXx/2JKfla4dLTbk24tvPxHbdiR/dRREdn8e+80SptiFGdHLtj1Q5+X5xiqxaldL40fveGff6cFDgfcLK1LbuL64u+DoiifcEwjZcVgXCNPtzLz27TQ/SKeJRjv8PmIaf5HF+/NQpRfuMRtJq1ZdVHancLEzE2VAMhQSNkfEz2Uhh5sW3RpgSuh0vegm0hsbTozKDxpCG/pTvhJViwoHPAoOFNsDG8qvJJtnfIUL0giC6L5dgetlM99S7Nmf8u+c2wQaV7vsovht+OUlymBLyH7U3r7HX3N3soiLjXIeRloUyfPvidNm4RffU0OCe0VhTBlENd0Wo3Rrwtg8rBgoKlb+JAh6KtIpST0jozaolBQH/mDKBQn0ppv+VgaqglLS4FRg6oo4n2ftK3iPTYKwEVj5uwVPVmHr/LKWqQPxPKzDKB0EBh370YH/zB2ZZ+GWaThKqa64IIzdTTiOtA9roT4tlyYXHJ0ObDx3wlPrTAJ7xJANjzIYd21tED6mlaUM/OxZ4VICgmMo822Cyatolx6d4l/gZdjNR+IzL9QIDedyxD5vkd7VkjZB9Vty3layMzfsJCf31Yf0RZsYtv6DCvzyNbnO9kUcliRoE+jmn0MLMxVFtcZcY8Sq4XrtXO4AhDNCRL5vA4lylNXyyBEqLqPcHN4efKGP0bbVlbN2kBGw8c1qyva1W6B80fnGDguCxKWNeOFfOvKuTRvCptAqItkMvTk6sCexEeRie405Fn4xwkZWg3SlbxuxXzVbGD1mNVRpVnBYcUEVwT/U7FxwjD8Q4OiAiDqGUcbbldR3ZfJvZ8Dc93L+EmaBEKRgVM7jGnZEW1h0YIDBnnII6a2379XgLvRHtLW0Gv76xWzkFA7zIIBn/EG4CTMhh7SznQORgS7aieViOHVA74+fPg/uUp4wPNsGVgiJIHq+Wgk4KKlXFqSZ9Tzk11o/Vd49+TmR/ZPHyOuOn3YM1eSPFQZZ6ZmjWyuzDSKBQcKFc6HqHOiklUAIHOnE4utNemWur3iFGjL764cLWofrj0icDpiNIrT8bOxyfDp8AKLYrtxS8zhQCVLiiuSeUj+m+WoZiFK/uB2rxwQWzXu78A1zAYlzOHJAjAGNsl4LDm6uXjXOmR0m1zsYT+7UOMVeZYrncfEWAxxQ4KTQe53rL3L118vkBOPKEVaTf+XlGApyBHYVquSJNEJiGHn96pZo5nHWF73f1wC9kzJLn64Vq2R8aZylmtZAegqa/4+bo89c67qV"}}],"created":1763682744,"id":"takfaZb8F-DrlOoPh4G-2AU","usage":{"completion_tokens":31,"prompt_tokens":6009,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":6132,"reasoning_tokens":92},"model":"gemini-3-pro-preview"}

tests/adapters/http/copilot/test_copilot.lua

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -210,11 +210,33 @@ T["Copilot adapter"]["it can form tools to be sent to the API"] = function()
210210
h.eq({ tools = { weather } }, adapter.handlers.form_tools(adapter, tools))
211211
end
212212

213+
T["Copilot adapter"]["forms reasoning output"] = function()
214+
local messages = {
215+
{
216+
content = "Content 1\n",
217+
},
218+
{
219+
content = "Content 2\n",
220+
},
221+
{
222+
content = "Content 3\n",
223+
},
224+
{
225+
opaque = "gj5HGhYVIOT",
226+
},
227+
}
228+
229+
local form_reasoning = adapter.handlers.form_reasoning(adapter, messages)
230+
231+
h.eq("Content 1\nContent 2\nContent 3\n", form_reasoning.content)
232+
h.eq("gj5HGhYVIOT", form_reasoning.opaque)
233+
end
234+
213235
T["Copilot adapter"]["Streaming"] = new_set()
214236

215237
T["Copilot adapter"]["Streaming"]["can output streamed data into the chat buffer"] = function()
216238
local output = ""
217-
local lines = vim.fn.readfile("tests/adapters/http/stubs/copilot_streaming.txt")
239+
local lines = vim.fn.readfile("tests/adapters/http/copilot/stubs/copilot_streaming.txt")
218240
for _, line in ipairs(lines) do
219241
local chat_output = adapter.handlers.chat_output(adapter, line)
220242
if chat_output and chat_output.output.content then
@@ -227,7 +249,7 @@ end
227249

228250
T["Copilot adapter"]["Streaming"]["can process tools"] = function()
229251
local tools = {}
230-
local lines = vim.fn.readfile("tests/adapters/http/stubs/copilot_tools_streaming.txt")
252+
local lines = vim.fn.readfile("tests/adapters/http/copilot/stubs/copilot_tools_streaming.txt")
231253
for _, line in ipairs(lines) do
232254
adapter.handlers.chat_output(adapter, line, tools)
233255
end
@@ -247,6 +269,80 @@ T["Copilot adapter"]["Streaming"]["can process tools"] = function()
247269
h.eq(tool_output, tools)
248270
end
249271

272+
T["Copilot adapter"]["Streaming"]["stores reasoning_opaque in extra"] = function()
273+
local lines = vim.fn.readfile("tests/adapters/http/copilot/stubs/copilot_tools_streaming_reasoning.txt")
274+
275+
local output = {}
276+
for _, line in ipairs(lines) do
277+
local chat_output = adapter.handlers.chat_output(adapter, line)
278+
if chat_output then
279+
table.insert(output, adapter.handlers.parse_message_meta(adapter, chat_output))
280+
end
281+
end
282+
283+
h.expect_starts_with("lgxMQq0m/J6cVjsaH8bbfhxHtAvK4Y", output[#output].output.reasoning.opaque)
284+
end
285+
286+
T["Copilot adapter"]["Streaming"]["can send reasoning opaque back in messages"] = function()
287+
local input = {
288+
{
289+
content = "Search for quotes.lua",
290+
role = "user",
291+
},
292+
{
293+
content = "LLM's response here",
294+
reasoning = {
295+
content = "Some reasoning here",
296+
opaque = "SzZZSfDxyWB",
297+
},
298+
role = "llm",
299+
},
300+
{
301+
role = "llm",
302+
tools = {
303+
calls = {
304+
{
305+
_index = 0,
306+
["function"] = {
307+
arguments = '{"dryRun":false,"edits":[{"newText":" \\"The only limit to our realization of tomorrow will be our doubts of today. - Franklin D. Roosevelt\\",\\n \\"Talk is cheap. Show me the code. - Linus Torvalds\\",\\n }","oldText":" \\"The only limit to our realization of tomorrow will be our doubts of today. - Franklin D. Roosevelt\\",\\n }","replaceAll":false}],"explanation":"Adding a new quote by Linus Torvalds to the end of the list in quotes.lua.","filepath":"quotes.lua","mode":"append"}',
308+
name = "insert_edit_into_file",
309+
},
310+
id = "call_MHxYMWV1QmRVTng0Znd2b0tyM0Y",
311+
type = "function",
312+
},
313+
},
314+
},
315+
},
316+
}
317+
318+
local expected = {
319+
{
320+
content = "Search for quotes.lua",
321+
role = "user",
322+
},
323+
{
324+
content = "LLM's response here",
325+
role = "llm",
326+
reasoning_opaque = "SzZZSfDxyWB",
327+
reasoning_text = "Some reasoning here",
328+
tool_calls = {
329+
{
330+
["function"] = {
331+
arguments = '{"dryRun":false,"edits":[{"newText":" \\"The only limit to our realization of tomorrow will be our doubts of today. - Franklin D. Roosevelt\\",\\n \\"Talk is cheap. Show me the code. - Linus Torvalds\\",\\n }","oldText":" \\"The only limit to our realization of tomorrow will be our doubts of today. - Franklin D. Roosevelt\\",\\n }","replaceAll":false}],"explanation":"Adding a new quote by Linus Torvalds to the end of the list in quotes.lua.","filepath":"quotes.lua","mode":"append"}',
332+
name = "insert_edit_into_file",
333+
},
334+
id = "call_MHxYMWV1QmRVTng0Znd2b0tyM0Y",
335+
type = "function",
336+
},
337+
},
338+
},
339+
}
340+
341+
local output = adapter.handlers.form_messages(adapter, input)
342+
343+
h.eq({ messages = expected }, output)
344+
end
345+
250346
T["Copilot adapter"]["No Streaming"] = new_set({
251347
hooks = {
252348
pre_case = function()
@@ -260,7 +356,7 @@ T["Copilot adapter"]["No Streaming"] = new_set({
260356
})
261357

262358
T["Copilot adapter"]["No Streaming"]["can output for the chat buffer"] = function()
263-
local data = vim.fn.readfile("tests/adapters/http/stubs/copilot_no_streaming.txt")
359+
local data = vim.fn.readfile("tests/adapters/http/copilot/stubs/copilot_no_streaming.txt")
264360
data = table.concat(data, "\n")
265361

266362
-- Match the format of the actual request
@@ -273,7 +369,7 @@ T["Copilot adapter"]["No Streaming"]["can output for the chat buffer"] = functio
273369
end
274370

275371
T["Copilot adapter"]["No Streaming"]["can process tools"] = function()
276-
local data = vim.fn.readfile("tests/adapters/http/stubs/copilot_tools_no_streaming.txt")
372+
local data = vim.fn.readfile("tests/adapters/http/copilot/stubs/copilot_tools_no_streaming.txt")
277373
data = table.concat(data, "\n")
278374

279375
local tools = {}
@@ -297,7 +393,7 @@ T["Copilot adapter"]["No Streaming"]["can process tools"] = function()
297393
end
298394

299395
T["Copilot adapter"]["No Streaming"]["can output for the inline assistant"] = function()
300-
local data = vim.fn.readfile("tests/adapters/http/stubs/copilot_no_streaming.txt")
396+
local data = vim.fn.readfile("tests/adapters/http/copilot/stubs/copilot_no_streaming.txt")
301397
data = table.concat(data, "\n")
302398

303399
-- Match the format of the actual request

0 commit comments

Comments
 (0)