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 }