From f42e1d4788a8540edd5acd06ee83557e70811712 Mon Sep 17 00:00:00 2001 From: andrewwillette Date: Mon, 10 Nov 2025 08:38:48 -0600 Subject: [PATCH] adding support for openai responses API --- lua/gp/config.lua | 21 ++++++++++- lua/gp/dispatcher.lua | 84 ++++++++++++++++++++++++++++++++++++++++++- lua/gp/init.lua | 54 +++++++++++++++------------- lua/gp/render.lua | 1 - lua/gp/vault.lua | 25 ++++++------- 5 files changed, 145 insertions(+), 40 deletions(-) diff --git a/lua/gp/config.lua b/lua/gp/config.lua index 22d2ce8e..3ae16325 100644 --- a/lua/gp/config.lua +++ b/lua/gp/config.lua @@ -33,6 +33,11 @@ local config = { endpoint = "https://api.openai.com/v1/chat/completions", -- secret = os.getenv("OPENAI_API_KEY"), }, + openai_resp = { + disable = false, + endpoint = "https://api.openai.com/v1/responses", + secret = os.getenv("OPENAI_API_KEY"), + }, azure = { disable = true, endpoint = "https://$URL.openai.azure.com/openai/deployments/{{model}}/chat/completions", @@ -59,7 +64,8 @@ local config = { }, googleai = { disable = true, - endpoint = "https://generativelanguage.googleapis.com/v1beta/models/{{model}}:streamGenerateContent?key={{secret}}", + endpoint = + "https://generativelanguage.googleapis.com/v1beta/models/{{model}}:streamGenerateContent?key={{secret}}", secret = os.getenv("GOOGLEAI_API_KEY"), }, pplx = { @@ -111,6 +117,16 @@ local config = { -- system prompt (use this to specify the persona/role of the AI) system_prompt = require("gp.defaults").chat_system_prompt, }, + { + name = "ChatGPT4o_new", + provider = "openai_resp", + chat = true, + command = false, + -- string with model name or table with model name and parameters + model = { model = "gpt-4o", temperature = 1.1, top_p = 1 }, + -- system prompt (use this to specify the persona/role of the AI) + system_prompt = require("gp.defaults").chat_system_prompt, + }, { provider = "openai", name = "ChatGPT4o-mini", @@ -368,6 +384,9 @@ local config = { ---@type "popup" | "split" | "vsplit" | "tabnew" toggle_target = "vsplit", + -- utilize tooling made available in the openai responses API, see openAI's online documentation + openai_resp_tools = {}, + -- styling for chatfinder ---@type "single" | "double" | "rounded" | "solid" | "shadow" | "none" style_chat_finder_border = "single", diff --git a/lua/gp/dispatcher.lua b/lua/gp/dispatcher.lua index c28caba9..ca866ab4 100644 --- a/lua/gp/dispatcher.lua +++ b/lua/gp/dispatcher.lua @@ -22,6 +22,8 @@ D.setup = function(opts) D.config.curl_params = opts.curl_params or default_config.curl_params + D.config.openai_resp_tools = opts.openai_resp_tools or default_config.openai_resp_tools + D.providers = vim.deepcopy(default_config.providers) opts.providers = opts.providers or {} for k, v in pairs(opts.providers) do @@ -212,6 +214,17 @@ D.prepare_payload = function(messages, model, provider) temperature = math.max(0, math.min(2, model.temperature or 1)), top_p = math.max(0, math.min(1, model.top_p or 1)), } + if provider == "openai_resp" then + output = { + model = model.model, + stream = true, + input = messages, + tools = D.config.openai_resp_tools, + max_output_tokens = model.max_completion_tokens or 4096, + temperature = math.max(0, math.min(2, model.temperature or 1)), + top_p = math.max(0, math.min(1, model.top_p or 1)), + } + end if (provider == "openai" or provider == "copilot") and model.model:sub(1, 1) == "o" then if model.model:sub(1, 2) == "o3" then @@ -229,7 +242,7 @@ D.prepare_payload = function(messages, model, provider) output.top_p = nil end - if model.model == "gpt-5" or model.model == "gpt-5-mini" then + if model.model == "gpt-5" or model.model == "gpt-5-mini" then -- remove max_tokens, top_p, temperature for gpt-5 models (duh) output.max_tokens = nil output.temperature = nil @@ -289,6 +302,38 @@ local query = function(buf, provider, payload, handler, on_exit, callback) end line = line:gsub("^data: ", "") local content = "" + + if qt.provider == "openai_resp" then + local ok, evt = pcall(vim.json.decode, line) + if ok and type(evt) == "table" and evt.type then + if evt.type == "response.output_text.delta" + and type(evt.delta) == "string" + then + -- Streamed token chunk + content = evt.delta + elseif evt.type == "response.completed" + and qt.response == "" + and evt.response + then + -- Fallback for non-stream / if deltas weren't processed. + local resp = evt.response + if type(resp.output) == "table" then + local acc = {} + for _, item in ipairs(resp.output) do + if item.type == "message" and type(item.content) == "table" then + for _, part in ipairs(item.content) do + if part.type == "output_text" and type(part.text) == "string" then + table.insert(acc, part.text) + end + end + end + end + content = table.concat(acc) + end + end + end + end + if line:match("choices") and line:match("delta") and line:match("content") then line = vim.json.decode(line) if line.choices[1] and line.choices[1].delta and line.choices[1].delta.content then @@ -379,6 +424,38 @@ local query = function(buf, provider, payload, handler, on_exit, callback) handler(qid, content) end end + if qt.provider == "openai_resp" and content == "" then + local last_json + for json_str in raw_response:gmatch("data:%s*(%b{})") do + last_json = json_str + end + + if last_json then + local ok, evt = pcall(vim.json.decode, last_json) + if ok and evt + and evt.type == "response.completed" + and evt.response + and type(evt.response.output) == "table" + then + local acc = {} + for _, item in ipairs(evt.response.output) do + if item.type == "message" and type(item.content) == "table" then + for _, part in ipairs(item.content) do + if part.type == "output_text" and type(part.text) == "string" then + table.insert(acc, part.text) + end + end + end + end + local full = table.concat(acc) + if full ~= "" then + qt.response = qt.response .. full + handler(qid, full) + content = qt.response + end + end + end + end if qt.response == "" then @@ -434,6 +511,11 @@ local query = function(buf, provider, payload, handler, on_exit, callback) "-H", "api-key: " .. bearer, } + elseif provider == "openai_resp" then + headers = { + "-H", + "Authorization: Bearer " .. bearer, + } elseif provider == "googleai" then headers = {} endpoint = render.template_replace(endpoint, "{{secret}}", bearer) diff --git a/lua/gp/init.lua b/lua/gp/init.lua index c05ab7f6..011dea55 100644 --- a/lua/gp/init.lua +++ b/lua/gp/init.lua @@ -7,23 +7,23 @@ local config = require("gp.config") local M = { - _Name = "Gp", -- plugin name - _state = {}, -- table of state variables - agents = {}, -- table of agents - cmd = {}, -- default command functions - config = {}, -- config variables - hooks = {}, -- user defined command functions - defaults = require("gp.defaults"), -- some useful defaults + _Name = "Gp", -- plugin name + _state = {}, -- table of state variables + agents = {}, -- table of agents + cmd = {}, -- default command functions + config = {}, -- config variables + hooks = {}, -- user defined command functions + defaults = require("gp.defaults"), -- some useful defaults deprecator = require("gp.deprecator"), -- handle deprecated options dispatcher = require("gp.dispatcher"), -- handle communication with LLM providers - helpers = require("gp.helper"), -- helper functions - imager = require("gp.imager"), -- image generation module - logger = require("gp.logger"), -- logger module - render = require("gp.render"), -- render module - spinner = require("gp.spinner"), -- spinner module - tasker = require("gp.tasker"), -- tasker module - vault = require("gp.vault"), -- handles secrets - whisper = require("gp.whisper"), -- whisper module + helpers = require("gp.helper"), -- helper functions + imager = require("gp.imager"), -- image generation module + logger = require("gp.logger"), -- logger module + render = require("gp.render"), -- render module + spinner = require("gp.spinner"), -- spinner module + tasker = require("gp.tasker"), -- tasker module + vault = require("gp.vault"), -- handles secrets + whisper = require("gp.whisper"), -- whisper module } -------------------------------------------------------------------------------- @@ -70,7 +70,11 @@ M.setup = function(opts) M.config.openai_api_key = nil opts.openai_api_key = nil - M.dispatcher.setup({ providers = opts.providers, curl_params = curl_params }) + M.dispatcher.setup({ + providers = opts.providers, + curl_params = curl_params, + openai_resp_tools = opts.openai_resp_tools + }) M.config.providers = nil opts.providers = nil @@ -137,11 +141,11 @@ M.setup = function(opts) elseif not agent.model or not agent.system_prompt then M.logger.warning( "Agent " - .. name - .. " is missing model or system_prompt\n" - .. "If you want to disable an agent, use: { name = '" - .. name - .. "', disable = true }," + .. name + .. " is missing model or system_prompt\n" + .. "If you want to disable an agent, use: { name = '" + .. name + .. "', disable = true }," ) M.agents[name] = nil end @@ -896,7 +900,8 @@ M.cmd.ChatLast = function(params) end end end - buf = win_found and buf or M.open_buf(last, M.resolve_buf_target(params), toggle and M._toggle_kind.chat or nil, toggle) + buf = win_found and buf or + M.open_buf(last, M.resolve_buf_target(params), toggle and M._toggle_kind.chat or nil, toggle) -- if there is a selection, paste it if params.range == 2 then M.render.append_selection(params, cbuf, buf, M.config.template_selection) @@ -1281,7 +1286,7 @@ M.cmd.ChatFinder = function() local command_buf, command_win, command_close, command_resize = M.render.popup( nil, "Search: /|navigate |picker |exit " - .. "/////t|open/float/split/vsplit/tab/toggle", + .. "/////t|open/float/split/vsplit/tab/toggle", function(w, h) return w - left - right, 1, h - bottom, left end, @@ -1480,7 +1485,6 @@ M.cmd.ChatFinder = function() local target = M.resolve_buf_target(M.config.toggle_target) open_chat(target, true) end) - -- tab in command window will cycle through lines in picker window M.helpers.set_keymap({ command_buf, picker_buf }, { "i", "n" }, "", function() local index = vim.api.nvim_win_get_cursor(picker_win)[1] @@ -1947,7 +1951,7 @@ M.Prompt = function(params, target, agent, template, prompt, whisper, callback) if target == M.Target.rewrite then -- delete selection if not (start_line - 1 == 0 and end_line - 1 == 0 and vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] == "") then - vim.api.nvim_buf_set_lines(buf, start_line - 1, end_line - 1, false, {}) + vim.api.nvim_buf_set_lines(buf, start_line - 1, end_line - 1, false, {}) end -- prepare handler handler = M.dispatcher.create_handler(buf, win, start_line - 1, true, prefix, cursor) diff --git a/lua/gp/render.lua b/lua/gp/render.lua index d2d4b943..d4ee3eb2 100644 --- a/lua/gp/render.lua +++ b/lua/gp/render.lua @@ -14,7 +14,6 @@ local M = {} ---@return string # returns rendered template with specified key replaced by value M.template_replace = function(template, key, value) value = value or "" - if type(value) == "table" then value = table.concat(value, "\n") end diff --git a/lua/gp/vault.lua b/lua/gp/vault.lua index 77225c75..78bf0ceb 100644 --- a/lua/gp/vault.lua +++ b/lua/gp/vault.lua @@ -104,7 +104,8 @@ V.resolve_secret = function(name, secret, callback) if code == 0 then local content = stdout_data:match("^%s*(.-)%s*$") if not string.match(content, "%S") then - logger.warning("vault resolver got empty response for " .. name .. " secret command " .. vim.inspect(secret)) + logger.warning("vault resolver got empty response for " .. + name .. " secret command " .. vim.inspect(secret)) return end secrets[name] = content @@ -112,17 +113,17 @@ V.resolve_secret = function(name, secret, callback) else logger.warning( "vault resolver for " - .. name - .. "secret command " - .. vim.inspect(secret) - .. " failed:\ncode: " - .. code - .. ", signal: " - .. signal - .. "\nstdout: " - .. stdout_data - .. "\nstderr: " - .. stderr_data + .. name + .. "secret command " + .. vim.inspect(secret) + .. " failed:\ncode: " + .. code + .. ", signal: " + .. signal + .. "\nstdout: " + .. stdout_data + .. "\nstderr: " + .. stderr_data ) end end)