385 lines
11 KiB
Lua
385 lines
11 KiB
Lua
-- PUBLIC DOMAIN - NO LICENSE, NO WARRANTY
|
|
-- Copyright 2025 TimeHexOn & foxhop & russell@unturf
|
|
-- https://www.permacomputer.com
|
|
|
|
local http = require("socket.http")
|
|
local https = require("ssl.https")
|
|
local ltn12 = require("ltn12")
|
|
local json = require("cjson")
|
|
local socket = require("socket")
|
|
|
|
-- UncloseAI - Lua client for OpenAI-compatible APIs with streaming support
|
|
local UncloseAI = {}
|
|
UncloseAI.__index = UncloseAI
|
|
|
|
function UncloseAI.new(opts)
|
|
opts = opts or {}
|
|
|
|
local self = setmetatable({}, UncloseAI)
|
|
self.models = {}
|
|
self.tts_endpoints = {}
|
|
self.api_key = opts.api_key
|
|
self.timeout = opts.timeout or 30
|
|
self.debug = opts.debug or false
|
|
|
|
-- Discover endpoints from environment
|
|
local model_endpoints = opts.model_endpoints or self:_discover_env_endpoints("MODEL_ENDPOINT")
|
|
local tts_endpoints = opts.tts_endpoints or self:_discover_env_endpoints("TTS_ENDPOINT")
|
|
|
|
if self.debug then
|
|
print(string.format("[DEBUG] Initialized with %d endpoint(s)", #model_endpoints))
|
|
end
|
|
|
|
-- Discover models
|
|
self:_discover_models(model_endpoints)
|
|
self.tts_endpoints = tts_endpoints
|
|
|
|
return self
|
|
end
|
|
|
|
function UncloseAI:_discover_env_endpoints(prefix)
|
|
local endpoints = {}
|
|
for i = 1, 9999 do
|
|
local endpoint = os.getenv(prefix .. "_" .. tostring(i))
|
|
if not endpoint then break end
|
|
table.insert(endpoints, endpoint)
|
|
end
|
|
return endpoints
|
|
end
|
|
|
|
function UncloseAI:_discover_models(endpoints)
|
|
for _, endpoint in ipairs(endpoints) do
|
|
if self.debug then
|
|
print("[DEBUG] Discovering from: " .. endpoint)
|
|
end
|
|
|
|
local success, err = pcall(function()
|
|
local response_body = {}
|
|
local res, code = https.request{
|
|
url = endpoint .. "/models",
|
|
method = "GET",
|
|
sink = ltn12.sink.table(response_body)
|
|
}
|
|
|
|
if code == 200 then
|
|
local response = json.decode(table.concat(response_body))
|
|
if response.data then
|
|
for _, model in ipairs(response.data) do
|
|
table.insert(self.models, {
|
|
id = model.id,
|
|
endpoint = endpoint,
|
|
max_tokens = model.max_model_len or 8192
|
|
})
|
|
if self.debug then
|
|
print("[DEBUG] Discovered: " .. model.id)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end)
|
|
|
|
if not success and self.debug then
|
|
print("[DEBUG] Error: " .. tostring(err))
|
|
end
|
|
end
|
|
end
|
|
|
|
function UncloseAI:list_models()
|
|
local result = {}
|
|
for _, model in ipairs(self.models) do
|
|
table.insert(result, {
|
|
id = model.id,
|
|
endpoint = model.endpoint,
|
|
max_tokens = model.max_tokens
|
|
})
|
|
end
|
|
return result
|
|
end
|
|
|
|
function UncloseAI:_resolve_model(model)
|
|
if #self.models == 0 then
|
|
error("No models available")
|
|
end
|
|
|
|
if not model then
|
|
return self.models[1]
|
|
end
|
|
|
|
for _, m in ipairs(self.models) do
|
|
if m.id == model then
|
|
return m
|
|
end
|
|
end
|
|
|
|
error("Model '" .. model .. "' not found")
|
|
end
|
|
|
|
function UncloseAI:chat(messages, opts)
|
|
opts = opts or {}
|
|
local model_info = self:_resolve_model(opts.model)
|
|
local max_tokens = opts.max_tokens or 100
|
|
local temperature = opts.temperature or 0.7
|
|
|
|
local payload = {
|
|
model = model_info.id,
|
|
messages = messages,
|
|
max_tokens = max_tokens,
|
|
temperature = temperature,
|
|
stream = false
|
|
}
|
|
|
|
local request_body = json.encode(payload)
|
|
local response_body = {}
|
|
|
|
local headers = {
|
|
["Content-Type"] = "application/json",
|
|
["Content-Length"] = tostring(#request_body)
|
|
}
|
|
|
|
if self.api_key then
|
|
headers["Authorization"] = "Bearer " .. self.api_key
|
|
end
|
|
|
|
local res, code = https.request{
|
|
url = model_info.endpoint .. "/chat/completions",
|
|
method = "POST",
|
|
headers = headers,
|
|
source = ltn12.source.string(request_body),
|
|
sink = ltn12.sink.table(response_body)
|
|
}
|
|
|
|
if code == 200 then
|
|
return json.decode(table.concat(response_body))
|
|
else
|
|
error("Request failed with code: " .. tostring(code))
|
|
end
|
|
end
|
|
|
|
function UncloseAI:chat_stream(messages, opts, callback)
|
|
opts = opts or {}
|
|
local model_info = self:_resolve_model(opts.model)
|
|
local max_tokens = opts.max_tokens or 500
|
|
local temperature = opts.temperature or 0.7
|
|
|
|
local payload = {
|
|
model = model_info.id,
|
|
messages = messages,
|
|
max_tokens = max_tokens,
|
|
temperature = temperature,
|
|
stream = true
|
|
}
|
|
|
|
local request_body = json.encode(payload)
|
|
|
|
-- Parse URL
|
|
local protocol, host, port, path = model_info.endpoint:match("^(https?)://([^:/]+):?(%d*)(.*)$")
|
|
port = port and tonumber(port) or (protocol == "https" and 443 or 80)
|
|
path = (path == "" and "/v1" or path) .. "/chat/completions"
|
|
|
|
-- Create socket connection
|
|
local sock = socket.tcp()
|
|
sock:settimeout(self.timeout)
|
|
|
|
local success, err = pcall(function()
|
|
assert(sock:connect(host, port))
|
|
|
|
-- For HTTPS, wrap socket with SSL
|
|
if protocol == "https" then
|
|
local ssl = require("ssl")
|
|
sock = assert(ssl.wrap(sock, {mode = "client", protocol = "tlsv1_2"}))
|
|
assert(sock:dohandshake())
|
|
end
|
|
|
|
-- Send HTTP request
|
|
local headers = {
|
|
"POST " .. path .. " HTTP/1.1",
|
|
"Host: " .. host,
|
|
"Content-Type: application/json",
|
|
"Content-Length: " .. tostring(#request_body),
|
|
"Connection: close"
|
|
}
|
|
|
|
if self.api_key then
|
|
table.insert(headers, "Authorization: Bearer " .. self.api_key)
|
|
end
|
|
|
|
local request = table.concat(headers, "\r\n") .. "\r\n\r\n" .. request_body
|
|
assert(sock:send(request))
|
|
|
|
-- Read response headers
|
|
local line = sock:receive("*l")
|
|
while line and line ~= "" do
|
|
line = sock:receive("*l")
|
|
end
|
|
|
|
-- Read streaming response
|
|
local buffer = ""
|
|
while true do
|
|
local chunk, err = sock:receive(1024)
|
|
if not chunk then break end
|
|
|
|
buffer = buffer .. chunk
|
|
local lines = {}
|
|
|
|
for line in buffer:gmatch("([^\n]*)\n") do
|
|
table.insert(lines, line)
|
|
end
|
|
|
|
-- Keep incomplete line in buffer
|
|
local last_newline = buffer:find("\n[^\n]*$")
|
|
if last_newline then
|
|
buffer = buffer:sub(last_newline + 1)
|
|
end
|
|
|
|
-- Process complete lines
|
|
for i = 1, #lines - 1 do
|
|
local line = lines[i]:gsub("\r", "")
|
|
if line:match("^data: ") then
|
|
local data = line:sub(7)
|
|
if data == "[DONE]" then
|
|
return
|
|
end
|
|
|
|
local success, chunk_data = pcall(json.decode, data)
|
|
if success and chunk_data.choices and chunk_data.choices[1] then
|
|
local delta = chunk_data.choices[1].delta
|
|
if delta and delta.content then
|
|
callback(delta.content)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end)
|
|
|
|
sock:close()
|
|
|
|
if not success and self.debug then
|
|
print("[DEBUG] Stream error: " .. tostring(err))
|
|
end
|
|
end
|
|
|
|
function UncloseAI:tts(text, opts)
|
|
opts = opts or {}
|
|
|
|
if #self.tts_endpoints == 0 then
|
|
error("No TTS endpoints available")
|
|
end
|
|
|
|
local endpoint = self.tts_endpoints[1]
|
|
local voice = opts.voice or "alloy"
|
|
local model = opts.model or "tts-1"
|
|
local response_format = opts.response_format or "mp3"
|
|
|
|
local payload = {
|
|
model = model,
|
|
voice = voice,
|
|
input = text,
|
|
response_format = response_format
|
|
}
|
|
|
|
local request_body = json.encode(payload)
|
|
local response_body = {}
|
|
|
|
local headers = {
|
|
["Content-Type"] = "application/json",
|
|
["Content-Length"] = tostring(#request_body)
|
|
}
|
|
|
|
if self.api_key then
|
|
headers["Authorization"] = "Bearer " .. self.api_key
|
|
end
|
|
|
|
local res, code = https.request{
|
|
url = endpoint .. "/audio/speech",
|
|
method = "POST",
|
|
headers = headers,
|
|
source = ltn12.source.string(request_body),
|
|
sink = ltn12.sink.table(response_body)
|
|
}
|
|
|
|
if code == 200 then
|
|
return table.concat(response_body)
|
|
else
|
|
error("Request failed with code: " .. tostring(code))
|
|
end
|
|
end
|
|
|
|
-- Demo when run as script
|
|
if not pcall(debug.getlocal, 4, 1) then
|
|
print("=== UncloseAI Lua Client (with Streaming) ===\n")
|
|
|
|
local client = UncloseAI.new({debug = true})
|
|
|
|
local models = client:list_models()
|
|
if #models == 0 then
|
|
print("ERROR: No models discovered. Set environment variables:")
|
|
print(" MODEL_ENDPOINT_1, MODEL_ENDPOINT_2, etc.")
|
|
os.exit(1)
|
|
end
|
|
|
|
print(string.format("\nDiscovered %d model(s):", #models))
|
|
for _, model in ipairs(models) do
|
|
print(string.format(" - %s (max_tokens: %d)", model.id, model.max_tokens))
|
|
end
|
|
print()
|
|
|
|
-- Non-streaming chat
|
|
print("=== Non-Streaming Chat ===")
|
|
local success, response = pcall(function()
|
|
return client:chat({
|
|
{role = "system", content = "You are a helpful AI assistant."},
|
|
{role = "user", content = "Explain quantum computing in one sentence."}
|
|
}, {max_tokens = 100})
|
|
end)
|
|
|
|
if success then
|
|
local content = response.choices[1].message.content
|
|
print("Response: " .. content .. "\n")
|
|
else
|
|
print("Error: " .. tostring(response) .. "\n")
|
|
end
|
|
|
|
-- Streaming chat
|
|
print("=== Streaming Chat ===")
|
|
local model_id = #models > 1 and models[2].id or nil
|
|
print("Model: " .. (model_id or models[1].id))
|
|
io.write("Response: ")
|
|
io.flush()
|
|
|
|
local success, err = pcall(function()
|
|
client:chat_stream({
|
|
{role = "system", content = "You are a coding assistant."},
|
|
{role = "user", content = "Write a Lua function to check if a number is prime"}
|
|
}, {model = model_id, max_tokens = 200}, function(content)
|
|
io.write(content)
|
|
io.flush()
|
|
end)
|
|
end)
|
|
|
|
if not success then
|
|
print("\nError: " .. tostring(err))
|
|
end
|
|
print("\n")
|
|
|
|
-- TTS
|
|
if #client.tts_endpoints > 0 then
|
|
print("=== TTS Speech Generation ===")
|
|
local success, audio_data = pcall(function()
|
|
return client:tts("Hello from UncloseAI Lua client! This demonstrates streaming support.")
|
|
end)
|
|
|
|
if success then
|
|
local file = io.open("speech.mp3", "wb")
|
|
file:write(audio_data)
|
|
file:close()
|
|
print(string.format("[OK] Speech file created: speech.mp3 (%d bytes)\n", #audio_data))
|
|
else
|
|
print("[ERROR] TTS Error: " .. tostring(audio_data) .. "\n")
|
|
end
|
|
end
|
|
|
|
print("=== Examples Complete ===")
|
|
end
|
|
|
|
return UncloseAI
|