diff --git a/broker.lua b/broker.lua index f91b0ca..a79a60b 100644 --- a/broker.lua +++ b/broker.lua @@ -1,15 +1,69 @@ -- broker.lua — llama.cpp HTTP client. --- Phase 0: blocking POST via libcurl FFI; SSE streaming wired in Phase 1. +-- Phase 0: blocking POST via ffi/curl + vendored dkjson; the response is read +-- to completion and returned as a Lua string. SSE streaming wired in Phase 1. -- See docs/PHASE0.md §6. +local curl = require("ffi.curl") +local json = require("dkjson") + local M = {} +local function build_headers(model_cfg) + local h = { "Content-Type: application/json" } + if model_cfg.key_env then + local key = os.getenv(model_cfg.key_env) + if key and key ~= "" then + h[#h + 1] = "Authorization: Bearer " .. key + end + end + return h +end + -- Send a /v1/chat/completions request. --- model_cfg: entry from config.models (endpoint, model, temperature, [key_env]) --- messages: list of { role = ..., content = ... } including system prompt --- Returns: assistant content string on success, (nil, errmsg) on failure. +-- model_cfg : entry from config.models — { endpoint, model, temperature, +-- [key_env], [timeout_ms] } +-- messages : ordered list of { role = ..., content = ... }, system +-- prompt already prepended (context:to_messages handles that). +-- Returns: +-- assistant_content_string on success +-- nil, errmsg on transport / decode / API failure function M.chat(model_cfg, messages) - error("broker.chat: not implemented (Phase 0 pending)") + if not (model_cfg and model_cfg.endpoint and model_cfg.model) then + return nil, "broker.chat: model_cfg.endpoint and .model are required" + end + + local url = model_cfg.endpoint:gsub("/+$", "") .. "/v1/chat/completions" + local body = json.encode({ + model = model_cfg.model, + messages = messages, + stream = false, + temperature = model_cfg.temperature or 0.2, + }) + local headers = build_headers(model_cfg) + local timeout_ms = model_cfg.timeout_ms or 60000 + + local resp, err = curl.post(url, body, headers, timeout_ms) + if not resp then return nil, "transport: " .. tostring(err) end + + local doc, _, derr = json.decode(resp) + if not doc then + return nil, "decode: " .. tostring(derr) .. " (raw: " .. resp:sub(1, 200) .. ")" + end + + -- OpenAI-style error envelope: { error: { message, type, ... } } + if doc.error then + local m = (type(doc.error) == "table" and doc.error.message) or tostring(doc.error) + return nil, "api: " .. m + end + + local choice = doc.choices and doc.choices[1] + local msg = choice and choice.message + local content = msg and msg.content + if type(content) ~= "string" then + return nil, "broker.chat: no choices[1].message.content in response" + end + + return content end return M