Files
AetherForge/server/internal/ai/client.go
AetherForge 0002e5fd93
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Add Calibrate AI Control UI and fleet LLM backend wiring.
Operators toggle Logic gates vs AI Control on Settings, refresh local Ollama models, and save ai_endpoint settings via Calibrate PUT; server scheduler and agent snapshot/command paths support stateless 60s fleet decisions.
2026-06-07 02:14:28 -07:00

144 lines
3.8 KiB
Go

package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
var defaultHTTPClient = &http.Client{Timeout: 45 * time.Second}
// ListModels GET {endpoint}/models — OpenAI-compatible model list.
func ListModels(ctx context.Context, endpoint string) ([]string, error) {
return ListModelsWithClient(ctx, endpoint, defaultHTTPClient)
}
func ListModelsWithClient(ctx context.Context, endpoint string, client *http.Client) ([]string, error) {
base := normalizeEndpoint(endpoint)
if base == "" {
return nil, fmt.Errorf("endpoint is required")
}
if client == nil {
client = defaultHTTPClient
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/models", nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("models: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var out struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
Models []struct {
Name string `json:"name"`
} `json:"models"`
}
if err := json.Unmarshal(body, &out); err != nil {
return nil, fmt.Errorf("models: parse: %w", err)
}
names := make([]string, 0)
seen := map[string]bool{}
for _, m := range out.Data {
id := strings.TrimSpace(m.ID)
if id != "" && !seen[id] {
seen[id] = true
names = append(names, id)
}
}
for _, m := range out.Models {
name := strings.TrimSpace(m.Name)
if name != "" && !seen[name] {
seen[name] = true
names = append(names, name)
}
}
return names, nil
}
// Decide POST chat/completions — single turn, no conversation history.
func Decide(ctx context.Context, endpoint, model, systemPrompt, userPrompt string) (string, error) {
return DecideWithClient(ctx, endpoint, model, systemPrompt, userPrompt, defaultHTTPClient)
}
func DecideWithClient(ctx context.Context, endpoint, model, systemPrompt, userPrompt string, client *http.Client) (string, error) {
base := normalizeEndpoint(endpoint)
if base == "" {
return "", fmt.Errorf("endpoint is required")
}
if client == nil {
client = defaultHTTPClient
}
if strings.TrimSpace(model) == "" {
model = "llama3.2"
}
payload := map[string]interface{}{
"model": model,
"messages": []map[string]string{
{"role": "system", "content": systemPrompt},
{"role": "user", "content": userPrompt},
},
"stream": false,
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/chat/completions", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("completions: status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var completion struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(raw, &completion); err != nil {
return "", fmt.Errorf("completions: parse: %w", err)
}
if completion.Error != nil && completion.Error.Message != "" {
return "", fmt.Errorf("completions: %s", completion.Error.Message)
}
if len(completion.Choices) == 0 {
return "", fmt.Errorf("completions: empty choices")
}
return strings.TrimSpace(completion.Choices[0].Message.Content), nil
}
func normalizeEndpoint(endpoint string) string {
endpoint = strings.TrimSpace(endpoint)
endpoint = strings.TrimRight(endpoint, "/")
if endpoint == "" {
return ""
}
if !strings.HasSuffix(endpoint, "/v1") {
endpoint += "/v1"
}
return endpoint
}