Skip to content

Commit 746a697

Browse files
feat(provider): Support OpenAI Responses API (#1463)
Closes #1442
1 parent 94dfc01 commit 746a697

File tree

1 file changed

+272
-2
lines changed

1 file changed

+272
-2
lines changed

lua/CopilotChat/config/providers.lua

Lines changed: 272 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,36 @@ local function get_github_models_token(tag)
196196
return github_device_flow(tag, '178c6fc778ccc68e1d6a', 'read:user copilot')
197197
end
198198

199+
--- Helper function to extract text content from Responses API output parts
200+
---@param parts table Array of content parts from Responses API
201+
---@return string The concatenated text content
202+
local function extract_text_from_parts(parts)
203+
local content = ''
204+
if not parts or type(parts) ~= 'table' then
205+
return content
206+
end
207+
208+
for _, part in ipairs(parts) do
209+
if type(part) == 'table' then
210+
-- Handle different content types from Responses API
211+
if part.type == 'output_text' or part.type == 'text' then
212+
content = content .. (part.text or '')
213+
elseif part.output_text then
214+
-- Handle nested output_text
215+
if type(part.output_text) == 'string' then
216+
content = content .. part.output_text
217+
elseif type(part.output_text) == 'table' and part.output_text.text then
218+
content = content .. part.output_text.text
219+
end
220+
end
221+
elseif type(part) == 'string' then
222+
content = content .. part
223+
end
224+
end
225+
226+
return content
227+
end
228+
199229
---@class CopilotChat.config.providers.Options
200230
---@field model CopilotChat.client.Model
201231
---@field temperature number?
@@ -308,6 +338,10 @@ M.copilot = {
308338
return model.capabilities.type == 'chat' and model.model_picker_enabled
309339
end)
310340
:map(function(model)
341+
local supported_endpoints = model.supported_endpoints or {}
342+
-- Pre-compute whether this model uses the Responses API
343+
local use_responses = vim.tbl_contains(supported_endpoints, '/responses')
344+
311345
return {
312346
id = model.id,
313347
name = model.name,
@@ -318,6 +352,7 @@ M.copilot = {
318352
tools = model.capabilities.supports.tool_calls,
319353
policy = not model['policy'] or model['policy']['state'] == 'enabled',
320354
version = model.version,
355+
use_responses = use_responses,
321356
}
322357
end)
323358
:totable()
@@ -347,6 +382,63 @@ M.copilot = {
347382
prepare_input = function(inputs, opts)
348383
local is_o1 = vim.startswith(opts.model.id, 'o1')
349384

385+
-- Check if this model uses the Responses API
386+
if opts.model.use_responses then
387+
-- Prepare input for Responses API
388+
local instructions = nil
389+
local input_messages = {}
390+
391+
for _, msg in ipairs(inputs) do
392+
if msg.role == constants.ROLE.SYSTEM then
393+
-- Combine system messages as instructions
394+
if instructions then
395+
instructions = instructions .. '\n\n' .. msg.content
396+
else
397+
instructions = msg.content
398+
end
399+
else
400+
-- Include the message in the input array
401+
table.insert(input_messages, {
402+
role = msg.role,
403+
content = msg.content,
404+
})
405+
end
406+
end
407+
408+
-- The Responses API expects the input field to be an array of message objects
409+
local out = {
410+
model = opts.model.id,
411+
-- Always request streaming for Responses API (honor model.streaming or default to true)
412+
stream = opts.model.streaming ~= false,
413+
input = input_messages,
414+
}
415+
416+
-- Add instructions if we have any system messages
417+
if instructions then
418+
out.instructions = instructions
419+
end
420+
421+
-- Add tools for Responses API if available
422+
if opts.tools and opts.model.tools then
423+
out.tools = vim.tbl_map(function(tool)
424+
return {
425+
type = 'function',
426+
['function'] = {
427+
name = tool.name,
428+
description = tool.description,
429+
parameters = tool.schema,
430+
strict = true,
431+
},
432+
}
433+
end, opts.tools)
434+
end
435+
436+
-- Note: temperature is not supported by Responses API, so we don't include it
437+
438+
return out
439+
end
440+
441+
-- Original Chat Completion API logic
350442
inputs = vim.tbl_map(function(input)
351443
local output = {
352444
role = input.role,
@@ -411,7 +503,179 @@ M.copilot = {
411503
return out
412504
end,
413505

414-
prepare_output = function(output)
506+
prepare_output = function(output, opts)
507+
-- Check if this model uses the Responses API
508+
if opts and opts.model and opts.model.use_responses then
509+
-- Handle Responses API output format
510+
local content = ''
511+
local reasoning = ''
512+
local finish_reason = nil
513+
local total_tokens = 0
514+
local tool_calls = {}
515+
516+
-- Check for error in response
517+
if output.error then
518+
-- Surface the error as a finish reason to stop processing
519+
local error_msg = output.error
520+
if type(error_msg) == 'table' then
521+
error_msg = error_msg.message or vim.inspect(error_msg)
522+
end
523+
return {
524+
content = '',
525+
reasoning = '',
526+
finish_reason = 'error: ' .. tostring(error_msg),
527+
total_tokens = nil,
528+
tool_calls = {},
529+
}
530+
end
531+
532+
if output.type then
533+
-- This is a streaming response from Responses API
534+
if output.type == 'response.created' or output.type == 'response.in_progress' then
535+
-- In-progress events, we don't have content yet
536+
return {
537+
content = '',
538+
reasoning = '',
539+
finish_reason = nil,
540+
total_tokens = nil,
541+
tool_calls = {},
542+
}
543+
elseif output.type == 'response.completed' then
544+
-- Completed response: do NOT resend content here to avoid duplication.
545+
-- Only signal finish and capture usage/reasoning.
546+
local response = output.response
547+
if response then
548+
if response.reasoning and response.reasoning.summary then
549+
reasoning = response.reasoning.summary
550+
end
551+
if response.usage then
552+
total_tokens = response.usage.total_tokens
553+
end
554+
finish_reason = 'stop'
555+
end
556+
return {
557+
content = '',
558+
reasoning = reasoning,
559+
finish_reason = finish_reason,
560+
total_tokens = total_tokens,
561+
tool_calls = {},
562+
}
563+
elseif output.type == 'response.content.delta' or output.type == 'response.output_text.delta' then
564+
-- Streaming content delta
565+
if output.delta then
566+
if type(output.delta) == 'string' then
567+
content = output.delta
568+
elseif type(output.delta) == 'table' then
569+
if output.delta.content then
570+
content = output.delta.content
571+
elseif output.delta.output_text then
572+
content = extract_text_from_parts({ output.delta.output_text })
573+
elseif output.delta.text then
574+
content = output.delta.text
575+
end
576+
end
577+
end
578+
elseif output.type == 'response.delta' then
579+
-- Handle response.delta with nested output_text
580+
if output.delta and output.delta.output_text then
581+
content = extract_text_from_parts({ output.delta.output_text })
582+
end
583+
elseif output.type == 'response.content.done' or output.type == 'response.output_text.done' then
584+
-- Terminal content event; keep streaming open until response.completed provides usage info
585+
finish_reason = nil
586+
elseif output.type == 'response.error' then
587+
-- Handle error event
588+
local error_msg = output.error
589+
if type(error_msg) == 'table' then
590+
error_msg = error_msg.message or vim.inspect(error_msg)
591+
end
592+
finish_reason = 'error: ' .. tostring(error_msg)
593+
elseif output.type == 'response.tool_call.delta' then
594+
-- Handle tool call delta events
595+
if output.delta and output.delta.tool_calls then
596+
for _, tool_call in ipairs(output.delta.tool_calls) do
597+
local id = tool_call.id or ('tooluse_' .. (tool_call.index or 1))
598+
local existing_call = nil
599+
for _, tc in ipairs(tool_calls) do
600+
if tc.id == id then
601+
existing_call = tc
602+
break
603+
end
604+
end
605+
if not existing_call then
606+
table.insert(tool_calls, {
607+
id = id,
608+
index = tool_call.index or #tool_calls + 1,
609+
name = tool_call.name or '',
610+
arguments = tool_call.arguments or '',
611+
})
612+
else
613+
-- Append arguments
614+
existing_call.arguments = existing_call.arguments .. (tool_call.arguments or '')
615+
end
616+
end
617+
end
618+
end
619+
elseif output.response then
620+
-- Non-streaming response or final response
621+
local response = output.response
622+
623+
-- Check for error in the response object
624+
if response.error then
625+
local error_msg = response.error
626+
if type(error_msg) == 'table' then
627+
error_msg = error_msg.message or vim.inspect(error_msg)
628+
end
629+
return {
630+
content = '',
631+
reasoning = '',
632+
finish_reason = 'error: ' .. tostring(error_msg),
633+
total_tokens = nil,
634+
tool_calls = {},
635+
}
636+
end
637+
638+
if response.output and #response.output > 0 then
639+
for _, msg in ipairs(response.output) do
640+
if msg.content and #msg.content > 0 then
641+
content = content .. extract_text_from_parts(msg.content)
642+
end
643+
-- Extract tool calls from output messages
644+
if msg.tool_calls then
645+
for i, tool_call in ipairs(msg.tool_calls) do
646+
local id = tool_call.id or ('tooluse_' .. i)
647+
table.insert(tool_calls, {
648+
id = id,
649+
index = tool_call.index or i,
650+
name = tool_call.name or '',
651+
arguments = tool_call.arguments or '',
652+
})
653+
end
654+
end
655+
end
656+
end
657+
658+
if response.reasoning and response.reasoning.summary then
659+
reasoning = response.reasoning.summary
660+
end
661+
662+
if response.usage then
663+
total_tokens = response.usage.total_tokens
664+
end
665+
666+
finish_reason = response.status == 'completed' and 'stop' or nil
667+
end
668+
669+
return {
670+
content = content,
671+
reasoning = reasoning,
672+
finish_reason = finish_reason,
673+
total_tokens = total_tokens,
674+
tool_calls = tool_calls,
675+
}
676+
end
677+
678+
-- Original Chat Completion API logic
415679
local tool_calls = {}
416680

417681
local choice
@@ -458,7 +722,13 @@ M.copilot = {
458722
}
459723
end,
460724

461-
get_url = function()
725+
get_url = function(opts)
726+
-- Check if this model uses the Responses API
727+
if opts and opts.model and opts.model.use_responses then
728+
return 'https://api.githubcopilot.com/responses'
729+
end
730+
731+
-- Default to Chat Completion API
462732
return 'https://api.githubcopilot.com/chat/completions'
463733
end,
464734
}

0 commit comments

Comments
 (0)