208 lines
6.5 KiB
Go
208 lines
6.5 KiB
Go
package main
|
|
|
|
// llmlocal.go — finding the OpenAI-compatible model server on this machine
|
|
// when PRIVATAE_LLM_ADDR names none.
|
|
//
|
|
// Before 0.63.0 that lane existed only when someone set the variable in the
|
|
// bastion's environment, which no installer does, so a machine running
|
|
// llama.cpp on its usual port served no model to its Host. Now, with the
|
|
// variable unset, the bastion looks on this machine's loopback, and nowhere
|
|
// else, at the ports the common servers
|
|
// listen on by default: 127.0.0.1:8080 (llama.cpp's llama-server), :8000
|
|
// (vLLM), :1234 (LM Studio), in that order. Each is asked GET /v1/models with a
|
|
// short timeout, and the first whose answer is OpenAI-shaped (a "data" list of
|
|
// models, each with an "id") is the server. Something else answering on one of
|
|
// those ports (a web app on 8080, say) is not taken for a model server, nor,
|
|
// on Linux, a server another account runs (llmListenerTrusted).
|
|
//
|
|
// The answer is kept and asked again every llmReprobeEvery, and at once after
|
|
// it failed (llmTargetFailed: the beat's probe of it, a dial of it), so the
|
|
// heartbeat that advertises the lane and the forward that serves it read the
|
|
// same address: both call llmTarget (env.go), the one function for the lane.
|
|
// The variable, when set, overrides all of this, as before. What was found, or
|
|
// that nothing was, is logged once per change and shown by `status` and
|
|
// `doctor`.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// llmCandidate is one place a local OpenAI-compatible server listens by
|
|
// default. Loopback only, by construction: the addresses are fixed here.
|
|
type llmCandidate struct {
|
|
Addr string
|
|
Server string // whose default port it is, for the words
|
|
}
|
|
|
|
var llmCandidates = []llmCandidate{
|
|
{"127.0.0.1:8080", "llama.cpp"},
|
|
{"127.0.0.1:8000", "vLLM"},
|
|
{"127.0.0.1:1234", "LM Studio"},
|
|
}
|
|
|
|
const (
|
|
llmReprobeEvery = 60 * time.Second
|
|
llmProbeTimeout = 800 * time.Millisecond
|
|
)
|
|
|
|
// llmProbe asks addr for its models; ok only for an OpenAI-shaped answer. A
|
|
// seam for the suite.
|
|
var llmProbe = probeOpenAIShaped
|
|
|
|
// llmTrusted: the program on that port may be handed the Host's prompts
|
|
// (llmlocal_linux.go: root's, this bastion's user's or the owner's). A seam.
|
|
var llmTrusted = llmListenerTrusted
|
|
|
|
// probeOpenAIShaped is GET http://addr/v1/models: a 200 whose body is
|
|
// {"data": [{"id": "..."}, ...]} with at least one model and an id on each.
|
|
func probeOpenAIShaped(addr string) ([]string, bool) {
|
|
cl := &http.Client{Timeout: llmProbeTimeout}
|
|
resp, err := cl.Get("http://" + addr + "/v1/models")
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, false
|
|
}
|
|
return openAIShapedModels(io.LimitReader(resp.Body, 1<<20))
|
|
}
|
|
|
|
// openAIShapedModels reads an OpenAI /v1/models answer: the model ids, and
|
|
// whether it is one.
|
|
func openAIShapedModels(r io.Reader) ([]string, bool) {
|
|
var list struct {
|
|
Data []map[string]any `json:"data"`
|
|
}
|
|
if err := json.NewDecoder(r).Decode(&list); err != nil || len(list.Data) == 0 {
|
|
return nil, false
|
|
}
|
|
var ids []string
|
|
for _, m := range list.Data {
|
|
id, _ := m["id"].(string)
|
|
if strings.TrimSpace(id) == "" {
|
|
return nil, false
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
return ids, true
|
|
}
|
|
|
|
// llmFound is what the bastion found on this machine.
|
|
type llmFound struct {
|
|
Addr string `json:"addr,omitempty"`
|
|
Server string `json:"server,omitempty"` // whose default port it answered on
|
|
Models []string `json:"models,omitempty"`
|
|
}
|
|
|
|
var llmLocal = struct {
|
|
sync.Mutex
|
|
found llmFound
|
|
at time.Time
|
|
probed bool
|
|
said string // what was last logged
|
|
}{}
|
|
|
|
// discoveredLLM is the server found on this machine, asked again when the
|
|
// last answer is older than llmReprobeEvery or was marked failed.
|
|
func discoveredLLM() llmFound {
|
|
llmLocal.Lock()
|
|
defer llmLocal.Unlock()
|
|
if llmLocal.probed && time.Since(llmLocal.at) < llmReprobeEvery {
|
|
return llmLocal.found
|
|
}
|
|
f := llmFound{}
|
|
for _, c := range llmCandidates {
|
|
if models, ok := llmProbe(c.Addr); ok && llmTrusted(c.Addr) {
|
|
f = llmFound{Addr: c.Addr, Server: c.Server, Models: models}
|
|
break
|
|
}
|
|
}
|
|
llmLocal.found, llmLocal.at, llmLocal.probed = f, time.Now(), true
|
|
// Said in the running bastion's log, once per change; a command such as
|
|
// `status` shows it in its own words instead.
|
|
if line := llmFoundWords(f); line != llmLocal.said && daemonRunningHere() {
|
|
llmLocal.said = line
|
|
fmt.Printf("[llm] %s\n", line)
|
|
}
|
|
return f
|
|
}
|
|
|
|
// daemonRunningHere: this process is the running bastion (cmdRun set up its
|
|
// supervisor), not a command beside it.
|
|
func daemonRunningHere() bool {
|
|
supervisor.Lock()
|
|
defer supervisor.Unlock()
|
|
return supervisor.ctx != nil
|
|
}
|
|
|
|
// llmTargetFailed marks the found server as failed when addr is it, so the
|
|
// next llmTarget asks again rather than serve a server that went away.
|
|
func llmTargetFailed(addr string) {
|
|
if addr == "" || os.Getenv("PRIVATAE_LLM_ADDR") != "" {
|
|
return
|
|
}
|
|
llmLocal.Lock()
|
|
defer llmLocal.Unlock()
|
|
if llmLocal.found.Addr == addr {
|
|
llmLocal.probed = false
|
|
}
|
|
}
|
|
|
|
// llmCandidateList is the places looked, for the words.
|
|
func llmCandidateList() string {
|
|
var out []string
|
|
for _, c := range llmCandidates {
|
|
out = append(out, c.Addr+" ("+c.Server+")")
|
|
}
|
|
return strings.Join(out, ", ")
|
|
}
|
|
|
|
func llmFoundWords(f llmFound) string {
|
|
if f.Addr == "" {
|
|
return "no OpenAI-compatible model server found on this machine (looked on " + llmCandidateList() +
|
|
"); set PRIVATAE_LLM_ADDR to name another on this machine"
|
|
}
|
|
s := fmt.Sprintf("found an OpenAI-compatible model server on %s, %s's port", f.Addr, f.Server)
|
|
if len(f.Models) > 0 {
|
|
s += ": " + strings.Join(f.Models, ", ")
|
|
}
|
|
return s
|
|
}
|
|
|
|
// statusLLM is the model-server line `status` and `doctor` show.
|
|
type statusLLM struct {
|
|
Addr string `json:"addr,omitempty"`
|
|
Source string `json:"source"` // "PRIVATAE_LLM_ADDR", "found" or "none"
|
|
Server string `json:"server,omitempty"`
|
|
Models []string `json:"models,omitempty"`
|
|
}
|
|
|
|
func llmStatus() *statusLLM {
|
|
if v := os.Getenv("PRIVATAE_LLM_ADDR"); v != "" {
|
|
return &statusLLM{Addr: v, Source: "PRIVATAE_LLM_ADDR"}
|
|
}
|
|
f := discoveredLLM()
|
|
if f.Addr == "" {
|
|
return &statusLLM{Source: "none"}
|
|
}
|
|
return &statusLLM{Addr: f.Addr, Source: "found", Server: f.Server, Models: f.Models}
|
|
}
|
|
|
|
func (l *statusLLM) words() string {
|
|
switch l.Source {
|
|
case "PRIVATAE_LLM_ADDR":
|
|
return l.Addr + " (set by PRIVATAE_LLM_ADDR)"
|
|
case "found":
|
|
return llmFoundWords(llmFound{Addr: l.Addr, Server: l.Server, Models: l.Models})
|
|
}
|
|
return llmFoundWords(llmFound{})
|
|
}
|