mirror of
https://github.com/Warky-Devs/vecna.git
synced 2026-05-05 01:26:58 +00:00
207 lines
4.9 KiB
Go
207 lines
4.9 KiB
Go
package discovery
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Kind describes a known server type.
|
|
type Kind struct {
|
|
Name string
|
|
APIType string
|
|
Port int
|
|
NeedsKey bool
|
|
}
|
|
|
|
// Found is a server discovered on the network.
|
|
type Found struct {
|
|
Kind Kind
|
|
BaseURL string
|
|
Models []string
|
|
}
|
|
|
|
// knownServers lists server types by their default port and display name.
|
|
// Ollama is listed separately because it uses a non-OpenAI probe endpoint.
|
|
var knownServers = []Kind{
|
|
{Name: "Ollama", APIType: "openai", Port: 11434},
|
|
{Name: "LM Studio", APIType: "openai", Port: 1234},
|
|
{Name: "vLLM", APIType: "openai", Port: 8000, NeedsKey: true},
|
|
{Name: "LocalAI", APIType: "openai", Port: 8080},
|
|
{Name: "Jan", APIType: "openai", Port: 1337},
|
|
{Name: "Kobold", APIType: "openai", Port: 5001},
|
|
{Name: "Tabby", APIType: "openai", Port: 9090},
|
|
}
|
|
|
|
// Scan concurrently probes localhost and LAN gateway addresses for known LLM servers.
|
|
// Results are returned in the order they are found (non-deterministic).
|
|
func Scan(ctx context.Context) []Found {
|
|
hosts := localHosts()
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
results []Found
|
|
wg sync.WaitGroup
|
|
)
|
|
|
|
probeCtx, cancel := context.WithTimeout(ctx, 4*time.Second)
|
|
defer cancel()
|
|
|
|
httpClient := &http.Client{Timeout: 600 * time.Millisecond}
|
|
|
|
for _, host := range hosts {
|
|
for _, kind := range knownServers {
|
|
host := host
|
|
wg.Add(1)
|
|
go func(kind Kind) {
|
|
defer wg.Done()
|
|
baseURL := fmt.Sprintf("http://%s:%d", host, kind.Port)
|
|
models, err := probe(probeCtx, httpClient, baseURL, kind)
|
|
if err != nil {
|
|
return
|
|
}
|
|
mu.Lock()
|
|
results = append(results, Found{Kind: kind, BaseURL: baseURL, Models: models})
|
|
mu.Unlock()
|
|
}(kind)
|
|
}
|
|
}
|
|
|
|
wg.Wait()
|
|
return results
|
|
}
|
|
|
|
// Models fetches the model list from a single base URL and kind (for the models command).
|
|
func Models(ctx context.Context, baseURL string, kind Kind) ([]string, error) {
|
|
httpClient := &http.Client{Timeout: 5 * time.Second}
|
|
return probe(ctx, httpClient, baseURL, kind)
|
|
}
|
|
|
|
// localHosts returns localhost plus the .1 gateway of every local IPv4 subnet.
|
|
func localHosts() []string {
|
|
seen := map[string]bool{"127.0.0.1": true}
|
|
hosts := []string{"127.0.0.1"}
|
|
|
|
ifaces, err := net.Interfaces()
|
|
if err != nil {
|
|
return hosts
|
|
}
|
|
|
|
for _, iface := range ifaces {
|
|
if iface.Flags&net.FlagUp == 0 {
|
|
continue
|
|
}
|
|
addrs, err := iface.Addrs()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, addr := range addrs {
|
|
ipnet, ok := addr.(*net.IPNet)
|
|
if !ok {
|
|
continue
|
|
}
|
|
ip := ipnet.IP.To4()
|
|
if ip == nil || ip.IsLoopback() {
|
|
continue
|
|
}
|
|
// derive the likely gateway: network address + 1
|
|
gw := ip.Mask(ipnet.Mask)
|
|
gw[3] = 1
|
|
h := gw.String()
|
|
if !seen[h] {
|
|
seen[h] = true
|
|
hosts = append(hosts, h)
|
|
}
|
|
}
|
|
}
|
|
return hosts
|
|
}
|
|
|
|
// probe attempts to identify the server at baseURL and returns its model list.
|
|
func probe(ctx context.Context, client *http.Client, baseURL string, kind Kind) ([]string, error) {
|
|
// Ollama has its own endpoint; everything else is OpenAI-compatible
|
|
if kind.Name == "Ollama" {
|
|
models, err := probeOllama(ctx, client, baseURL)
|
|
if err != nil {
|
|
// Ollama also exposes /v1/models since v0.1.27 — fall back
|
|
return probeOpenAI(ctx, client, baseURL)
|
|
}
|
|
return models, nil
|
|
}
|
|
return probeOpenAI(ctx, client, baseURL)
|
|
}
|
|
|
|
// --- Ollama ---
|
|
|
|
type ollamaTagsResponse struct {
|
|
Models []struct {
|
|
Name string `json:"name"`
|
|
} `json:"models"`
|
|
}
|
|
|
|
func probeOllama(ctx context.Context, client *http.Client, baseURL string) ([]string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api/tags", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("status %d", resp.StatusCode)
|
|
}
|
|
|
|
var body ollamaTagsResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
|
return nil, fmt.Errorf("decode ollama response: %w", err)
|
|
}
|
|
|
|
models := make([]string, len(body.Models))
|
|
for i, m := range body.Models {
|
|
models[i] = m.Name
|
|
}
|
|
return models, nil
|
|
}
|
|
|
|
// --- OpenAI-compatible ---
|
|
|
|
type openAIModelsResponse struct {
|
|
Data []struct {
|
|
ID string `json:"id"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
func probeOpenAI(ctx context.Context, client *http.Client, baseURL string) ([]string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/v1/models", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("status %d", resp.StatusCode)
|
|
}
|
|
|
|
var body openAIModelsResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
|
return nil, fmt.Errorf("decode openai response: %w", err)
|
|
}
|
|
|
|
models := make([]string, len(body.Data))
|
|
for i, m := range body.Data {
|
|
models[i] = m.ID
|
|
}
|
|
return models, nil
|
|
}
|