broker: blocking POST /v1/chat/completions via ffi/curl + dkjson

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 14:10:00 +00:00
parent 91187d2302
commit f9f8b0370c
+59 -5
View File
@@ -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