From c69b7e96e7ea8db5c7fd78e4ca5525ec35b6fcab Mon Sep 17 00:00:00 2001 From: Krystian Piecko Date: Mon, 30 Jun 2025 12:22:43 -0400 Subject: [PATCH 01/10] using security app to obtain API key --- README.md | 6 + lua/gemini/api.lua | 302 +++++++++++++++++++++++++++++---------------- 2 files changed, 203 insertions(+), 105 deletions(-) diff --git a/README.md b/README.md index 6f20a32..8574c51 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,12 @@ sudo apt install curl export GEMINI_API_KEY="" ``` +On macOS, the Keychain Services API, which is part of the Security framework, allows you to programmatically add API_KEY to the Keychain using cli app `security` + +``` +security add-generic-password -a ${USER} -s "gemini-cli" -w "YOUR_API_KEY_HERE" +``` + * [lazy.nvim](https://github.com/folke/lazy.nvim) ```lua diff --git a/lua/gemini/api.lua b/lua/gemini/api.lua index 5ee4371..4fa92b6 100644 --- a/lua/gemini/api.lua +++ b/lua/gemini/api.lua @@ -1,120 +1,212 @@ +-- Helper for libuv operations local uv = vim.loop or vim.uv local M = {} -local API = "https://generativelanguage.googleapis.com/v1beta/models/"; +-- Base URL for the Google Gemini API +local API = "https://generativelanguage.googleapis.com/v1beta/models/" +-- A list of available Gemini models M.MODELS = { - GEMINI_2_5_FLASH_PREVIEW = 'gemini-2.5-flash-preview-04-17', - GEMINI_2_5_PRO_PREVIEW = 'gemini-2.5-pro-preview-03-25', - GEMINI_2_0_FLASH = 'gemini-2.0-flash', - GEMINI_2_0_FLASH_LITE = 'gemini-2.0-flash-lite', - GEMINI_2_0_FLASH_EXP = 'gemini-2.0-flash-exp', - GEMINI_2_0_FLASH_THINKING_EXP = 'gemini-2.0-flash-thinking-exp-1219', - GEMINI_1_5_PRO = 'gemini-1.5-pro', - GEMINI_1_5_FLASH = 'gemini-1.5-flash', - GEMINI_1_5_FLASH_8B = 'gemini-1.5-flash-8b', + GEMINI_2_5_FLASH_PREVIEW = "gemini-2.5-flash-preview-04-17", + GEMINI_2_5_PRO_PREVIEW = "gemini-2.5-pro-preview-03-25", + GEMINI_2_5_FLASH = "gemini-2.5-flash", + GEMINI_2_5_PRO = "gemini-2.5-pro", + GEMINI_2_0_FLASH = "gemini-2.0-flash", + GEMINI_2_0_FLASH_LITE = "gemini-2.0-flash-lite", + GEMINI_2_0_FLASH_EXP = "gemini-2.0-flash-exp", + GEMINI_2_0_FLASH_THINKING_EXP = "gemini-2.0-flash-thinking-exp-1219", + GEMINI_1_5_PRO = "gemini-1.5-pro", + GEMINI_1_5_FLASH = "gemini-1.5-flash", + GEMINI_1_5_FLASH_8B = "gemini-1.5-flash-8b", } +--- Retrieves the Gemini API key from the macOS keychain. +-- This function executes the `security` command to fetch the password +-- stored for the "gemini-cli" generic password item. +-- @return (string|nil) The API key if found, or nil if an error occurs. +local function get_api_key() + -- The `security` command is specific to macOS. + if vim.fn.has("mac") == 0 then + vim.notify("Keychain access is only supported on macOS.", vim.log.levels.ERROR) + return nil + end + + local cmd = { "security", "find-generic-password", "-l", "gemini-cli", "-w" } + -- Execute the command synchronously and wait for the result. + local result = vim.system(cmd):wait() + + -- Check if the command executed successfully. A non-zero exit code indicates an error. + if result.code ~= 0 then + vim.notify("Error getting Gemini API key from keychain. Is it stored under 'gemini-cli'?", vim.log.levels.ERROR) + -- Log stderr for debugging purposes. + if result.stderr and result.stderr ~= "" then + vim.notify("Keychain Error: " .. result.stderr, vim.log.levels.INFO) + end + return nil + end + + -- The key is in stdout. Trim whitespace and newlines from the output. + local key = vim.trim(result.stdout) + + -- Check if the retrieved key is empty. + if key == "" then + vim.notify("Gemini API key from keychain is empty.", vim.log.levels.WARN) + return nil + end + + return key +end + +--- Generates content using the Gemini API (non-streaming). +-- @param user_text (string) The user's prompt. +-- @param system_text (string|nil) Optional system instructions. +-- @param model_name (string) The model to use from M.MODELS. +-- @param generation_config (table) Configuration for the generation request. +-- @param callback (function|nil) Optional callback for asynchronous execution. M.gemini_generate_content = function(user_text, system_text, model_name, generation_config, callback) - local api_key = os.getenv("GEMINI_API_KEY") - if not api_key then - return '' - end - - local api = API .. model_name .. ':generateContent?key=' .. api_key - local contents = { - { - role = 'user', - parts = { - { - text = user_text - } - } - } - } - local data = { - contents = contents, - generationConfig = generation_config, - } - if system_text then - data.systemInstruction = { - role = 'user', - parts = { - { - text = system_text, - } - } - } - end - - local json_text = vim.json.encode(data) - local cmd = { 'curl', '-X', 'POST', api, '-H', 'Content-Type: application/json', '--data-binary', '@-' } - local opts = { stdin = json_text } - if callback then - return vim.system(cmd, opts, callback) - else - return vim.system(cmd, opts) - end + -- Retrieve the API key from the keychain instead of an environment variable. + local api_key = get_api_key() + if not api_key then + -- Return an empty string to maintain the original function's behavior on failure. + return "" + end + + local api = API .. model_name .. ":generateContent?key=" .. api_key + local contents = { + { + role = "user", + parts = { + { + text = user_text, + }, + }, + }, + } + local data = { + contents = contents, + generationConfig = generation_config, + } + if system_text then + data.systemInstruction = { + role = "user", + parts = { + { + text = system_text, + }, + }, + } + end + + local json_text = vim.json.encode(data) + local cmd = { "curl", "-X", "POST", api, "-H", "Content-Type: application/json", "--data-binary", "@-" } + local opts = { stdin = json_text } + + -- Execute synchronously or asynchronously based on whether a callback is provided. + if callback then + return vim.system(cmd, opts, callback) + else + return vim.system(cmd, opts) + end end +--- Generates content using the Gemini API (streaming). +-- @param user_text (string) The user's prompt. +-- @param model_name (string) The model to use from M.MODELS. +-- @param generation_config (table) Configuration for the generation request. +-- @param callback (function) Callback to handle each streamed data chunk. M.gemini_generate_content_stream = function(user_text, model_name, generation_config, callback) - local api_key = os.getenv("GEMINI_API_KEY") - if not api_key then - return - end - - if not callback then - return - end - - local api = API .. model_name .. ':streamGenerateContent?alt=sse&key=' .. api_key - local data = { - contents = { - { - role = 'user', - parts = { - { - text = user_text - } - } - } - }, - generationConfig = generation_config, - } - local json_text = vim.json.encode(data) - - local stdin = uv.new_pipe() - local stdout = uv.new_pipe() - local stderr = uv.new_pipe() - local options = { - stdio = { stdin, stdout, stderr }, - args = { api, '-X', 'POST', '-s', '-H', 'Content-Type: application/json', '-d', json_text } - } - - uv.spawn('curl', options, function(code, _) - print("gemini chat finished exit code", code) - end) - - local streamed_data = '' - uv.read_start(stdout, function(err, data) - if not err and data then - streamed_data = streamed_data .. data - - local start_index = string.find(streamed_data, 'data:') - local end_index = string.find(streamed_data, '\r') - local json_text = '' - while start_index and end_index do - if end_index >= start_index then - json_text = string.sub(streamed_data, start_index + 5, end_index - 1) - callback(json_text) - end - streamed_data = string.sub(streamed_data, end_index + 1) - start_index = string.find(streamed_data, 'data:') - end_index = string.find(streamed_data, '\r') - end - end - end) + -- Retrieve the API key from the keychain. + local api_key = get_api_key() + if not api_key then + return + end + + if not callback then + vim.notify("Streaming requires a callback function.", vim.log.levels.ERROR) + return + end + + local api = API .. model_name .. ":streamGenerateContent?alt=sse&key=" .. api_key + local data = { + contents = { + { + role = "user", + parts = { + { + text = user_text, + }, + }, + }, + }, + generationConfig = generation_config, + } + local json_text = vim.json.encode(data) + + local stdout = uv.new_pipe(false) + local stderr = uv.new_pipe(false) + local handle + + -- Arguments for the curl command. Using -N disables output buffering, which is ideal for streams. + -- The API URL is placed at the end, which is the standard position for curl. + local options = { + args = { "curl", "-X", "POST", "-s", "-N", "-H", "Content-Type: application/json", "-d", json_text, api }, + stdio = { nil, stdout, stderr }, + } + + -- Spawn the curl process. + handle = uv.spawn("curl", options, function(code, _) + -- Ensure pipes are closed when the process exits. + stdout:close() + stderr:close() + if code ~= 0 then + vim.notify("Gemini stream finished with non-zero exit code: " .. tostring(code), vim.log.levels.WARN) + end + end) + + if not handle then + vim.notify("Failed to spawn curl for streaming.", vim.log.levels.ERROR) + stdout:close() + stderr:close() + return + end + + local streamed_data = "" + -- Start reading from stdout. + uv.read_start(stdout, function(err, data) + if err then + return + end + if data then + streamed_data = streamed_data .. data + + -- Process Server-Sent Events (SSE). Events are separated by double newlines. + while true do + local s, e = string.find(streamed_data, "\n\n", 1, true) + if not s then + break -- No complete event block found, wait for more data. + end + + -- Extract the complete event block. + local chunk = string.sub(streamed_data, 1, e - 1) + -- Remove the processed block from the buffer. + streamed_data = string.sub(streamed_data, e + 1) + + -- Find the 'data:' line within the event block and pass it to the callback. + local data_line = string.match(chunk, "data: (.*)") + if data_line then + callback(data_line) + end + end + end + end) + + -- Optionally, read from stderr for debugging. + uv.read_start(stderr, function(err, data) + if not err and data then + vim.notify("Gemini stream stderr: " .. data, vim.log.levels.INFO) + end + end) end return M From 848125a949fd68aa83d33f26855bed2c467765c0 Mon Sep 17 00:00:00 2001 From: Krystian Piecko Date: Tue, 2 Sep 2025 20:12:25 -0400 Subject: [PATCH 02/10] feat: Make API key retrieval asynchronous Refactored the get_api_key function to be asynchronous (get_api_key_async) using vim.loop.spawn. This prevents blocking during streaming operations. - Updated gemini_generate_content and gemini_generate_content_stream to use the new asynchronous function. - Adjusted the test suite in tests/gemini/api_spec.lua to handle the asynchronous nature of the API calls, ensuring tests pass. --- lua/gemini/api.lua | 301 +++++++++++++++++++++----------------- tests/gemini/api_spec.lua | 44 ++++-- 2 files changed, 198 insertions(+), 147 deletions(-) diff --git a/lua/gemini/api.lua b/lua/gemini/api.lua index 4fa92b6..f5b8c95 100644 --- a/lua/gemini/api.lua +++ b/lua/gemini/api.lua @@ -21,192 +21,223 @@ M.MODELS = { GEMINI_1_5_FLASH_8B = "gemini-1.5-flash-8b", } ---- Retrieves the Gemini API key from the macOS keychain. +--- Retrieves the Gemini API key from the macOS keychain asynchronously. -- This function executes the `security` command to fetch the password -- stored for the "gemini-cli" generic password item. --- @return (string|nil) The API key if found, or nil if an error occurs. -local function get_api_key() +-- @param callback (function) A callback function that receives the API key. +local function get_api_key_async(callback) -- The `security` command is specific to macOS. if vim.fn.has("mac") == 0 then vim.notify("Keychain access is only supported on macOS.", vim.log.levels.ERROR) - return nil + callback(nil) + return end - local cmd = { "security", "find-generic-password", "-l", "gemini-cli", "-w" } - -- Execute the command synchronously and wait for the result. - local result = vim.system(cmd):wait() + local cmd = "security" + local args = { "find-generic-password", "-l", "gemini-cli", "-w" } + local stdout = vim.loop.new_pipe(false) + local stderr = vim.loop.new_pipe(false) + local key_buffer = "" + local err_buffer = "" - -- Check if the command executed successfully. A non-zero exit code indicates an error. - if result.code ~= 0 then - vim.notify("Error getting Gemini API key from keychain. Is it stored under 'gemini-cli'?", vim.log.levels.ERROR) - -- Log stderr for debugging purposes. - if result.stderr and result.stderr ~= "" then - vim.notify("Keychain Error: " .. result.stderr, vim.log.levels.INFO) - end - return nil - end + local handle +handle = vim.loop.spawn(cmd, { + args = args, + stdio = { nil, stdout, stderr }, + }, function(code, _) + stdout:close() + stderr:close() + handle:close() - -- The key is in stdout. Trim whitespace and newlines from the output. - local key = vim.trim(result.stdout) + if code ~= 0 then + vim.notify( + "Error getting Gemini API key from keychain. Is it stored under 'gemini-cli'?", + vim.log.levels.ERROR + ) + if err_buffer ~= "" then + vim.notify("Keychain Error: " .. err_buffer, vim.log.levels.INFO) + end + callback(nil) + else + local key = vim.trim(key_buffer) + if key == "" then + vim.notify("Gemini API key from keychain is empty.", vim.log.levels.WARN) + callback(nil) + else + callback(key) + end + end + end) - -- Check if the retrieved key is empty. - if key == "" then - vim.notify("Gemini API key from keychain is empty.", vim.log.levels.WARN) - return nil - end +vim.loop.read_start(stdout, function(err, data) + assert(not err, err) + if data then + key_buffer = key_buffer .. data + end +end) - return key +vim.loop.read_start(stderr, function(err, data) + assert(not err, err) + if data then + err_buffer = err_buffer .. data + end +end) end ---- Generates content using the Gemini API (non-streaming). +--- +-- Generates content using the Gemini API (non-streaming). -- @param user_text (string) The user's prompt. -- @param system_text (string|nil) Optional system instructions. -- @param model_name (string) The model to use from M.MODELS. -- @param generation_config (table) Configuration for the generation request. -- @param callback (function|nil) Optional callback for asynchronous execution. M.gemini_generate_content = function(user_text, system_text, model_name, generation_config, callback) - -- Retrieve the API key from the keychain instead of an environment variable. - local api_key = get_api_key() - if not api_key then - -- Return an empty string to maintain the original function's behavior on failure. - return "" - end + get_api_key_async(function(api_key) + if not api_key then + if callback then + callback(nil, "Failed to get API key") + end + return + end - local api = API .. model_name .. ":generateContent?key=" .. api_key - local contents = { - { - role = "user", - parts = { - { - text = user_text, - }, - }, - }, - } - local data = { - contents = contents, - generationConfig = generation_config, - } - if system_text then - data.systemInstruction = { - role = "user", - parts = { - { - text = system_text, + local api = API .. model_name .. ":generateContent?key=" .. api_key + local contents = { + { + role = "user", + parts = { + { + text = user_text, + }, }, }, } - end + local data = { + contents = contents, + generationConfig = generation_config, + } + if system_text then + data.systemInstruction = { + role = "user", + parts = { + { + text = system_text, + }, + }, + } + end - local json_text = vim.json.encode(data) - local cmd = { "curl", "-X", "POST", api, "-H", "Content-Type: application/json", "--data-binary", "@-" } - local opts = { stdin = json_text } + local json_text = vim.json.encode(data) + local cmd = { "curl", "-X", "POST", api, "-H", "Content-Type: application/json", "--data-binary", "@-" } + local opts = { stdin = json_text } - -- Execute synchronously or asynchronously based on whether a callback is provided. - if callback then - return vim.system(cmd, opts, callback) - else - return vim.system(cmd, opts) - end + -- Execute synchronously or asynchronously based on whether a callback is provided. + if callback then + vim.system(cmd, opts, callback) + else + return vim.system(cmd, opts) + end + end) end ---- Generates content using the Gemini API (streaming). +--- +-- Generates content using the Gemini API (streaming). -- @param user_text (string) The user's prompt. -- @param model_name (string) The model to use from M.MODELS. -- @param generation_config (table) Configuration for the generation request. -- @param callback (function) Callback to handle each streamed data chunk. M.gemini_generate_content_stream = function(user_text, model_name, generation_config, callback) - -- Retrieve the API key from the keychain. - local api_key = get_api_key() - if not api_key then - return - end - if not callback then vim.notify("Streaming requires a callback function.", vim.log.levels.ERROR) return end - local api = API .. model_name .. ":streamGenerateContent?alt=sse&key=" .. api_key - local data = { - contents = { - { - role = "user", - parts = { - { - text = user_text, + get_api_key_async(function(api_key) + if not api_key then + return + end + + local api = API .. model_name .. ":streamGenerateContent?alt=sse&key=" .. api_key + local data = { + contents = { + { + role = "user", + parts = { + { + text = user_text, + }, }, }, }, - }, - generationConfig = generation_config, - } - local json_text = vim.json.encode(data) - - local stdout = uv.new_pipe(false) - local stderr = uv.new_pipe(false) - local handle + generationConfig = generation_config, + } + local json_text = vim.json.encode(data) - -- Arguments for the curl command. Using -N disables output buffering, which is ideal for streams. - -- The API URL is placed at the end, which is the standard position for curl. - local options = { - args = { "curl", "-X", "POST", "-s", "-N", "-H", "Content-Type: application/json", "-d", json_text, api }, - stdio = { nil, stdout, stderr }, - } + local stdout = uv.new_pipe(false) + local stderr = uv.new_pipe(false) + local handle - -- Spawn the curl process. - handle = uv.spawn("curl", options, function(code, _) - -- Ensure pipes are closed when the process exits. - stdout:close() - stderr:close() - if code ~= 0 then - vim.notify("Gemini stream finished with non-zero exit code: " .. tostring(code), vim.log.levels.WARN) - end - end) + -- Arguments for the curl command. Using -N disables output buffering, which is ideal for streams. + -- The API URL is placed at the end, which is the standard position for curl. + local options = { + args = { "curl", "-X", "POST", "-s", "-N", "-H", "Content-Type: application/json", "-d", json_text, api }, + stdio = { nil, stdout, stderr }, + } - if not handle then - vim.notify("Failed to spawn curl for streaming.", vim.log.levels.ERROR) - stdout:close() - stderr:close() - return - end + -- Spawn the curl process. + handle = uv.spawn("curl", options, function(code, _) + -- Ensure pipes are closed when the process exits. + stdout:close() + stderr:close() + if code ~= 0 then + vim.notify("Gemini stream finished with non-zero exit code: " .. tostring(code), vim.log.levels.WARN) + end + end) - local streamed_data = "" - -- Start reading from stdout. - uv.read_start(stdout, function(err, data) - if err then + if not handle then + vim.notify("Failed to spawn curl for streaming.", vim.log.levels.ERROR) + stdout:close() + stderr:close() return end - if data then - streamed_data = streamed_data .. data - -- Process Server-Sent Events (SSE). Events are separated by double newlines. - while true do - local s, e = string.find(streamed_data, "\n\n", 1, true) - if not s then - break -- No complete event block found, wait for more data. - end - - -- Extract the complete event block. - local chunk = string.sub(streamed_data, 1, e - 1) - -- Remove the processed block from the buffer. - streamed_data = string.sub(streamed_data, e + 1) - - -- Find the 'data:' line within the event block and pass it to the callback. - local data_line = string.match(chunk, "data: (.*)") - if data_line then - callback(data_line) + local streamed_data = "" + -- Start reading from stdout. + uv.read_start(stdout, function(err, data) + if err then + return + end + if data then + streamed_data = streamed_data .. data + + -- Process Server-Sent Events (SSE). Events are separated by double newlines. + while true do + local s, e = string.find(streamed_data, "\n\n", 1, true) + if not s then + break -- No complete event block found, wait for more data. + end + + -- Extract the complete event block. + local chunk = string.sub(streamed_data, 1, e - 1) + -- Remove the processed block from the buffer. + streamed_data = string.sub(streamed_data, e + 1) + + -- Find the 'data:' line within the event block and pass it to the callback. + local data_line = string.match(chunk, "data: (.*)") + if data_line then + callback(data_line) + end end end - end - end) + end) - -- Optionally, read from stderr for debugging. - uv.read_start(stderr, function(err, data) - if not err and data then - vim.notify("Gemini stream stderr: " .. data, vim.log.levels.INFO) - end + -- Optionally, read from stderr for debugging. + uv.read_start(stderr, function(err, data) + if not err and data then + vim.notify("Gemini stream stderr: " .. data, vim.log.levels.INFO) + end + end) end) end + return M diff --git a/tests/gemini/api_spec.lua b/tests/gemini/api_spec.lua index 12cac15..004903b 100644 --- a/tests/gemini/api_spec.lua +++ b/tests/gemini/api_spec.lua @@ -3,25 +3,38 @@ local util = require('gemini.util') describe('api', function() it('should send message', function() + local completed = false + local result_table + local generation_config = { temperature = 0.9, top_k = 1.0, max_output_tokens = 2048, response_mime_type = 'text/plain', } - local future = api.gemini_generate_content('hello there', nil, api.MODELS.GEMINI_2_0_FLASH, generation_config, nil) - local result = future:wait() - local stdout = result.stdout - print(stdout) - assert(#stdout > 0) - local result = vim.json.decode(stdout) + api.gemini_generate_content('hello there', nil, api.MODELS.GEMINI_2_0_FLASH, generation_config, function(result) + result_table = result + completed = true + end) + + vim.wait(5000, function() return completed end) + + assert.is_not_nil(result_table) + assert.is_equal(result_table.code, 0) + assert.is_not_nil(result_table.stdout) + assert(#result_table.stdout > 0) + + local result = vim.json.decode(result_table.stdout) local model_response = util.table_get(result, { 'candidates', 1, 'content', 'parts', 1, 'text' }) assert(#model_response > 0) end) it('should send long message', function() + local completed = false + local result_table + local generation_config = { temperature = 0.9, top_k = 1.0, @@ -29,13 +42,20 @@ describe('api', function() response_mime_type = 'text/plain', } local long_message = string.rep('this is a very very long message ', 3000) - local future = api.gemini_generate_content(long_message, nil, api.MODELS.GEMINI_2_0_FLASH, generation_config, nil) - local result = future:wait() - local stdout = result.stdout - print(stdout) - assert(#stdout > 0) - local result = vim.json.decode(stdout) + api.gemini_generate_content(long_message, nil, api.MODELS.GEMINI_2_0_FLASH, generation_config, function(result) + result_table = result + completed = true + end) + + vim.wait(20000, function() return completed end) + + assert.is_not_nil(result_table) + assert.is_equal(result_table.code, 0) + assert.is_not_nil(result_table.stdout) + assert(#result_table.stdout > 0) + + local result = vim.json.decode(result_table.stdout) local model_response = util.table_get(result, { 'candidates', 1, 'content', 'parts', 1, 'text' }) assert(#model_response > 0) From 7659f7892514d76f88190919602df57f0fe326e3 Mon Sep 17 00:00:00 2001 From: Krystian Piecko Date: Thu, 4 Sep 2025 13:27:43 -0400 Subject: [PATCH 03/10] feat: Read API key from file, fallback to keychain --- lua/gemini/api.lua | 142 ++++++++++++++++++++++++++++----------------- 1 file changed, 90 insertions(+), 52 deletions(-) diff --git a/lua/gemini/api.lua b/lua/gemini/api.lua index f5b8c95..3d03e2f 100644 --- a/lua/gemini/api.lua +++ b/lua/gemini/api.lua @@ -21,67 +21,105 @@ M.MODELS = { GEMINI_1_5_FLASH_8B = "gemini-1.5-flash-8b", } ---- Retrieves the Gemini API key from the macOS keychain asynchronously. --- This function executes the `security` command to fetch the password --- stored for the "gemini-cli" generic password item. +local function get_api_key_from_file(callback) + local path = vim.fn.expand("~/.gemini/api.key") + vim.uv.fs_open(path, "r", 438, function(err, fd) + if err then + callback(nil) -- File doesn't exist or can't be opened, fallback. + return + end + vim.uv.fs_fstat(fd, function(err, stat) + if err then + vim.uv.fs_close(fd, function() end) + callback(nil) + return + end + vim.uv.fs_read(fd, stat.size, 0, function(err, data) + vim.uv.fs_close(fd, function() end) + if err then + callback(nil) + return + end + local key = vim.trim(data) + if key == "" then + vim.notify("API key file is empty.", vim.log.levels.WARN) + callback(nil) + else + callback(key) + end + end) + end) + end) +end + +--- Retrieves the Gemini API key. +-- It first tries to read from ~/.gemini/api.key. +-- If that fails, it falls back to the macOS keychain. -- @param callback (function) A callback function that receives the API key. local function get_api_key_async(callback) - -- The `security` command is specific to macOS. - if vim.fn.has("mac") == 0 then - vim.notify("Keychain access is only supported on macOS.", vim.log.levels.ERROR) - callback(nil) - return - end + get_api_key_from_file(function(key) + if key then + callback(key) + return + end - local cmd = "security" - local args = { "find-generic-password", "-l", "gemini-cli", "-w" } - local stdout = vim.loop.new_pipe(false) - local stderr = vim.loop.new_pipe(false) - local key_buffer = "" - local err_buffer = "" - - local handle -handle = vim.loop.spawn(cmd, { - args = args, - stdio = { nil, stdout, stderr }, - }, function(code, _) - stdout:close() - stderr:close() - handle:close() - - if code ~= 0 then - vim.notify( - "Error getting Gemini API key from keychain. Is it stored under 'gemini-cli'?", - vim.log.levels.ERROR - ) - if err_buffer ~= "" then - vim.notify("Keychain Error: " .. err_buffer, vim.log.levels.INFO) - end + -- Fallback to keychain if file method fails. + if vim.fn.has("mac") == 0 then + vim.notify("API key file not found and keychain access is only supported on macOS.", vim.log.levels.ERROR) callback(nil) - else - local key = vim.trim(key_buffer) - if key == "" then - vim.notify("Gemini API key from keychain is empty.", vim.log.levels.WARN) + return + end + + local cmd = "security" + local args = { "find-generic-password", "-l", "gemini-cli", "-w" } + local stdout = vim.loop.new_pipe(false) + local stderr = vim.loop.new_pipe(false) + local key_buffer = "" + local err_buffer = "" + + local handle + handle = vim.loop.spawn(cmd, { + args = args, + stdio = { nil, stdout, stderr }, + }, function(code, _) + stdout:close() + stderr:close() + handle:close() + + if code ~= 0 then + vim.notify( + "Error getting Gemini API key from keychain. Is it stored under 'gemini-cli'?", + vim.log.levels.ERROR + ) + if err_buffer ~= "" then + vim.notify("Keychain Error: " .. err_buffer, vim.log.levels.INFO) + end callback(nil) else - callback(key) + local key = vim.trim(key_buffer) + if key == "" then + vim.notify("Gemini API key from keychain is empty.", vim.log.levels.WARN) + callback(nil) + else + callback(key) + end end - end - end) + end) -vim.loop.read_start(stdout, function(err, data) - assert(not err, err) - if data then - key_buffer = key_buffer .. data - end -end) + vim.loop.read_start(stdout, function(err, data) + assert(not err, err) + if data then + key_buffer = key_buffer .. data + end + end) -vim.loop.read_start(stderr, function(err, data) - assert(not err, err) - if data then - err_buffer = err_buffer .. data - end -end) + vim.loop.read_start(stderr, function(err, data) + assert(not err, err) + if data then + err_buffer = err_buffer .. data + end + end) + end) end --- From b623d065b531b0a4235aa82993237ed15091645d Mon Sep 17 00:00:00 2001 From: Krystian Piecko Date: Thu, 4 Sep 2025 13:37:49 -0400 Subject: [PATCH 04/10] refactor: Add debug notifications for API key loading --- lua/gemini/api.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lua/gemini/api.lua b/lua/gemini/api.lua index 3d03e2f..9074230 100644 --- a/lua/gemini/api.lua +++ b/lua/gemini/api.lua @@ -45,6 +45,7 @@ local function get_api_key_from_file(callback) vim.notify("API key file is empty.", vim.log.levels.WARN) callback(nil) else + vim.notify("Gemini: Successfully loaded API key from ~/.gemini/api.key") callback(key) end end) @@ -88,7 +89,7 @@ local function get_api_key_async(callback) if code ~= 0 then vim.notify( - "Error getting Gemini API key from keychain. Is it stored under 'gemini-cli'?", + "Gemini: Could not find API key in file or keychain.", vim.log.levels.ERROR ) if err_buffer ~= "" then @@ -101,6 +102,7 @@ local function get_api_key_async(callback) vim.notify("Gemini API key from keychain is empty.", vim.log.levels.WARN) callback(nil) else + vim.notify("Gemini: Successfully loaded API key from macOS keychain.") callback(key) end end From 17f44315635eea14f0258a30af6ef1083bba899b Mon Sep 17 00:00:00 2001 From: Krystian Piecko Date: Thu, 4 Sep 2025 14:08:33 -0400 Subject: [PATCH 05/10] fix: Improve error reporting for API stream --- lua/gemini/api.lua | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lua/gemini/api.lua b/lua/gemini/api.lua index 9074230..d097f9d 100644 --- a/lua/gemini/api.lua +++ b/lua/gemini/api.lua @@ -215,6 +215,7 @@ M.gemini_generate_content_stream = function(user_text, model_name, generation_co local stdout = uv.new_pipe(false) local stderr = uv.new_pipe(false) local handle + local stderr_buffer = "" -- Arguments for the curl command. Using -N disables output buffering, which is ideal for streams. -- The API URL is placed at the end, which is the standard position for curl. @@ -229,7 +230,15 @@ M.gemini_generate_content_stream = function(user_text, model_name, generation_co stdout:close() stderr:close() if code ~= 0 then - vim.notify("Gemini stream finished with non-zero exit code: " .. tostring(code), vim.log.levels.WARN) + vim.schedule(function() + vim.notify( + "Gemini stream finished with non-zero exit code: " .. tostring(code), + vim.log.levels.ERROR + ) + if stderr_buffer ~= "" then + vim.notify("Gemini API Error: " .. stderr_buffer, vim.log.levels.ERROR) + end + end) end end) @@ -273,11 +282,12 @@ M.gemini_generate_content_stream = function(user_text, model_name, generation_co -- Optionally, read from stderr for debugging. uv.read_start(stderr, function(err, data) if not err and data then - vim.notify("Gemini stream stderr: " .. data, vim.log.levels.INFO) + stderr_buffer = stderr_buffer .. data end end) end) end + return M From 55c40cbc775846e931d8ebf39e454a9a5872a3cd Mon Sep 17 00:00:00 2001 From: Krystian Piecko Date: Thu, 4 Sep 2025 14:15:00 -0400 Subject: [PATCH 06/10] fix(api): Trim whitespace from stream response before parsing --- lua/gemini/api.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/gemini/api.lua b/lua/gemini/api.lua index d097f9d..ff14e63 100644 --- a/lua/gemini/api.lua +++ b/lua/gemini/api.lua @@ -273,7 +273,7 @@ M.gemini_generate_content_stream = function(user_text, model_name, generation_co -- Find the 'data:' line within the event block and pass it to the callback. local data_line = string.match(chunk, "data: (.*)") if data_line then - callback(data_line) + callback(vim.trim(data_line)) end end end From 34c51de676dc5932a447f88d2abf3c75de9bca6d Mon Sep 17 00:00:00 2001 From: Krystian Piecko Date: Thu, 4 Sep 2025 14:19:20 -0400 Subject: [PATCH 07/10] debug: Add notification to inspect JSON before decoding --- lua/gemini/chat.lua | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lua/gemini/chat.lua b/lua/gemini/chat.lua index 686fe73..a8c2a3e 100644 --- a/lua/gemini/chat.lua +++ b/lua/gemini/chat.lua @@ -28,7 +28,12 @@ M.start_chat = function(context) local text = '' local model_id = config.get_config({ 'model', 'model_id' }) api.gemini_generate_content_stream(user_text, model_id, generation_config, function(json_text) + vim.notify("Gemini trying to decode: " .. vim.inspect(json_text)) local model_response = vim.json.decode(json_text) + if not model_response then + vim.notify("Gemini JSON decoding failed for the text above.", vim.log.levels.ERROR) + return + end model_response = util.table_get(model_response, { 'candidates', 1, 'content', 'parts', 1, 'text' }) if not model_response then return From cf18a1b4a71c631b83fadfb1d7e2ca4cfa32dac0 Mon Sep 17 00:00:00 2001 From: Krystian Piecko Date: Thu, 4 Sep 2025 14:22:37 -0400 Subject: [PATCH 08/10] refactor(chat): Use non-streaming API call for robustness --- lua/gemini/chat.lua | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/lua/gemini/chat.lua b/lua/gemini/chat.lua index a8c2a3e..b4c2d03 100644 --- a/lua/gemini/chat.lua +++ b/lua/gemini/chat.lua @@ -25,26 +25,36 @@ M.start_chat = function(context) vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) local generation_config = config.get_gemini_generation_config() - local text = '' local model_id = config.get_config({ 'model', 'model_id' }) - api.gemini_generate_content_stream(user_text, model_id, generation_config, function(json_text) - vim.notify("Gemini trying to decode: " .. vim.inspect(json_text)) - local model_response = vim.json.decode(json_text) - if not model_response then - vim.notify("Gemini JSON decoding failed for the text above.", vim.log.levels.ERROR) + + api.gemini_generate_content(user_text, nil, model_id, generation_config, function(err, data) + if err then + vim.notify("Gemini API Error: " .. vim.inspect(err), vim.log.levels.ERROR) return end - model_response = util.table_get(model_response, { 'candidates', 1, 'content', 'parts', 1, 'text' }) + if data == "" or data == nil then + vim.notify("Gemini API returned empty response.", vim.log.levels.ERROR) + return + end + + local response_data = vim.json.decode(data) + if not response_data then + vim.notify("Failed to decode Gemini API response: " .. data, vim.log.levels.ERROR) + return + end + + local model_response = util.table_get(response_data, { 'candidates', 1, 'content', 'parts', 1, 'text' }) if not model_response then + vim.notify("Unexpected API response structure: " .. data, vim.log.levels.ERROR) return end - text = text .. model_response vim.schedule(function() - lines = vim.split(text, '\n') + lines = vim.split(model_response, '\n') vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) end) end) end + return M From f1de04303d6e35bf6d0259ba0c992e09838d3542 Mon Sep 17 00:00:00 2001 From: Krystian Piecko Date: Thu, 4 Sep 2025 14:29:50 -0400 Subject: [PATCH 09/10] fix(api): Automatically look up model name from config --- lua/gemini/api.lua | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lua/gemini/api.lua b/lua/gemini/api.lua index ff14e63..319f676 100644 --- a/lua/gemini/api.lua +++ b/lua/gemini/api.lua @@ -140,7 +140,10 @@ M.gemini_generate_content = function(user_text, system_text, model_name, generat return end - local api = API .. model_name .. ":generateContent?key=" .. api_key + -- If the user provides a key from the MODELS table (e.g., "GEMINI_2_5_FLASH"), + -- look up its value (e.g., "gemini-2.5-flash"). + local final_model_name = M.MODELS[model_name] or model_name + local api = API .. final_model_name .. ":generateContent?key=" .. api_key local contents = { { role = "user", @@ -196,7 +199,10 @@ M.gemini_generate_content_stream = function(user_text, model_name, generation_co return end - local api = API .. model_name .. ":streamGenerateContent?alt=sse&key=" .. api_key + -- If the user provides a key from the MODELS table (e.g., "GEMINI_2_5_FLASH"), + -- look up its value (e.g., "gemini-2.5-flash"). + local final_model_name = M.MODELS[model_name] or model_name + local api = API .. final_model_name .. ":streamGenerateContent?alt=sse&key=" .. api_key local data = { contents = { { From 2d6382653d836128c291596edf3644987cfb8af6 Mon Sep 17 00:00:00 2001 From: Krystian Piecko Date: Thu, 4 Sep 2025 14:35:08 -0400 Subject: [PATCH 10/10] fix(chat): Correctly parse vim.system callback object --- lua/gemini/chat.lua | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lua/gemini/chat.lua b/lua/gemini/chat.lua index b4c2d03..bd090f7 100644 --- a/lua/gemini/chat.lua +++ b/lua/gemini/chat.lua @@ -27,11 +27,13 @@ M.start_chat = function(context) local generation_config = config.get_gemini_generation_config() local model_id = config.get_config({ 'model', 'model_id' }) - api.gemini_generate_content(user_text, nil, model_id, generation_config, function(err, data) - if err then - vim.notify("Gemini API Error: " .. vim.inspect(err), vim.log.levels.ERROR) + api.gemini_generate_content(user_text, nil, model_id, generation_config, function(obj) + if obj.code ~= 0 then + vim.notify("Gemini API Error: " .. vim.inspect(obj), vim.log.levels.ERROR) return end + + local data = obj.stdout if data == "" or data == nil then vim.notify("Gemini API returned empty response.", vim.log.levels.ERROR) return