From f9f8b0370c25a117b4209e5995733bb4cfb74b8c Mon Sep 17 00:00:00 2001 From: Markus Fritsche Date: Sun, 10 May 2026 14:10:00 +0000 Subject: [PATCH] broker: blocking POST /v1/chat/completions via ffi/curl + dkjson MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 implementation per PHASE0.md §6. M.chat(model_cfg, messages) -> content_string | (nil, errmsg) Builds the OpenAI-compat JSON body: { model, messages, stream: false, temperature: model_cfg.temperature ?? 0.2 } Sends Content-Type and (optionally) Authorization Bearer pulled from model_cfg.key_env's process environment. Default timeout 60s; overridable per-model via model_cfg.timeout_ms. Error surfaces split: "transport: ..." curl-side (TCP/TLS/timeout) "decode: ..." non-JSON response body "api: ..." OpenAI-style { error: { message } } envelope "broker.chat: no choices[1].message.content..." shape miss Tested against four canned mock responses (nc -lN listener feeding HTTP/1.0 + Connection: close so EOF terminates the body): happy path, api error envelope, raw-text non-JSON, empty choices[]. The on-wire request body verified as well: POST path, headers, model/messages/ temperature/stream JSON. Live test against a real llama.cpp/hossenfelder endpoint deferred per issue #12 (broker endpoint configuration). Co-Authored-By: Claude Opus 4.7 (1M context) --- broker.lua | 64 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 5 deletions(-) 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