feat: add event-driven architecture with scope resolution

- Add event queue system (queue.lua) with priority-based processing
- Add patch system (patch.lua) with staleness detection via changedtick
- Add confidence scoring (confidence.lua) with 5 weighted heuristics
- Add async worker wrapper (worker.lua) with timeout handling
- Add scheduler (scheduler.lua) with completion-aware injection
- Add Tree-sitter scope resolution (scope.lua) for functions/methods/classes
- Add intent detection (intent.lua) for complete/refactor/fix/add/etc
- Add tag precedence rules (first tag in scope wins)
- Update autocmds to emit events instead of direct processing
- Add scheduler config options (ollama_scout, escalation_threshold)
- Update prompts with scope-aware context
- Update README with emojis and new features
- Update documentation (llms.txt, CHANGELOG.md, doc/codetyper.txt)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-13 21:55:44 -05:00
parent 6268a57498
commit 8a3ee81c3f
28 changed files with 6055 additions and 2687 deletions

View File

@@ -505,4 +505,148 @@ function M.format_messages_for_claude(messages)
return formatted
end
--- Generate with tool use support for agentic mode
---@param messages table[] Conversation history
---@param context table Context information
---@param tool_definitions table Tool definitions
---@param callback fun(response: table|nil, error: string|nil) Callback with raw response
function M.generate_with_tools(messages, context, tool_definitions, callback)
local api_key = get_api_key()
if not api_key then
callback(nil, "Claude API key not configured")
return
end
local tools_module = require("codetyper.agent.tools")
local agent_prompts = require("codetyper.prompts.agent")
-- Build system prompt with agent instructions
local system_prompt = llm.build_system_prompt(context)
system_prompt = system_prompt .. "\n\n" .. agent_prompts.system
system_prompt = system_prompt .. "\n\n" .. agent_prompts.tool_instructions
-- Build request body with tools
local body = {
model = get_model(),
max_tokens = 4096,
system = system_prompt,
messages = M.format_messages_for_claude(messages),
tools = tools_module.to_claude_format(),
}
local json_body = vim.json.encode(body)
local cmd = {
"curl",
"-s",
"-X", "POST",
API_URL,
"-H", "Content-Type: application/json",
"-H", "x-api-key: " .. api_key,
"-H", "anthropic-version: 2023-06-01",
"-d", json_body,
}
vim.fn.jobstart(cmd, {
stdout_buffered = true,
on_stdout = function(_, data)
if not data or #data == 0 or (data[1] == "" and #data == 1) then
return
end
local response_text = table.concat(data, "\n")
local ok, response = pcall(vim.json.decode, response_text)
if not ok then
vim.schedule(function()
callback(nil, "Failed to parse Claude response")
end)
return
end
if response.error then
vim.schedule(function()
callback(nil, response.error.message or "Claude API error")
end)
return
end
-- Return raw response for parser to handle
vim.schedule(function()
callback(response, nil)
end)
end,
on_stderr = function(_, data)
if data and #data > 0 and data[1] ~= "" then
vim.schedule(function()
callback(nil, "Claude API request failed: " .. table.concat(data, "\n"))
end)
end
end,
on_exit = function(_, code)
if code ~= 0 then
vim.schedule(function()
callback(nil, "Claude API request failed with code: " .. code)
end)
end
end,
})
end
--- Format messages for Claude API
---@param messages table[] Internal message format
---@return table[] Claude API message format
function M.format_messages_for_claude(messages)
local formatted = {}
for _, msg in ipairs(messages) do
if msg.role == "user" then
if type(msg.content) == "table" then
-- Tool results
table.insert(formatted, {
role = "user",
content = msg.content,
})
else
table.insert(formatted, {
role = "user",
content = msg.content,
})
end
elseif msg.role == "assistant" then
-- Build content array for assistant messages
local content = {}
-- Add text if present
if msg.content and msg.content ~= "" then
table.insert(content, {
type = "text",
text = msg.content,
})
end
-- Add tool uses if present
if msg.tool_calls then
for _, tool_call in ipairs(msg.tool_calls) do
table.insert(content, {
type = "tool_use",
id = tool_call.id,
name = tool_call.name,
input = tool_call.parameters,
})
end
end
if #content > 0 then
table.insert(formatted, {
role = "assistant",
content = content,
})
end
end
end
return formatted
end
return M

View File

@@ -1,531 +1,501 @@
---Reference implementation:
---https://github.com/zbirenbaum/copilot.lua/blob/master/lua/copilot/auth.lua config file
---https://github.com/zed-industries/zed/blob/ad43bbbf5eda59eba65309735472e0be58b4f7dd/crates/copilot/src/copilot_chat.rs#L272 for authorization
---
---@class CopilotToken
---@field annotations_enabled boolean
---@field chat_enabled boolean
---@field chat_jetbrains_enabled boolean
---@field code_quote_enabled boolean
---@field codesearch boolean
---@field copilotignore_enabled boolean
---@field endpoints {api: string, ["origin-tracker"]: string, proxy: string, telemetry: string}
---@field expires_at integer
---@field individual boolean
---@field nes_enabled boolean
---@field prompt_8k boolean
---@field public_suggestions string
---@field refresh_in integer
---@field sku string
---@field snippy_load_test_enabled boolean
---@field telemetry string
---@field token string
---@field tracking_id string
---@field vsc_electron_fetcher boolean
---@field xcode boolean
---@field xcode_chat boolean
---@mod codetyper.llm.copilot GitHub Copilot API client for Codetyper.nvim
local curl = require("plenary.curl")
local Path = require("plenary.path")
local Utils = require("avante.utils")
local Providers = require("avante.providers")
local OpenAI = require("avante.providers").openai
local H = {}
---@class AvanteProviderFunctor
local M = {}
local copilot_path = vim.fn.stdpath("data") .. "/avante/github-copilot.json"
local lockfile_path = vim.fn.stdpath("data") .. "/avante/copilot-timer.lock"
local utils = require("codetyper.utils")
local llm = require("codetyper.llm")
-- Lockfile management
local function is_process_running(pid)
local result = vim.uv.kill(pid, 0)
if result ~= nil and result == 0 then
return true
else
return false
end
end
--- Copilot API endpoints
local AUTH_URL = "https://api.github.com/copilot_internal/v2/token"
local function try_acquire_timer_lock()
local lockfile = Path:new(lockfile_path)
--- Cached state
---@class CopilotState
---@field oauth_token string|nil
---@field github_token table|nil
M.state = nil
local tmp_lockfile = lockfile_path .. ".tmp." .. vim.fn.getpid()
Path:new(tmp_lockfile):write(tostring(vim.fn.getpid()), "w")
-- Check existing lock
if lockfile:exists() then
local content = lockfile:read()
local pid = tonumber(content)
if pid and is_process_running(pid) then
os.remove(tmp_lockfile)
return false -- Another instance is already managing
end
end
-- Attempt to take ownership
local success = os.rename(tmp_lockfile, lockfile_path)
if not success then
os.remove(tmp_lockfile)
return false
end
return true
end
local function start_manager_check_timer()
if M._manager_check_timer then
M._manager_check_timer:stop()
M._manager_check_timer:close()
end
M._manager_check_timer = vim.uv.new_timer()
M._manager_check_timer:start(
30000,
30000,
vim.schedule_wrap(function()
if not M._refresh_timer and try_acquire_timer_lock() then
M.setup_timer()
end
end)
)
end
---@class OAuthToken
---@field user string
---@field oauth_token string
---
---@return string
function H.get_oauth_token()
--- Get OAuth token from copilot.lua or copilot.vim config
---@return string|nil OAuth token
local function get_oauth_token()
local xdg_config = vim.fn.expand("$XDG_CONFIG_HOME")
local os_name = Utils.get_os_name()
---@type string
local config_dir
local os_name = vim.loop.os_uname().sysname:lower()
local config_dir
if xdg_config and vim.fn.isdirectory(xdg_config) > 0 then
config_dir = xdg_config
elseif vim.tbl_contains({ "linux", "darwin" }, os_name) then
elseif os_name:match("linux") or os_name:match("darwin") then
config_dir = vim.fn.expand("~/.config")
else
config_dir = vim.fn.expand("~/AppData/Local")
end
--- hosts.json (copilot.lua), apps.json (copilot.vim)
---@type Path[]
local paths = vim.iter({ "hosts.json", "apps.json" }):fold({}, function(acc, path)
local yason = Path:new(config_dir):joinpath("github-copilot", path)
if yason:exists() then
table.insert(acc, yason)
end
return acc
end)
if #paths == 0 then
error("You must setup copilot with either copilot.lua or copilot.vim", 2)
end
local yason = paths[1]
return vim
.iter(
---@type table<string, OAuthToken>
---@diagnostic disable-next-line: param-type-mismatch
vim.json.decode(yason:read())
)
:filter(function(k, _)
return k:match("github.com")
end)
---@param acc {oauth_token: string}
:fold({}, function(acc, _, v)
acc.oauth_token = v.oauth_token
return acc
end)
.oauth_token
end
H.chat_auth_url = "https://api.github.com/copilot_internal/v2/token"
function H.chat_completion_url(base_url)
return Utils.url_join(base_url, "/chat/completions")
end
function H.response_url(base_url)
return Utils.url_join(base_url, "/responses")
end
function H.refresh_token(async, force)
if not M.state then
error("internal initialization error")
end
async = async == nil and true or async
force = force or false
-- Do not refresh token if not forced or not expired
if
not force
and M.state.github_token
and M.state.github_token.expires_at
and M.state.github_token.expires_at > math.floor(os.time())
then
return false
end
local provider_conf = Providers.get_config("copilot")
local curl_opts = {
headers = {
["Authorization"] = "token " .. M.state.oauth_token,
["Accept"] = "application/json",
},
timeout = provider_conf.timeout,
proxy = provider_conf.proxy,
insecure = provider_conf.allow_insecure,
}
local function handle_response(response)
if response.status == 200 then
M.state.github_token = vim.json.decode(response.body)
local file = Path:new(copilot_path)
file:write(vim.json.encode(M.state.github_token), "w")
if not vim.g.avante_login then
vim.g.avante_login = true
end
-- If triggered synchronously, reset timer
if not async and M._refresh_timer then
M.setup_timer()
end
return true
else
error("Failed to get success response: " .. vim.inspect(response))
return false
end
end
if async then
curl.get(
H.chat_auth_url,
vim.tbl_deep_extend("force", {
callback = handle_response,
}, curl_opts)
)
else
local response = curl.get(H.chat_auth_url, curl_opts)
handle_response(response)
end
end
---@private
---@class AvanteCopilotState
---@field oauth_token string
---@field github_token CopilotToken?
M.state = nil
M.api_key_name = ""
M.tokenizer_id = "gpt-4o"
M.role_map = {
user = "user",
assistant = "assistant",
}
function M:is_disable_stream()
return false
end
setmetatable(M, { __index = OpenAI })
function M:list_models()
if M._model_list_cache then
return M._model_list_cache
end
if not M._is_setup then
M.setup()
end
-- refresh token synchronously, only if it has expired
-- (this should rarely happen, as we refresh the token in the background)
H.refresh_token(false, false)
local provider_conf = Providers.parse_config(self)
local headers = self:build_headers()
local curl_opts = {
headers = Utils.tbl_override(headers, self.extra_headers),
timeout = provider_conf.timeout,
proxy = provider_conf.proxy,
insecure = provider_conf.allow_insecure,
}
local function handle_response(response)
if response.status == 200 then
local body = vim.json.decode(response.body)
-- ref: https://github.com/CopilotC-Nvim/CopilotChat.nvim/blob/16d897fd43d07e3b54478ccdb2f8a16e4df4f45a/lua/CopilotChat/config/providers.lua#L171-L187
local models = vim.iter(body.data)
:filter(function(model)
return model.capabilities.type == "chat" and not vim.endswith(model.id, "paygo")
end)
:map(function(model)
return {
id = model.id,
display_name = model.name,
name = "copilot/" .. model.name .. " (" .. model.id .. ")",
provider_name = "copilot",
tokenizer = model.capabilities.tokenizer,
max_input_tokens = model.capabilities.limits.max_prompt_tokens,
max_output_tokens = model.capabilities.limits.max_output_tokens,
policy = not model["policy"] or model["policy"]["state"] == "enabled",
version = model.version,
}
end)
:totable()
M._model_list_cache = models
return models
else
error("Failed to get success response: " .. vim.inspect(response))
return {}
end
end
local response = curl.get((M.state.github_token.endpoints.api or "") .. "/models", curl_opts)
return handle_response(response)
end
function M:build_headers()
return {
["Authorization"] = "Bearer " .. M.state.github_token.token,
["User-Agent"] = "GitHubCopilotChat/0.26.7",
["Editor-Version"] = "vscode/1.105.1",
["Editor-Plugin-Version"] = "copilot-chat/0.26.7",
["Copilot-Integration-Id"] = "vscode-chat",
["Openai-Intent"] = "conversation-edits",
}
end
function M:parse_curl_args(prompt_opts)
-- refresh token synchronously, only if it has expired
-- (this should rarely happen, as we refresh the token in the background)
H.refresh_token(false, false)
local provider_conf, request_body = Providers.parse_config(self)
local use_response_api = Providers.resolve_use_response_api(provider_conf, prompt_opts)
local disable_tools = provider_conf.disable_tools or false
-- Apply OpenAI's set_allowed_params for Response API compatibility
OpenAI.set_allowed_params(provider_conf, request_body)
local use_ReAct_prompt = provider_conf.use_ReAct_prompt == true
local tools = nil
if not disable_tools and prompt_opts.tools and not use_ReAct_prompt then
tools = {}
for _, tool in ipairs(prompt_opts.tools) do
local transformed_tool = OpenAI:transform_tool(tool)
-- Response API uses flattened tool structure
if use_response_api then
if transformed_tool.type == "function" and transformed_tool["function"] then
transformed_tool = {
type = "function",
name = transformed_tool["function"].name,
description = transformed_tool["function"].description,
parameters = transformed_tool["function"].parameters,
}
-- Try hosts.json (copilot.lua) and apps.json (copilot.vim)
local paths = { "hosts.json", "apps.json" }
for _, filename in ipairs(paths) do
local path = config_dir .. "/github-copilot/" .. filename
if vim.fn.filereadable(path) == 1 then
local content = vim.fn.readfile(path)
if content and #content > 0 then
local ok, data = pcall(vim.json.decode, table.concat(content, "\n"))
if ok and data then
for key, value in pairs(data) do
if key:match("github.com") and value.oauth_token then
return value.oauth_token
end
end
end
end
table.insert(tools, transformed_tool)
end
end
local headers = self:build_headers()
if prompt_opts.messages and #prompt_opts.messages > 0 then
local last_message = prompt_opts.messages[#prompt_opts.messages]
local initiator = last_message.role == "user" and "user" or "agent"
headers["X-Initiator"] = initiator
end
local parsed_messages = self:parse_messages(prompt_opts)
-- Build base body
local base_body = {
model = provider_conf.model,
stream = true,
tools = tools,
}
-- Response API uses 'input' instead of 'messages'
-- NOTE: Copilot doesn't support previous_response_id, always send full history
if use_response_api then
base_body.input = parsed_messages
-- Response API uses max_output_tokens instead of max_tokens/max_completion_tokens
if request_body.max_completion_tokens then
request_body.max_output_tokens = request_body.max_completion_tokens
request_body.max_completion_tokens = nil
end
if request_body.max_tokens then
request_body.max_output_tokens = request_body.max_tokens
request_body.max_tokens = nil
end
-- Response API doesn't use stream_options
base_body.stream_options = nil
base_body.include = { "reasoning.encrypted_content" }
base_body.reasoning = {
summary = "detailed",
}
base_body.truncation = "disabled"
else
base_body.messages = parsed_messages
base_body.stream_options = {
include_usage = true,
}
end
local base_url = M.state.github_token.endpoints.api or provider_conf.endpoint
local build_url = use_response_api and H.response_url or H.chat_completion_url
return {
url = build_url(base_url),
timeout = provider_conf.timeout,
proxy = provider_conf.proxy,
insecure = provider_conf.allow_insecure,
headers = Utils.tbl_override(headers, self.extra_headers),
body = vim.tbl_deep_extend("force", base_body, request_body),
}
return nil
end
M._refresh_timer = nil
function M.setup_timer()
if M._refresh_timer then
M._refresh_timer:stop()
M._refresh_timer:close()
end
-- Calculate time until token expires
local now = math.floor(os.time())
local expires_at = M.state.github_token and M.state.github_token.expires_at or now
local time_until_expiry = math.max(0, expires_at - now)
-- Refresh 2 minutes before expiration
local initial_interval = math.max(0, (time_until_expiry - 120) * 1000)
-- Regular interval of 28 minutes after the first refresh
local repeat_interval = 28 * 60 * 1000
M._refresh_timer = vim.uv.new_timer()
M._refresh_timer:start(
initial_interval,
repeat_interval,
vim.schedule_wrap(function()
H.refresh_token(true, true)
end)
)
--- Get model from config
---@return string Model name
local function get_model()
local codetyper = require("codetyper")
local config = codetyper.get_config()
return config.llm.copilot.model
end
function M.setup_file_watcher()
if M._file_watcher then
--- Refresh GitHub token using OAuth token
---@param callback fun(token: table|nil, error: string|nil)
local function refresh_token(callback)
if not M.state or not M.state.oauth_token then
callback(nil, "No OAuth token available")
return
end
local copilot_token_file = Path:new(copilot_path)
M._file_watcher = vim.uv.new_fs_event()
-- Check if current token is still valid
if M.state.github_token and M.state.github_token.expires_at then
if M.state.github_token.expires_at > os.time() then
callback(M.state.github_token, nil)
return
end
end
M._file_watcher:start(
copilot_path,
{},
vim.schedule_wrap(function()
-- Reload token from file
if copilot_token_file:exists() then
local ok, token = pcall(vim.json.decode, copilot_token_file:read())
if ok then
M.state.github_token = token
end
local cmd = {
"curl",
"-s",
"-X",
"GET",
AUTH_URL,
"-H",
"Authorization: token " .. M.state.oauth_token,
"-H",
"Accept: application/json",
}
vim.fn.jobstart(cmd, {
stdout_buffered = true,
on_stdout = function(_, data)
if not data or #data == 0 or (data[1] == "" and #data == 1) then
return
end
end)
)
local response_text = table.concat(data, "\n")
local ok, token = pcall(vim.json.decode, response_text)
if not ok then
vim.schedule(function()
callback(nil, "Failed to parse token response")
end)
return
end
if token.error then
vim.schedule(function()
callback(nil, token.error_description or "Token refresh failed")
end)
return
end
M.state.github_token = token
vim.schedule(function()
callback(token, nil)
end)
end,
on_stderr = function(_, data)
if data and #data > 0 and data[1] ~= "" then
vim.schedule(function()
callback(nil, "Token refresh failed: " .. table.concat(data, "\n"))
end)
end
end,
on_exit = function(_, code)
if code ~= 0 then
vim.schedule(function()
callback(nil, "Token refresh failed with code: " .. code)
end)
end
end,
})
end
M._is_setup = false
function M.is_env_set()
local ok = pcall(function()
H.get_oauth_token()
end)
return ok
--- Build request headers
---@param token table GitHub token
---@return table Headers
local function build_headers(token)
return {
"Authorization: Bearer " .. token.token,
"Content-Type: application/json",
"User-Agent: GitHubCopilotChat/0.26.7",
"Editor-Version: vscode/1.105.1",
"Editor-Plugin-Version: copilot-chat/0.26.7",
"Copilot-Integration-Id: vscode-chat",
"Openai-Intent: conversation-edits",
}
end
function M.setup()
local copilot_token_file = Path:new(copilot_path)
--- Build request body for Copilot API
---@param prompt string User prompt
---@param context table Context information
---@return table Request body
local function build_request_body(prompt, context)
local system_prompt = llm.build_system_prompt(context)
return {
model = get_model(),
messages = {
{ role = "system", content = system_prompt },
{ role = "user", content = prompt },
},
max_tokens = 4096,
temperature = 0.2,
stream = false,
}
end
--- Make HTTP request to Copilot API
---@param token table GitHub token
---@param body table Request body
---@param callback fun(response: string|nil, error: string|nil, usage: table|nil)
local function make_request(token, body, callback)
local endpoint = (token.endpoints and token.endpoints.api or "https://api.githubcopilot.com")
.. "/chat/completions"
local json_body = vim.json.encode(body)
local headers = build_headers(token)
local cmd = {
"curl",
"-s",
"-X",
"POST",
endpoint,
}
for _, header in ipairs(headers) do
table.insert(cmd, "-H")
table.insert(cmd, header)
end
table.insert(cmd, "-d")
table.insert(cmd, json_body)
vim.fn.jobstart(cmd, {
stdout_buffered = true,
on_stdout = function(_, data)
if not data or #data == 0 or (data[1] == "" and #data == 1) then
return
end
local response_text = table.concat(data, "\n")
local ok, response = pcall(vim.json.decode, response_text)
if not ok then
vim.schedule(function()
callback(nil, "Failed to parse Copilot response", nil)
end)
return
end
if response.error then
vim.schedule(function()
callback(nil, response.error.message or "Copilot API error", nil)
end)
return
end
-- Extract usage info
local usage = response.usage or {}
if response.choices and response.choices[1] and response.choices[1].message then
local code = llm.extract_code(response.choices[1].message.content)
vim.schedule(function()
callback(code, nil, usage)
end)
else
vim.schedule(function()
callback(nil, "No content in Copilot response", nil)
end)
end
end,
on_stderr = function(_, data)
if data and #data > 0 and data[1] ~= "" then
vim.schedule(function()
callback(nil, "Copilot API request failed: " .. table.concat(data, "\n"), nil)
end)
end
end,
on_exit = function(_, code)
if code ~= 0 then
vim.schedule(function()
callback(nil, "Copilot API request failed with code: " .. code, nil)
end)
end
end,
})
end
--- Initialize Copilot state
local function ensure_initialized()
if not M.state then
M.state = {
oauth_token = get_oauth_token(),
github_token = nil,
oauth_token = H.get_oauth_token(),
}
end
-- Load and validate existing token
if copilot_token_file:exists() then
local ok, token = pcall(vim.json.decode, copilot_token_file:read())
if ok and token.expires_at and token.expires_at > math.floor(os.time()) then
M.state.github_token = token
end
end
-- Setup timer management
local timer_lock_acquired = try_acquire_timer_lock()
if timer_lock_acquired then
M.setup_timer()
else
vim.schedule(function()
H.refresh_token(true, false)
end)
end
M.setup_file_watcher()
start_manager_check_timer()
require("avante.tokenizers").setup(M.tokenizer_id)
vim.g.avante_login = true
M._is_setup = true
end
function M.cleanup()
-- Cleanup refresh timer
if M._refresh_timer then
M._refresh_timer:stop()
M._refresh_timer:close()
M._refresh_timer = nil
--- Generate code using Copilot API
---@param prompt string The user's prompt
---@param context table Context information
---@param callback fun(response: string|nil, error: string|nil)
function M.generate(prompt, context, callback)
local logs = require("codetyper.agent.logs")
-- Remove lockfile if we were the manager
local lockfile = Path:new(lockfile_path)
if lockfile:exists() then
local content = lockfile:read()
local pid = tonumber(content)
if pid and pid == vim.fn.getpid() then
lockfile:rm()
ensure_initialized()
if not M.state.oauth_token then
local err = "Copilot not authenticated. Please set up copilot.lua or copilot.vim first."
logs.error(err)
callback(nil, err)
return
end
local model = get_model()
logs.request("copilot", model)
logs.thinking("Refreshing authentication token...")
refresh_token(function(token, err)
if err then
logs.error(err)
utils.notify(err, vim.log.levels.ERROR)
callback(nil, err)
return
end
logs.thinking("Building request body...")
local body = build_request_body(prompt, context)
local prompt_estimate = logs.estimate_tokens(vim.json.encode(body))
logs.debug(string.format("Estimated prompt: ~%d tokens", prompt_estimate))
logs.thinking("Sending to Copilot API...")
utils.notify("Sending request to Copilot...", vim.log.levels.INFO)
make_request(token, body, function(response, request_err, usage)
if request_err then
logs.error(request_err)
utils.notify(request_err, vim.log.levels.ERROR)
callback(nil, request_err)
else
if usage then
logs.response(usage.prompt_tokens or 0, usage.completion_tokens or 0, "stop")
end
logs.thinking("Response received, extracting code...")
logs.info("Code generated successfully")
utils.notify("Code generated successfully", vim.log.levels.INFO)
callback(response, nil)
end
end)
end)
end
--- Check if Copilot is properly configured
---@return boolean, string? Valid status and optional error message
function M.validate()
ensure_initialized()
if not M.state.oauth_token then
return false, "Copilot not authenticated. Set up copilot.lua or copilot.vim first."
end
return true
end
--- Generate with tool use support for agentic mode
---@param messages table[] Conversation history
---@param context table Context information
---@param tool_definitions table Tool definitions
---@param callback fun(response: table|nil, error: string|nil)
function M.generate_with_tools(messages, context, tool_definitions, callback)
local logs = require("codetyper.agent.logs")
ensure_initialized()
if not M.state.oauth_token then
local err = "Copilot not authenticated"
logs.error(err)
callback(nil, err)
return
end
local model = get_model()
logs.request("copilot", model)
logs.thinking("Refreshing authentication token...")
refresh_token(function(token, err)
if err then
logs.error(err)
callback(nil, err)
return
end
local tools_module = require("codetyper.agent.tools")
local agent_prompts = require("codetyper.prompts.agent")
-- Build system prompt with agent instructions
local system_prompt = llm.build_system_prompt(context)
system_prompt = system_prompt .. "\n\n" .. agent_prompts.system
system_prompt = system_prompt .. "\n\n" .. agent_prompts.tool_instructions
-- Format messages for Copilot (OpenAI-compatible format)
local copilot_messages = { { role = "system", content = system_prompt } }
for _, msg in ipairs(messages) do
if type(msg.content) == "string" then
table.insert(copilot_messages, { role = msg.role, content = msg.content })
elseif type(msg.content) == "table" then
local text_parts = {}
for _, part in ipairs(msg.content) do
if part.type == "tool_result" then
table.insert(text_parts, "[" .. (part.name or "tool") .. " result]: " .. (part.content or ""))
elseif part.type == "text" then
table.insert(text_parts, part.text or "")
end
end
if #text_parts > 0 then
table.insert(copilot_messages, { role = msg.role, content = table.concat(text_parts, "\n") })
end
end
end
end
-- Cleanup manager check timer
if M._manager_check_timer then
M._manager_check_timer:stop()
M._manager_check_timer:close()
M._manager_check_timer = nil
end
local body = {
model = get_model(),
messages = copilot_messages,
max_tokens = 4096,
temperature = 0.3,
stream = false,
tools = tools_module.to_openai_format(),
}
-- Cleanup file watcher
if M._file_watcher then
---@diagnostic disable-next-line: param-type-mismatch
M._file_watcher:stop()
M._file_watcher = nil
end
local endpoint = (token.endpoints and token.endpoints.api or "https://api.githubcopilot.com")
.. "/chat/completions"
local json_body = vim.json.encode(body)
local prompt_estimate = logs.estimate_tokens(json_body)
logs.debug(string.format("Estimated prompt: ~%d tokens", prompt_estimate))
logs.thinking("Sending to Copilot API...")
local headers = build_headers(token)
local cmd = {
"curl",
"-s",
"-X",
"POST",
endpoint,
}
for _, header in ipairs(headers) do
table.insert(cmd, "-H")
table.insert(cmd, header)
end
table.insert(cmd, "-d")
table.insert(cmd, json_body)
vim.fn.jobstart(cmd, {
stdout_buffered = true,
on_stdout = function(_, data)
if not data or #data == 0 or (data[1] == "" and #data == 1) then
return
end
local response_text = table.concat(data, "\n")
local ok, response = pcall(vim.json.decode, response_text)
if not ok then
vim.schedule(function()
logs.error("Failed to parse Copilot response")
callback(nil, "Failed to parse Copilot response")
end)
return
end
if response.error then
vim.schedule(function()
logs.error(response.error.message or "Copilot API error")
callback(nil, response.error.message or "Copilot API error")
end)
return
end
-- Log token usage
if response.usage then
logs.response(response.usage.prompt_tokens or 0, response.usage.completion_tokens or 0, "stop")
end
-- Convert to Claude-like format for parser compatibility
local converted = { content = {} }
if response.choices and response.choices[1] then
local choice = response.choices[1]
if choice.message then
if choice.message.content then
table.insert(converted.content, { type = "text", text = choice.message.content })
logs.thinking("Response contains text")
end
if choice.message.tool_calls then
for _, tc in ipairs(choice.message.tool_calls) do
local args = {}
if tc["function"] and tc["function"].arguments then
local ok_args, parsed = pcall(vim.json.decode, tc["function"].arguments)
if ok_args then
args = parsed
end
end
table.insert(converted.content, {
type = "tool_use",
id = tc.id,
name = tc["function"].name,
input = args,
})
logs.thinking("Tool call: " .. tc["function"].name)
end
end
end
end
vim.schedule(function()
callback(converted, nil)
end)
end,
on_stderr = function(_, data)
if data and #data > 0 and data[1] ~= "" then
vim.schedule(function()
logs.error("Copilot API request failed: " .. table.concat(data, "\n"))
callback(nil, "Copilot API request failed: " .. table.concat(data, "\n"))
end)
end
end,
on_exit = function(_, code)
if code ~= 0 then
vim.schedule(function()
logs.error("Copilot API request failed with code: " .. code)
callback(nil, "Copilot API request failed with code: " .. code)
end)
end
end,
})
end)
end
-- Register cleanup on Neovim exit
vim.api.nvim_create_autocmd("VimLeavePre", {
callback = function()
M.cleanup()
end,
})
return M

View File

@@ -1,361 +1,394 @@
local Utils = require("avante.utils")
local Providers = require("avante.providers")
local Clipboard = require("avante.clipboard")
local OpenAI = require("avante.providers").openai
local Prompts = require("avante.utils.prompts")
---@mod codetyper.llm.gemini Google Gemini API client for Codetyper.nvim
---@class AvanteProviderFunctor
local M = {}
M.api_key_name = "GEMINI_API_KEY"
M.role_map = {
user = "user",
assistant = "model",
}
local utils = require("codetyper.utils")
local llm = require("codetyper.llm")
function M:is_disable_stream()
return false
--- Gemini API endpoint
local API_URL = "https://generativelanguage.googleapis.com/v1beta/models"
--- Get API key from config or environment
---@return string|nil API key
local function get_api_key()
local codetyper = require("codetyper")
local config = codetyper.get_config()
return config.llm.gemini.api_key or vim.env.GEMINI_API_KEY
end
---@param tool AvanteLLMTool
function M:transform_to_function_declaration(tool)
local input_schema_properties, required = Utils.llm_tool_param_fields_to_json_schema(tool.param.fields)
local parameters = nil
if not vim.tbl_isempty(input_schema_properties) then
parameters = {
type = "object",
properties = input_schema_properties,
required = required,
}
end
return {
name = tool.name,
description = tool.get_description and tool.get_description() or tool.description,
parameters = parameters,
}
--- Get model from config
---@return string Model name
local function get_model()
local codetyper = require("codetyper")
local config = codetyper.get_config()
return config.llm.gemini.model
end
function M:parse_messages(opts)
local provider_conf, _ = Providers.parse_config(self)
local use_ReAct_prompt = provider_conf.use_ReAct_prompt == true
local contents = {}
local prev_role = nil
local tool_id_to_name = {}
vim.iter(opts.messages):each(function(message)
local role = message.role
if role == prev_role then
if role == M.role_map["user"] then
table.insert(
contents,
{ role = M.role_map["assistant"], parts = {
{ text = "Ok, I understand." },
} }
)
else
table.insert(contents, { role = M.role_map["user"], parts = {
{ text = "Ok" },
} })
end
end
prev_role = role
local parts = {}
local content_items = message.content
if type(content_items) == "string" then
table.insert(parts, { text = content_items })
elseif type(content_items) == "table" then
---@cast content_items AvanteLLMMessageContentItem[]
for _, item in ipairs(content_items) do
if type(item) == "string" then
table.insert(parts, { text = item })
elseif type(item) == "table" and item.type == "text" then
table.insert(parts, { text = item.text })
elseif type(item) == "table" and item.type == "image" then
table.insert(parts, {
inline_data = {
mime_type = "image/png",
data = item.source.data,
},
})
elseif type(item) == "table" and item.type == "tool_use" and not use_ReAct_prompt then
tool_id_to_name[item.id] = item.name
role = "model"
table.insert(parts, {
functionCall = {
name = item.name,
args = item.input,
},
})
elseif type(item) == "table" and item.type == "tool_result" and not use_ReAct_prompt then
role = "function"
local ok, content = pcall(vim.json.decode, item.content)
if not ok then
content = item.content
end
-- item.name here refers to the name of the tool that was called,
-- which is available in the tool_result content item prepared by llm.lua
local tool_name = item.name
if not tool_name then
-- Fallback, though item.name should ideally always be present for tool_result
tool_name = tool_id_to_name[item.tool_use_id]
end
table.insert(parts, {
functionResponse = {
name = tool_name,
response = {
name = tool_name, -- Gemini API requires the name in the response object as well
content = content,
},
},
})
elseif type(item) == "table" and item.type == "thinking" then
table.insert(parts, { text = item.thinking })
elseif type(item) == "table" and item.type == "redacted_thinking" then
table.insert(parts, { text = item.data })
end
end
if not provider_conf.disable_tools and use_ReAct_prompt then
if content_items[1].type == "tool_result" then
local tool_use_msg = nil
for _, msg_ in ipairs(opts.messages) do
if type(msg_.content) == "table" and #msg_.content > 0 then
if
msg_.content[1].type == "tool_use"
and msg_.content[1].id == content_items[1].tool_use_id
then
tool_use_msg = msg_
break
end
end
end
if tool_use_msg then
table.insert(contents, {
role = "model",
parts = {
{ text = Utils.tool_use_to_xml(tool_use_msg.content[1]) },
},
})
role = "user"
table.insert(parts, {
text = "The result of tool use "
.. Utils.tool_use_to_xml(tool_use_msg.content[1])
.. " is:\n",
})
table.insert(parts, {
text = content_items[1].content,
})
end
end
end
end
if #parts > 0 then
table.insert(contents, { role = M.role_map[role] or role, parts = parts })
end
end)
if Clipboard.support_paste_image() and opts.image_paths then
for _, image_path in ipairs(opts.image_paths) do
local image_data = {
inline_data = {
mime_type = "image/png",
data = Clipboard.get_base64_content(image_path),
},
}
table.insert(contents[#contents].parts, image_data)
end
end
local system_prompt = opts.system_prompt
if use_ReAct_prompt then
system_prompt = Prompts.get_ReAct_system_prompt(provider_conf, opts)
end
--- Build request body for Gemini API
---@param prompt string User prompt
---@param context table Context information
---@return table Request body
local function build_request_body(prompt, context)
local system_prompt = llm.build_system_prompt(context)
return {
systemInstruction = {
role = "user",
parts = {
{
text = system_prompt,
},
parts = { { text = system_prompt } },
},
contents = {
{
role = "user",
parts = { { text = prompt } },
},
},
contents = contents,
generationConfig = {
temperature = 0.2,
maxOutputTokens = 4096,
},
}
end
--- Prepares the main request body for Gemini-like APIs.
---@param provider_instance AvanteProviderFunctor The provider instance (self).
---@param prompt_opts AvantePromptOptions Prompt options including messages, tools, system_prompt.
---@param provider_conf table Provider configuration from config.lua (e.g., model, top-level temperature/max_tokens).
---@param request_body_ table Request-specific overrides, typically from provider_conf.request_config_overrides.
---@return table The fully constructed request body.
function M.prepare_request_body(provider_instance, prompt_opts, provider_conf, request_body_)
local request_body = {}
request_body.generationConfig = request_body_.generationConfig or {}
local use_ReAct_prompt = provider_conf.use_ReAct_prompt == true
if use_ReAct_prompt then
request_body.generationConfig.stopSequences = { "</tool_use>" }
--- Make HTTP request to Gemini API
---@param body table Request body
---@param callback fun(response: string|nil, error: string|nil, usage: table|nil) Callback function
local function make_request(body, callback)
local api_key = get_api_key()
if not api_key then
callback(nil, "Gemini API key not configured", nil)
return
end
local disable_tools = provider_conf.disable_tools or false
local model = get_model()
local url = API_URL .. "/" .. model .. ":generateContent?key=" .. api_key
local json_body = vim.json.encode(body)
if not use_ReAct_prompt and not disable_tools and prompt_opts.tools then
local function_declarations = {}
for _, tool in ipairs(prompt_opts.tools) do
table.insert(function_declarations, provider_instance:transform_to_function_declaration(tool))
end
if #function_declarations > 0 then
request_body.tools = {
{
functionDeclarations = function_declarations,
},
}
end
end
return vim.tbl_deep_extend("force", {}, provider_instance:parse_messages(prompt_opts), request_body)
end
---@param usage avante.GeminiTokenUsage | nil
---@return avante.LLMTokenUsage | nil
function M.transform_gemini_usage(usage)
if not usage then
return nil
end
---@type avante.LLMTokenUsage
local res = {
prompt_tokens = usage.promptTokenCount,
completion_tokens = usage.candidatesTokenCount,
local cmd = {
"curl",
"-s",
"-X",
"POST",
url,
"-H",
"Content-Type: application/json",
"-d",
json_body,
}
return res
end
function M:parse_response(ctx, data_stream, _, opts)
local ok, jsn = pcall(vim.json.decode, data_stream)
if not ok then
opts.on_stop({ reason = "error", error = "Failed to parse JSON response: " .. tostring(jsn) })
return
end
if opts.update_tokens_usage and jsn.usageMetadata and jsn.usageMetadata ~= nil then
local usage = M.transform_gemini_usage(jsn.usageMetadata)
if usage ~= nil then
opts.update_tokens_usage(usage)
end
end
-- Handle prompt feedback first, as it might indicate an overall issue with the prompt
if jsn.promptFeedback and jsn.promptFeedback.blockReason then
local feedback = jsn.promptFeedback
OpenAI:finish_pending_messages(ctx, opts) -- Ensure any pending messages are cleared
opts.on_stop({
reason = "error",
error = "Prompt blocked or filtered. Reason: " .. feedback.blockReason,
details = feedback,
})
return
end
if jsn.candidates and #jsn.candidates > 0 then
local candidate = jsn.candidates[1]
---@type AvanteLLMToolUse[]
ctx.tool_use_list = ctx.tool_use_list or {}
-- Check if candidate.content and candidate.content.parts exist before iterating
if candidate.content and candidate.content.parts then
for _, part in ipairs(candidate.content.parts) do
if part.text then
if opts.on_chunk then
opts.on_chunk(part.text)
end
OpenAI:add_text_message(ctx, part.text, "generating", opts)
elseif part.functionCall then
if not ctx.function_call_id then
ctx.function_call_id = 0
end
ctx.function_call_id = ctx.function_call_id + 1
local tool_use = {
id = ctx.turn_id .. "-" .. tostring(ctx.function_call_id),
name = part.functionCall.name,
input_json = vim.json.encode(part.functionCall.args),
}
table.insert(ctx.tool_use_list, tool_use)
OpenAI:add_tool_use_message(ctx, tool_use, "generated", opts)
end
vim.fn.jobstart(cmd, {
stdout_buffered = true,
on_stdout = function(_, data)
if not data or #data == 0 or (data[1] == "" and #data == 1) then
return
end
end
-- Check for finishReason to determine if this candidate's stream is done.
if candidate.finishReason then
OpenAI:finish_pending_messages(ctx, opts)
local reason_str = candidate.finishReason
local stop_details = { finish_reason = reason_str }
stop_details.usage = M.transform_gemini_usage(jsn.usageMetadata)
local response_text = table.concat(data, "\n")
local ok, response = pcall(vim.json.decode, response_text)
if reason_str == "TOOL_CODE" then
-- Model indicates a tool-related stop.
-- The tool_use list is added to the table in llm.lua
opts.on_stop(vim.tbl_deep_extend("force", { reason = "tool_use" }, stop_details))
elseif reason_str == "STOP" then
if ctx.tool_use_list and #ctx.tool_use_list > 0 then
-- Natural stop, but tools were found in this final chunk.
opts.on_stop(vim.tbl_deep_extend("force", { reason = "tool_use" }, stop_details))
if not ok then
vim.schedule(function()
callback(nil, "Failed to parse Gemini response", nil)
end)
return
end
if response.error then
vim.schedule(function()
callback(nil, response.error.message or "Gemini API error", nil)
end)
return
end
-- Extract usage info
local usage = {}
if response.usageMetadata then
usage.prompt_tokens = response.usageMetadata.promptTokenCount or 0
usage.completion_tokens = response.usageMetadata.candidatesTokenCount or 0
end
if response.candidates and response.candidates[1] then
local candidate = response.candidates[1]
if candidate.content and candidate.content.parts then
local text_parts = {}
for _, part in ipairs(candidate.content.parts) do
if part.text then
table.insert(text_parts, part.text)
end
end
local full_text = table.concat(text_parts, "")
local code = llm.extract_code(full_text)
vim.schedule(function()
callback(code, nil, usage)
end)
else
-- Natural stop, no tools in this final chunk.
-- llm.lua will check its accumulated tools if tool_choice was active.
opts.on_stop(vim.tbl_deep_extend("force", { reason = "complete" }, stop_details))
vim.schedule(function()
callback(nil, "No content in Gemini response", nil)
end)
end
elseif reason_str == "MAX_TOKENS" then
opts.on_stop(vim.tbl_deep_extend("force", { reason = "max_tokens" }, stop_details))
elseif reason_str == "SAFETY" or reason_str == "RECITATION" then
opts.on_stop(
vim.tbl_deep_extend(
"force",
{ reason = "error", error = "Generation stopped: " .. reason_str },
stop_details
)
)
else -- OTHER, FINISH_REASON_UNSPECIFIED, or any other unhandled reason.
opts.on_stop(
vim.tbl_deep_extend(
"force",
{ reason = "error", error = "Generation stopped with unhandled reason: " .. reason_str },
stop_details
)
)
else
vim.schedule(function()
callback(nil, "No candidates in Gemini response", nil)
end)
end
end
-- If no finishReason, it's an intermediate chunk; do not call on_stop.
end
end,
on_stderr = function(_, data)
if data and #data > 0 and data[1] ~= "" then
vim.schedule(function()
callback(nil, "Gemini API request failed: " .. table.concat(data, "\n"), nil)
end)
end
end,
on_exit = function(_, code)
if code ~= 0 then
vim.schedule(function()
callback(nil, "Gemini API request failed with code: " .. code, nil)
end)
end
end,
})
end
---@param prompt_opts AvantePromptOptions
---@return AvanteCurlOutput|nil
function M:parse_curl_args(prompt_opts)
local provider_conf, request_body = Providers.parse_config(self)
--- Generate code using Gemini API
---@param prompt string The user's prompt
---@param context table Context information
---@param callback fun(response: string|nil, error: string|nil) Callback function
function M.generate(prompt, context, callback)
local logs = require("codetyper.agent.logs")
local model = get_model()
local api_key = self:parse_api_key()
if api_key == nil then
Utils.error("Gemini: API key is not set. Please set " .. M.api_key_name)
return nil
-- Log the request
logs.request("gemini", model)
logs.thinking("Building request body...")
local body = build_request_body(prompt, context)
-- Estimate prompt tokens
local prompt_estimate = logs.estimate_tokens(vim.json.encode(body))
logs.debug(string.format("Estimated prompt: ~%d tokens", prompt_estimate))
logs.thinking("Sending to Gemini API...")
utils.notify("Sending request to Gemini...", vim.log.levels.INFO)
make_request(body, function(response, err, usage)
if err then
logs.error(err)
utils.notify(err, vim.log.levels.ERROR)
callback(nil, err)
else
-- Log token usage
if usage then
logs.response(usage.prompt_tokens or 0, usage.completion_tokens or 0, "stop")
end
logs.thinking("Response received, extracting code...")
logs.info("Code generated successfully")
utils.notify("Code generated successfully", vim.log.levels.INFO)
callback(response, nil)
end
end)
end
--- Check if Gemini is properly configured
---@return boolean, string? Valid status and optional error message
function M.validate()
local api_key = get_api_key()
if not api_key or api_key == "" then
return false, "Gemini API key not configured"
end
return true
end
--- Generate with tool use support for agentic mode
---@param messages table[] Conversation history
---@param context table Context information
---@param tool_definitions table Tool definitions
---@param callback fun(response: table|nil, error: string|nil) Callback with raw response
function M.generate_with_tools(messages, context, tool_definitions, callback)
local logs = require("codetyper.agent.logs")
local model = get_model()
logs.request("gemini", model)
logs.thinking("Preparing agent request...")
local api_key = get_api_key()
if not api_key then
logs.error("Gemini API key not configured")
callback(nil, "Gemini API key not configured")
return
end
return {
url = Utils.url_join(
provider_conf.endpoint,
provider_conf.model .. ":streamGenerateContent?alt=sse&key=" .. api_key
),
proxy = provider_conf.proxy,
insecure = provider_conf.allow_insecure,
headers = Utils.tbl_override({ ["Content-Type"] = "application/json" }, self.extra_headers),
body = M.prepare_request_body(self, prompt_opts, provider_conf, request_body),
local tools_module = require("codetyper.agent.tools")
local agent_prompts = require("codetyper.prompts.agent")
-- Build system prompt with agent instructions
local system_prompt = llm.build_system_prompt(context)
system_prompt = system_prompt .. "\n\n" .. agent_prompts.system
system_prompt = system_prompt .. "\n\n" .. agent_prompts.tool_instructions
-- Format messages for Gemini
local gemini_contents = {}
for _, msg in ipairs(messages) do
local role = msg.role == "assistant" and "model" or "user"
local parts = {}
if type(msg.content) == "string" then
table.insert(parts, { text = msg.content })
elseif type(msg.content) == "table" then
for _, part in ipairs(msg.content) do
if part.type == "tool_result" then
table.insert(parts, { text = "[" .. (part.name or "tool") .. " result]: " .. (part.content or "") })
elseif part.type == "text" then
table.insert(parts, { text = part.text or "" })
end
end
end
if #parts > 0 then
table.insert(gemini_contents, { role = role, parts = parts })
end
end
-- Build function declarations for tools
local function_declarations = {}
for _, tool in ipairs(tools_module.definitions) do
local properties = {}
local required = {}
if tool.parameters and tool.parameters.properties then
for name, prop in pairs(tool.parameters.properties) do
properties[name] = {
type = prop.type:upper(),
description = prop.description,
}
end
end
if tool.parameters and tool.parameters.required then
required = tool.parameters.required
end
table.insert(function_declarations, {
name = tool.name,
description = tool.description,
parameters = {
type = "OBJECT",
properties = properties,
required = required,
},
})
end
local body = {
systemInstruction = {
role = "user",
parts = { { text = system_prompt } },
},
contents = gemini_contents,
generationConfig = {
temperature = 0.3,
maxOutputTokens = 4096,
},
tools = {
{ functionDeclarations = function_declarations },
},
}
local url = API_URL .. "/" .. model .. ":generateContent?key=" .. api_key
local json_body = vim.json.encode(body)
local prompt_estimate = logs.estimate_tokens(json_body)
logs.debug(string.format("Estimated prompt: ~%d tokens", prompt_estimate))
logs.thinking("Sending to Gemini API...")
local cmd = {
"curl",
"-s",
"-X",
"POST",
url,
"-H",
"Content-Type: application/json",
"-d",
json_body,
}
vim.fn.jobstart(cmd, {
stdout_buffered = true,
on_stdout = function(_, data)
if not data or #data == 0 or (data[1] == "" and #data == 1) then
return
end
local response_text = table.concat(data, "\n")
local ok, response = pcall(vim.json.decode, response_text)
if not ok then
vim.schedule(function()
logs.error("Failed to parse Gemini response")
callback(nil, "Failed to parse Gemini response")
end)
return
end
if response.error then
vim.schedule(function()
logs.error(response.error.message or "Gemini API error")
callback(nil, response.error.message or "Gemini API error")
end)
return
end
-- Log token usage
if response.usageMetadata then
logs.response(
response.usageMetadata.promptTokenCount or 0,
response.usageMetadata.candidatesTokenCount or 0,
"stop"
)
end
-- Convert to Claude-like format for parser compatibility
local converted = { content = {} }
if response.candidates and response.candidates[1] then
local candidate = response.candidates[1]
if candidate.content and candidate.content.parts then
for _, part in ipairs(candidate.content.parts) do
if part.text then
table.insert(converted.content, { type = "text", text = part.text })
logs.thinking("Response contains text")
elseif part.functionCall then
table.insert(converted.content, {
type = "tool_use",
id = vim.fn.sha256(vim.json.encode(part.functionCall)):sub(1, 16),
name = part.functionCall.name,
input = part.functionCall.args or {},
})
logs.thinking("Tool call: " .. part.functionCall.name)
end
end
end
end
vim.schedule(function()
callback(converted, nil)
end)
end,
on_stderr = function(_, data)
if data and #data > 0 and data[1] ~= "" then
vim.schedule(function()
logs.error("Gemini API request failed: " .. table.concat(data, "\n"))
callback(nil, "Gemini API request failed: " .. table.concat(data, "\n"))
end)
end
end,
on_exit = function(_, code)
if code ~= 0 then
vim.schedule(function()
logs.error("Gemini API request failed with code: " .. code)
callback(nil, "Gemini API request failed with code: " .. code)
end)
end
end,
})
end
return M

View File

@@ -14,6 +14,12 @@ function M.get_client()
return require("codetyper.llm.claude")
elseif config.llm.provider == "ollama" then
return require("codetyper.llm.ollama")
elseif config.llm.provider == "openai" then
return require("codetyper.llm.openai")
elseif config.llm.provider == "gemini" then
return require("codetyper.llm.gemini")
elseif config.llm.provider == "copilot" then
return require("codetyper.llm.copilot")
else
error("Unknown LLM provider: " .. config.llm.provider)
end

View File

@@ -394,4 +394,104 @@ function M.generate_with_tools(messages, context, tools, callback)
end)
end
--- Generate with tool use support for agentic mode (simulated via prompts)
---@param messages table[] Conversation history
---@param context table Context information
---@param tool_definitions table Tool definitions
---@param callback fun(response: string|nil, error: string|nil) Callback with response text
function M.generate_with_tools(messages, context, tool_definitions, callback)
local tools_module = require("codetyper.agent.tools")
local agent_prompts = require("codetyper.prompts.agent")
-- Build system prompt with agent instructions and tool definitions
local system_prompt = llm.build_system_prompt(context)
system_prompt = system_prompt .. "\n\n" .. agent_prompts.system
system_prompt = system_prompt .. "\n\n" .. tools_module.to_prompt_format()
-- Flatten messages to single prompt (Ollama's generate API)
local prompt_parts = {}
for _, msg in ipairs(messages) do
if type(msg.content) == "string" then
local role_prefix = msg.role == "user" and "User" or "Assistant"
table.insert(prompt_parts, role_prefix .. ": " .. msg.content)
elseif type(msg.content) == "table" then
-- Handle tool results
for _, item in ipairs(msg.content) do
if item.type == "tool_result" then
table.insert(prompt_parts, "Tool result: " .. item.content)
end
end
end
end
local body = {
model = get_model(),
system = system_prompt,
prompt = table.concat(prompt_parts, "\n\n"),
stream = false,
options = {
temperature = 0.2,
num_predict = 4096,
},
}
local host = get_host()
local url = host .. "/api/generate"
local json_body = vim.json.encode(body)
local cmd = {
"curl",
"-s",
"-X", "POST",
url,
"-H", "Content-Type: application/json",
"-d", json_body,
}
vim.fn.jobstart(cmd, {
stdout_buffered = true,
on_stdout = function(_, data)
if not data or #data == 0 or (data[1] == "" and #data == 1) then
return
end
local response_text = table.concat(data, "\n")
local ok, response = pcall(vim.json.decode, response_text)
if not ok then
vim.schedule(function()
callback(nil, "Failed to parse Ollama response")
end)
return
end
if response.error then
vim.schedule(function()
callback(nil, response.error or "Ollama API error")
end)
return
end
-- Return raw response text for parser to handle
vim.schedule(function()
callback(response.response or "", nil)
end)
end,
on_stderr = function(_, data)
if data and #data > 0 and data[1] ~= "" then
vim.schedule(function()
callback(nil, "Ollama API request failed: " .. table.concat(data, "\n"))
end)
end
end,
on_exit = function(_, code)
if code ~= 0 then
vim.schedule(function()
callback(nil, "Ollama API request failed with code: " .. code)
end)
end
end,
})
end
return M

File diff suppressed because it is too large Load Diff