Import 0.9.0 development baseline
This commit is contained in:
314
llm_api.lua
Normal file
314
llm_api.lua
Normal file
@@ -0,0 +1,314 @@
|
||||
-- llm_api.lua
|
||||
-- Central LLM API interface for LLM-Connect (v0.8+)
|
||||
|
||||
local core = core
|
||||
local M = {}
|
||||
|
||||
-- Internal states
|
||||
M.http = nil
|
||||
M.config = {
|
||||
api_key = "",
|
||||
api_url = "",
|
||||
model = "",
|
||||
max_tokens = 4000,
|
||||
max_tokens_integer = true,
|
||||
temperature = 0.7,
|
||||
top_p = 0.9,
|
||||
presence_penalty = 0.0,
|
||||
frequency_penalty = 0.0,
|
||||
timeout = 120, -- global fallback
|
||||
timeout_chat = 0, -- 0 = use global
|
||||
timeout_ide = 0,
|
||||
timeout_we = 0,
|
||||
language = "en",
|
||||
language_repeat = 1,
|
||||
-- context
|
||||
context_max_history = 20,
|
||||
-- ide
|
||||
ide_naming_guide = true,
|
||||
ide_include_run_output = true,
|
||||
ide_context_mod_list = true,
|
||||
ide_context_node_sample = true,
|
||||
ide_max_code_context = 300,
|
||||
-- worldedit
|
||||
we_max_iterations = 6,
|
||||
we_snapshot = true,
|
||||
}
|
||||
|
||||
local language_instruction_cache = nil
|
||||
|
||||
-- ============================================================
|
||||
-- Initialization
|
||||
-- ============================================================
|
||||
|
||||
function M.init(http_api)
|
||||
if not http_api then
|
||||
core.log("error", "[llm_api] No HTTP API provided")
|
||||
return false
|
||||
end
|
||||
M.http = http_api
|
||||
|
||||
-- Load settings once
|
||||
M.reload_config()
|
||||
return true
|
||||
end
|
||||
|
||||
-- ============================================================
|
||||
-- Configuration loading / updating
|
||||
-- ============================================================
|
||||
|
||||
function M.reload_config()
|
||||
-- Read exact keys from settingtypes.txt
|
||||
M.config.api_key = core.settings:get("llm_api_key") or ""
|
||||
M.config.api_url = core.settings:get("llm_api_url") or ""
|
||||
M.config.model = core.settings:get("llm_model") or ""
|
||||
|
||||
M.config.max_tokens = tonumber(core.settings:get("llm_max_tokens")) or 4000
|
||||
M.config.max_tokens_integer = core.settings:get_bool("llm_max_tokens_integer", true)
|
||||
|
||||
M.config.temperature = tonumber(core.settings:get("llm_temperature")) or 0.7
|
||||
M.config.top_p = tonumber(core.settings:get("llm_top_p")) or 0.9
|
||||
M.config.presence_penalty = tonumber(core.settings:get("llm_presence_penalty")) or 0.0
|
||||
M.config.frequency_penalty = tonumber(core.settings:get("llm_frequency_penalty")) or 0.0
|
||||
|
||||
M.config.timeout = tonumber(core.settings:get("llm_timeout")) or 120
|
||||
M.config.timeout_chat = tonumber(core.settings:get("llm_timeout_chat")) or 0
|
||||
M.config.timeout_ide = tonumber(core.settings:get("llm_timeout_ide")) or 0
|
||||
M.config.timeout_we = tonumber(core.settings:get("llm_timeout_we")) or 0
|
||||
|
||||
M.config.language = core.settings:get("llm_language") or "en"
|
||||
M.config.language_repeat = tonumber(core.settings:get("llm_language_instruction_repeat")) or 1
|
||||
|
||||
M.config.context_max_history = tonumber(core.settings:get("llm_context_max_history")) or 20
|
||||
|
||||
M.config.ide_naming_guide = core.settings:get_bool("llm_ide_naming_guide", true)
|
||||
M.config.ide_include_run_output = core.settings:get_bool("llm_ide_include_run_output", true)
|
||||
M.config.ide_context_mod_list = core.settings:get_bool("llm_ide_context_mod_list", true)
|
||||
M.config.ide_context_node_sample = core.settings:get_bool("llm_ide_context_node_sample", true)
|
||||
M.config.ide_max_code_context = tonumber(core.settings:get("llm_ide_max_code_context")) or 300
|
||||
|
||||
M.config.we_max_iterations = tonumber(core.settings:get("llm_we_max_iterations")) or 6
|
||||
M.config.we_snapshot = core.settings:get_bool("llm_we_snapshot_before_exec", true)
|
||||
|
||||
-- Invalidate cache
|
||||
language_instruction_cache = nil
|
||||
|
||||
end
|
||||
|
||||
-- Returns the effective timeout for a given mode ("chat", "ide", "we").
|
||||
-- Uses per-mode override if > 0, otherwise falls back to global llm_timeout.
|
||||
function M.get_timeout(mode)
|
||||
local override = 0
|
||||
if mode == "chat" then override = M.config.timeout_chat
|
||||
elseif mode == "ide" then override = M.config.timeout_ide
|
||||
elseif mode == "we" then override = M.config.timeout_we
|
||||
end
|
||||
if override and override > 0 then return override end
|
||||
return M.config.timeout
|
||||
end
|
||||
|
||||
function M.set_config(updates)
|
||||
for k, v in pairs(updates) do
|
||||
if M.config[k] ~= nil then
|
||||
M.config[k] = v
|
||||
end
|
||||
end
|
||||
language_instruction_cache = nil
|
||||
end
|
||||
|
||||
function M.is_configured()
|
||||
return M.config.api_key ~= "" and
|
||||
M.config.api_url ~= "" and
|
||||
M.config.model ~= ""
|
||||
end
|
||||
|
||||
-- ============================================================
|
||||
-- Language instruction (cached)
|
||||
-- ============================================================
|
||||
|
||||
local function get_language_instruction()
|
||||
if language_instruction_cache then
|
||||
return language_instruction_cache
|
||||
end
|
||||
|
||||
local lang = M.config.language
|
||||
local repeat_count = math.max(0, M.config.language_repeat or 1)
|
||||
|
||||
if lang == "en" or repeat_count == 0 then
|
||||
language_instruction_cache = ""
|
||||
return ""
|
||||
end
|
||||
|
||||
local lang_name = "English"
|
||||
local lang_mod_path = core.get_modpath("llm_connect") .. "/ide_languages.lua"
|
||||
local ok, lang_mod = pcall(dofile, lang_mod_path)
|
||||
if ok and lang_mod and lang_mod.get_language_name then
|
||||
lang_name = lang_mod.get_language_name(lang) or lang_name
|
||||
end
|
||||
|
||||
local instr = "Important: Answer exclusively in " .. lang_name .. "!\n" ..
|
||||
"All explanations, code, comments, output and any text you generate must be in " .. lang_name .. "."
|
||||
|
||||
local parts = {}
|
||||
for _ = 1, repeat_count do
|
||||
table.insert(parts, instr)
|
||||
end
|
||||
|
||||
language_instruction_cache = table.concat(parts, "\n\n") .. "\n\n"
|
||||
return language_instruction_cache
|
||||
end
|
||||
|
||||
-- ============================================================
|
||||
-- Request Function
|
||||
-- ============================================================
|
||||
|
||||
function M.request(messages, callback, options)
|
||||
if not M.is_configured() then
|
||||
callback({ success = false, error = "LLM API not configured (Check API Key/URL/Model)" })
|
||||
return
|
||||
end
|
||||
|
||||
options = options or {}
|
||||
local cfg = M.config
|
||||
|
||||
local lang_instr = get_language_instruction()
|
||||
if lang_instr ~= "" and (not messages[1] or messages[1].role ~= "system") then
|
||||
table.insert(messages, 1, { role = "system", content = lang_instr })
|
||||
end
|
||||
|
||||
local body_table = {
|
||||
model = options.model or cfg.model,
|
||||
messages = messages,
|
||||
max_tokens = options.max_tokens or cfg.max_tokens,
|
||||
temperature = options.temperature or cfg.temperature,
|
||||
top_p = options.top_p or cfg.top_p,
|
||||
presence_penalty = options.presence_penalty or cfg.presence_penalty,
|
||||
frequency_penalty = options.frequency_penalty or cfg.frequency_penalty,
|
||||
stream = options.stream == true,
|
||||
}
|
||||
|
||||
if options.tools then
|
||||
body_table.tools = options.tools
|
||||
body_table.tool_choice = options.tool_choice or "auto"
|
||||
end
|
||||
|
||||
local max_t = body_table.max_tokens
|
||||
if cfg.max_tokens_integer then
|
||||
body_table.max_tokens = math.floor(max_t)
|
||||
else
|
||||
body_table.max_tokens = tonumber(max_t)
|
||||
end
|
||||
|
||||
local body = core.write_json(body_table)
|
||||
|
||||
if cfg.max_tokens_integer then
|
||||
body = body:gsub('"max_tokens"%s*:%s*(%d+)%.0', '"max_tokens": %1')
|
||||
end
|
||||
|
||||
if core.settings:get_bool("llm_debug") then
|
||||
core.log("action", "[llm_api] Requesting " .. cfg.model .. " at " .. cfg.api_url)
|
||||
end
|
||||
|
||||
M.http.fetch({
|
||||
url = cfg.api_url,
|
||||
method = "POST",
|
||||
data = body,
|
||||
timeout = options.timeout or cfg.timeout,
|
||||
extra_headers = {
|
||||
"Content-Type: application/json",
|
||||
"Authorization: Bearer " .. cfg.api_key,
|
||||
},
|
||||
}, function(result)
|
||||
if not result.succeeded then
|
||||
local err = "HTTP request failed"
|
||||
if result.timeout then
|
||||
err = "Request timed out (limit: " .. tostring(options.timeout or cfg.timeout) .. "s)"
|
||||
elseif result.code then
|
||||
err = "HTTP " .. tostring(result.code)
|
||||
elseif result.error then
|
||||
-- Proxy-level errors (Envoy overflow, connection reset, etc.)
|
||||
local raw = tostring(result.error)
|
||||
if raw:find("overflow") or raw:find("reset") or raw:find("upstream") then
|
||||
err = "Proxy/upstream error (possibly Mistral overload or rate limit). Retry in a moment."
|
||||
else
|
||||
err = raw
|
||||
end
|
||||
end
|
||||
callback({ success = false, error = err, code = result.code })
|
||||
return
|
||||
end
|
||||
|
||||
-- Handle non-JSON responses (proxy errors often return plain text)
|
||||
local raw_data = tostring(result.data or "")
|
||||
if raw_data:find("upstream connect error") or raw_data:find("reset reason") then
|
||||
callback({ success = false, error = "Proxy/upstream error: " .. raw_data:sub(1, 80) .. " – possibly Mistral overload, retry in a moment." })
|
||||
return
|
||||
end
|
||||
|
||||
local response = core.parse_json(result.data)
|
||||
if not response or type(response) ~= "table" then
|
||||
local raw_preview = raw_data:sub(1, 120)
|
||||
callback({ success = false, error = "Invalid JSON response: " .. raw_preview })
|
||||
return
|
||||
end
|
||||
|
||||
if response.error then
|
||||
callback({
|
||||
success = false,
|
||||
error = response.error.message or "API error",
|
||||
error_type = response.error.type,
|
||||
code = response.error.code
|
||||
})
|
||||
return
|
||||
end
|
||||
|
||||
local content = nil
|
||||
if response.choices and response.choices[1] then
|
||||
content = response.choices[1].message.content
|
||||
elseif response.message and response.message.content then
|
||||
content = response.message.content
|
||||
end
|
||||
|
||||
local ret = {
|
||||
success = content ~= nil,
|
||||
content = content,
|
||||
raw = response,
|
||||
finish_reason = response.choices and response.choices[1] and response.choices[1].finish_reason,
|
||||
usage = response.usage,
|
||||
}
|
||||
|
||||
if response.choices and response.choices[1] and response.choices[1].message.tool_calls then
|
||||
ret.tool_calls = response.choices[1].message.tool_calls
|
||||
end
|
||||
|
||||
if core.settings:get_bool("llm_debug") then
|
||||
core.log("action", "[llm_api DEBUG] Raw response: " .. tostring(result.data or "no data"))
|
||||
core.log("action", "[llm_api DEBUG] Parsed: " .. core.write_json(response or {}, true))
|
||||
end
|
||||
|
||||
callback(ret)
|
||||
end)
|
||||
end
|
||||
|
||||
-- ============================================================
|
||||
-- Helper Wrappers
|
||||
-- ============================================================
|
||||
|
||||
function M.chat(messages, callback, options)
|
||||
M.request(messages, callback, options)
|
||||
end
|
||||
|
||||
function M.ask(system_prompt, user_message, callback, options)
|
||||
local messages = {
|
||||
{ role = "system", content = system_prompt },
|
||||
{ role = "user", content = user_message },
|
||||
}
|
||||
M.request(messages, callback, options)
|
||||
end
|
||||
|
||||
function M.code(system_prompt, code_block, callback, options)
|
||||
local user_msg = "```lua\n" .. code_block .. "\n```"
|
||||
M.ask(system_prompt, user_msg, callback, options)
|
||||
end
|
||||
|
||||
return M
|
||||
Reference in New Issue
Block a user