Skip to content

Commit 5791127

Browse files
author
Mihamina RKTMB
committed
feat(provider): add /responses endpoint support for Copilot models\n\n- Detect models that only support /responses and mark as responses_only\n- Send requests with {model, stream, instructions, input} format\n- Parse /responses SSE events (response.output_text.delta, response.completed)\n- Keep backward compatibility with /chat/completions
1 parent f68deee commit 5791127

2 files changed

Lines changed: 118 additions & 1 deletion

File tree

lua/CopilotChat/client.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@
5252
---@field streaming boolean?
5353
---@field tools boolean?
5454
---@field reasoning boolean?
55+
---@field supported_endpoints string[]?
56+
---@field responses_only boolean?
5557

5658
local log = require('plenary.log')
5759
local constants = require('CopilotChat.constants')

lua/CopilotChat/config/providers.lua

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,10 @@ M.copilot = {
308308
return model.capabilities.type == 'chat' and model.model_picker_enabled
309309
end)
310310
:map(function(model)
311+
local supported_endpoints = model.supported_endpoints
312+
local responses_only = supported_endpoints
313+
and #supported_endpoints == 1
314+
and supported_endpoints[1] == '/responses'
311315
return {
312316
id = model.id,
313317
name = model.name,
@@ -318,6 +322,8 @@ M.copilot = {
318322
tools = model.capabilities.supports.tool_calls,
319323
policy = not model['policy'] or model['policy']['state'] == 'enabled',
320324
version = model.version,
325+
supported_endpoints = supported_endpoints,
326+
responses_only = responses_only,
321327
}
322328
end)
323329
:totable()
@@ -347,6 +353,66 @@ M.copilot = {
347353
prepare_input = function(inputs, opts)
348354
local is_o1 = vim.startswith(opts.model.id, 'o1')
349355

356+
-- Handle OpenAI /responses style models
357+
if opts.model.responses_only then
358+
local system_prompt = nil
359+
for _, m in ipairs(inputs) do
360+
if m.role == constants.ROLE.SYSTEM and not utils.empty(m.content) then
361+
system_prompt = m.content
362+
break
363+
end
364+
end
365+
366+
local function is_resource_msg(m)
367+
if m.role ~= constants.ROLE.USER or not m.content then
368+
return false
369+
end
370+
return vim.startswith(m.content, '# ') or m.content:find('```', 1, true) ~= nil
371+
end
372+
373+
-- Collect context resources
374+
local context_blocks = {}
375+
for _, m in ipairs(inputs) do
376+
if is_resource_msg(m) then
377+
table.insert(context_blocks, m.content)
378+
end
379+
end
380+
381+
-- Build a single-turn input from conversation history (excluding resource blocks)
382+
local lines = {}
383+
if #context_blocks > 0 then
384+
table.insert(lines, '# Context:')
385+
table.insert(lines, table.concat(context_blocks, '\n\n'))
386+
table.insert(lines, '')
387+
end
388+
389+
local i = 1
390+
while i <= #inputs do
391+
local msg = inputs[i]
392+
if msg.role == constants.ROLE.USER and not is_resource_msg(msg) then
393+
local next_msg = inputs[i + 1]
394+
if next_msg and next_msg.role == constants.ROLE.ASSISTANT then
395+
table.insert(lines, '## I asked :\n' .. (msg.content or '') .. '\n')
396+
table.insert(lines, '## The answer was :\n' .. (next_msg.content or '') .. '\n')
397+
i = i + 2
398+
else
399+
-- Last user question
400+
table.insert(lines, '# I ask:\n' .. (msg.content or '') .. '\n')
401+
i = i + 1
402+
end
403+
else
404+
i = i + 1
405+
end
406+
end
407+
408+
return {
409+
model = opts.model.id,
410+
stream = opts.model.streaming or false,
411+
instructions = system_prompt,
412+
input = table.concat(lines, '\n'),
413+
}
414+
end
415+
350416
inputs = vim.tbl_map(function(input)
351417
local output = {
352418
role = input.role,
@@ -414,6 +480,52 @@ M.copilot = {
414480
prepare_output = function(output)
415481
local tool_calls = {}
416482

483+
-- Handle OpenAI /responses streaming and non-streaming
484+
if type(output) == 'table' and output.type and vim.startswith(output.type, 'response.') then
485+
local t = output.type
486+
local content = nil
487+
local reasoning = nil
488+
local finish_reason = nil
489+
local usage = nil
490+
491+
if t:find('response.output_text.delta', 1, true) then
492+
local delta = output.delta or (output.data and output.data.delta) or output.text
493+
if type(delta) == 'table' then
494+
delta = delta.text or delta.content or delta[1]
495+
end
496+
content = delta
497+
elseif t == 'response.completed' then
498+
local resp = output.response or {}
499+
-- accumulate all output_text segments
500+
if resp.output then
501+
local parts = {}
502+
for _, msg in ipairs(resp.output) do
503+
if msg.content then
504+
for _, c in ipairs(msg.content) do
505+
if c.type == 'output_text' and c.text then
506+
table.insert(parts, c.text)
507+
end
508+
end
509+
end
510+
end
511+
content = table.concat(parts, '')
512+
end
513+
usage = resp.usage and resp.usage.total_tokens or output.usage and output.usage.total_tokens
514+
finish_reason = 'stop'
515+
elseif t == 'response.error' then
516+
finish_reason = 'error'
517+
end
518+
519+
return {
520+
content = content,
521+
reasoning = reasoning,
522+
finish_reason = finish_reason,
523+
total_tokens = usage,
524+
tool_calls = tool_calls,
525+
}
526+
end
527+
528+
-- Fallback to Chat Completions style
417529
local choice
418530
if output.choices and #output.choices > 0 then
419531
for _, choice in ipairs(output.choices) do
@@ -458,7 +570,10 @@ M.copilot = {
458570
}
459571
end,
460572

461-
get_url = function()
573+
get_url = function(opts)
574+
if opts and opts.model and opts.model.responses_only then
575+
return 'https://api.githubcopilot.com/responses'
576+
end
462577
return 'https://api.githubcopilot.com/chat/completions'
463578
end,
464579
}

0 commit comments

Comments
 (0)