Files
Eric Curtin 9858881ce6 feat(ai): add llmman as a local model provider (#13717)
llmman (https://github.com/llmmanorg/llmman) is a local model runner
serving Ollama- and OpenAI-compatible routes on 127.0.0.1:17434.
Register it in the agent provider catalog next to Ollama and extend
every Ollama special case (no API key, verification skipped, OpenClaw
placeholder key, manual initial model) to cover it as well.
2026-09-07 11:41:13 +08:00

794 lines
23 KiB
Go

package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"unicode/utf8"
providercatalog "github.com/1Panel-dev/1Panel/agent/app/provider"
"github.com/1Panel-dev/1Panel/agent/global"
)
const (
defaultTimeout = 30 * time.Second
defaultUserAgent = "1panel-terminal-ai/1.0"
defaultAnthropicToken = 1024
defaultTerminalTokens = 8192
)
type Client interface {
ChatCompletion(ctx context.Context, req ChatCompletionRequest) (*ChatCompletionResponse, error)
}
type ClientConfig struct {
Provider string
BaseURL string
APIKey string
Model string
APIType string
AuthMode string
MaxTokens int
Timeout time.Duration
HTTPClient *http.Client
}
type GeneratorConfig struct {
Provider string
BaseURL string
APIKey string
Model string
APIType string
AuthMode string
MaxTokens int
}
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type ChatCompletionRequest struct {
Messages []ChatMessage `json:"messages"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
}
type ThinkingMode struct {
Type string `json:"type"`
}
type ChatCompletionResponse struct {
ID string `json:"id"`
Model string `json:"model"`
Content string `json:"content"`
RawText string `json:"rawText"`
Usage ResponseUsage `json:"usage"`
}
type ResponseUsage struct {
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
}
type terminalAIClient struct {
config ClientConfig
httpClient *http.Client
}
func NewClient(cfg ClientConfig) (Client, error) {
providerKey := strings.ToLower(strings.TrimSpace(cfg.Provider))
if providerKey == "" {
providerKey = "custom"
}
if strings.TrimSpace(cfg.Model) == "" {
return nil, fmt.Errorf("model is required")
}
if strings.TrimSpace(cfg.APIKey) == "" && providerKey != "ollama" && providerKey != "llmman" {
return nil, fmt.Errorf("api key is required")
}
baseURL := normalizeBaseURL(providerKey, cfg.BaseURL)
if baseURL == "" {
return nil, fmt.Errorf("base url is required")
}
cfg.Provider = providerKey
cfg.BaseURL = baseURL
cfg.APIType = normalizeClientAPIType(providerKey, cfg.APIType)
authMode, err := providercatalog.ResolveAuthMode(providerKey, cfg.APIType, cfg.AuthMode)
if err != nil {
return nil, err
}
cfg.AuthMode = authMode
if cfg.Timeout <= 0 {
cfg.Timeout = defaultTimeout
}
client := cfg.HTTPClient
if client == nil {
client = &http.Client{Timeout: cfg.Timeout}
}
return &terminalAIClient{
config: cfg,
httpClient: client,
}, nil
}
func (c *terminalAIClient) ChatCompletion(ctx context.Context, req ChatCompletionRequest) (*ChatCompletionResponse, error) {
if len(req.Messages) == 0 {
return nil, fmt.Errorf("messages are required")
}
switch c.config.APIType {
case "anthropic-messages":
return c.chatCompletionAnthropic(ctx, req)
case "gemini-generate-content":
return c.chatCompletionGemini(ctx, req)
case "openai-responses":
return c.chatCompletionOpenAIResponses(ctx, req)
default:
return c.chatCompletionOpenAI(ctx, req)
}
}
func (c *terminalAIClient) chatCompletionOpenAI(ctx context.Context, req ChatCompletionRequest) (*ChatCompletionResponse, error) {
payload := openAIChatCompletionRequest{
Model: strings.TrimSpace(c.config.Model),
Messages: req.Messages,
MaxTokens: firstPositive(req.MaxTokens, c.config.MaxTokens),
Temperature: req.Temperature,
TopP: req.TopP,
Thinking: thinkingModeForModel(c.config.Model),
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, buildChatCompletionsURL(c.config.BaseURL), bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(c.config.APIKey))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "application/json")
httpReq.Header.Set("User-Agent", defaultUserAgent)
respBody, err := c.do(httpReq)
if err != nil {
return nil, err
}
var completionResp openAIChatCompletionResponse
if err := json.Unmarshal(respBody, &completionResp); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
content := extractAssistantContent(completionResp)
return &ChatCompletionResponse{
ID: completionResp.ID,
Model: firstNonEmptyString(c.config.Model, completionResp.Model),
Content: content,
RawText: strings.TrimSpace(string(respBody)),
Usage: ResponseUsage{
PromptTokens: completionResp.Usage.PromptTokens,
CompletionTokens: completionResp.Usage.CompletionTokens,
TotalTokens: completionResp.Usage.TotalTokens,
},
}, nil
}
func (c *terminalAIClient) chatCompletionOpenAIResponses(ctx context.Context, req ChatCompletionRequest) (*ChatCompletionResponse, error) {
payload := openAIResponsesRequest{
Model: strings.TrimSpace(c.config.Model),
Input: toResponsesInput(req.Messages),
MaxOutputTokens: firstPositive(req.MaxTokens, c.config.MaxTokens),
Temperature: req.Temperature,
TopP: req.TopP,
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, buildResponsesURL(c.config.BaseURL), bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
if strings.TrimSpace(c.config.APIKey) != "" {
httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(c.config.APIKey))
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "application/json")
httpReq.Header.Set("User-Agent", defaultUserAgent)
respBody, err := c.do(httpReq)
if err != nil {
return nil, err
}
var completionResp openAIResponsesResponse
if err := json.Unmarshal(respBody, &completionResp); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
content := extractResponsesContent(completionResp)
return &ChatCompletionResponse{
ID: completionResp.ID,
Model: firstNonEmptyString(c.config.Model, completionResp.Model),
Content: content,
RawText: strings.TrimSpace(string(respBody)),
Usage: ResponseUsage{
PromptTokens: completionResp.Usage.InputTokens,
CompletionTokens: completionResp.Usage.OutputTokens,
TotalTokens: completionResp.Usage.TotalTokens,
},
}, nil
}
func (c *terminalAIClient) chatCompletionAnthropic(ctx context.Context, req ChatCompletionRequest) (*ChatCompletionResponse, error) {
system, messages := toAnthropicMessages(req.Messages)
payload := anthropicMessagesRequest{
Model: strings.TrimSpace(c.config.Model),
System: system,
Messages: messages,
MaxTokens: firstPositive(req.MaxTokens, c.config.MaxTokens, defaultAnthropicToken),
Temperature: req.Temperature,
TopP: req.TopP,
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, buildAnthropicMessagesURL(c.config.BaseURL), bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
if c.config.AuthMode == providercatalog.AuthModeBearer {
httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(c.config.APIKey))
} else {
httpReq.Header.Set("x-api-key", strings.TrimSpace(c.config.APIKey))
}
httpReq.Header.Set("anthropic-version", "2023-06-01")
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "application/json")
httpReq.Header.Set("User-Agent", defaultUserAgent)
respBody, err := c.do(httpReq)
if err != nil {
return nil, err
}
var completionResp anthropicMessagesResponse
if err := json.Unmarshal(respBody, &completionResp); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
content := extractAnthropicContent(completionResp)
return &ChatCompletionResponse{
ID: completionResp.ID,
Model: firstNonEmptyString(c.config.Model, completionResp.Model),
Content: content,
RawText: strings.TrimSpace(string(respBody)),
Usage: ResponseUsage{
PromptTokens: completionResp.Usage.InputTokens,
CompletionTokens: completionResp.Usage.OutputTokens,
TotalTokens: completionResp.Usage.InputTokens + completionResp.Usage.OutputTokens,
},
}, nil
}
func (c *terminalAIClient) chatCompletionGemini(ctx context.Context, req ChatCompletionRequest) (*ChatCompletionResponse, error) {
payload := geminiGenerateContentRequest{
Contents: toGeminiContents(req.Messages),
GenerationConfig: &geminiGenerationConfig{
MaxOutputTokens: firstPositive(req.MaxTokens, c.config.MaxTokens),
Temperature: req.Temperature,
TopP: req.TopP,
},
}
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, buildGeminiGenerateContentURL(c.config.BaseURL, strings.TrimSpace(c.config.Model)), bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
httpReq.Header.Set("x-goog-api-key", strings.TrimSpace(c.config.APIKey))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "application/json")
httpReq.Header.Set("User-Agent", defaultUserAgent)
respBody, err := c.do(httpReq)
if err != nil {
return nil, err
}
var completionResp geminiGenerateContentResponse
if err := json.Unmarshal(respBody, &completionResp); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
content := extractGeminiContent(completionResp)
return &ChatCompletionResponse{
Model: firstNonEmptyString(c.config.Model, completionResp.ModelVersion),
Content: content,
RawText: strings.TrimSpace(string(respBody)),
Usage: ResponseUsage{
PromptTokens: completionResp.Usage.PromptTokenCount,
CompletionTokens: completionResp.Usage.CandidatesTokenCount,
TotalTokens: completionResp.Usage.TotalTokenCount,
},
}, nil
}
func (c *terminalAIClient) do(httpReq *http.Request) ([]byte, error) {
start := time.Now()
resp, err := c.httpClient.Do(httpReq)
if err != nil {
c.logRequestCost(httpReq, 0, time.Since(start), err)
return nil, fmt.Errorf("do request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
c.logRequestCost(httpReq, resp.StatusCode, time.Since(start), err)
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode >= http.StatusBadRequest {
providerErr := parseProviderError(resp.StatusCode, respBody)
c.logRequestCost(httpReq, resp.StatusCode, time.Since(start), providerErr)
return nil, providerErr
}
c.logRequestCost(httpReq, resp.StatusCode, time.Since(start), nil)
return respBody, nil
}
func (c *terminalAIClient) logRequestCost(httpReq *http.Request, statusCode int, duration time.Duration, err error) {
if global.LOG == nil || httpReq == nil {
return
}
if err != nil {
global.LOG.Warnf("[terminal-ai] request finished, provider=%s model=%s apiType=%s method=%s url=%s status=%d duration=%s err=%v",
c.config.Provider,
c.config.Model,
c.config.APIType,
httpReq.Method,
httpReq.URL.String(),
statusCode,
duration,
err,
)
return
}
global.LOG.Infof("[terminal-ai] request finished, provider=%s model=%s apiType=%s method=%s url=%s status=%d duration=%s",
c.config.Provider,
c.config.Model,
c.config.APIType,
httpReq.Method,
httpReq.URL.String(),
statusCode,
duration,
)
}
type openAIChatCompletionRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Thinking *ThinkingMode `json:"thinking,omitempty"`
}
type openAIChatCompletionResponse struct {
ID string `json:"id"`
Model string `json:"model"`
Choices []struct {
Message ChatMessage `json:"message"`
Text string `json:"text"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
type openAIResponsesRequest struct {
Model string `json:"model"`
Input []openAIResponsesInput `json:"input"`
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
}
type openAIResponsesInput struct {
Role string `json:"role"`
Content []openAIResponsesInputPart `json:"content"`
}
type openAIResponsesInputPart struct {
Type string `json:"type"`
Text string `json:"text"`
}
type openAIResponsesResponse struct {
ID string `json:"id"`
Model string `json:"model"`
Output []struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
} `json:"output"`
OutputText string `json:"output_text"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
type anthropicMessagesRequest struct {
Model string `json:"model"`
System string `json:"system,omitempty"`
Messages []anthropicMessage `json:"messages"`
MaxTokens int `json:"max_tokens"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
}
type anthropicMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type anthropicMessagesResponse struct {
ID string `json:"id"`
Model string `json:"model"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
}
type geminiGenerateContentRequest struct {
Contents []geminiContent `json:"contents"`
GenerationConfig *geminiGenerationConfig `json:"generationConfig,omitempty"`
}
type geminiContent struct {
Role string `json:"role,omitempty"`
Parts []geminiContentPart `json:"parts"`
}
type geminiContentPart struct {
Text string `json:"text"`
}
type geminiGenerationConfig struct {
MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"topP,omitempty"`
}
type geminiGenerateContentResponse struct {
Candidates []struct {
Content geminiContent `json:"content"`
} `json:"candidates"`
ModelVersion string `json:"modelVersion"`
Usage struct {
PromptTokenCount int `json:"promptTokenCount"`
CandidatesTokenCount int `json:"candidatesTokenCount"`
TotalTokenCount int `json:"totalTokenCount"`
} `json:"usageMetadata"`
}
type openAIErrorResponse struct {
Error struct {
Message string `json:"message"`
Type string `json:"type"`
Code string `json:"code"`
} `json:"error"`
}
func normalizeBaseURL(provider, rawBaseURL string) string {
baseURL := strings.TrimSpace(rawBaseURL)
if baseURL == "" {
defaultBaseURL, ok := providercatalog.DefaultBaseURL(provider)
if ok {
baseURL = defaultBaseURL
}
}
return strings.TrimRight(baseURL, "/")
}
func buildChatCompletionsURL(baseURL string) string {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if strings.HasSuffix(baseURL, "/chat/completions") {
return baseURL
}
parsed, err := url.Parse(baseURL)
if err != nil {
return baseURL + "/chat/completions"
}
switch {
case strings.HasSuffix(parsed.Path, "/v1"):
parsed.Path += "/chat/completions"
case strings.HasSuffix(parsed.Path, "/v1beta"):
parsed.Path += "/chat/completions"
default:
parsed.Path = strings.TrimRight(parsed.Path, "/") + "/v1/chat/completions"
}
return strings.TrimRight(parsed.String(), "/")
}
func buildResponsesURL(baseURL string) string {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if strings.HasSuffix(baseURL, "/responses") {
return baseURL
}
parsed, err := url.Parse(baseURL)
if err != nil {
return baseURL + "/responses"
}
if strings.HasSuffix(parsed.Path, "/v1") {
parsed.Path += "/responses"
} else {
parsed.Path = strings.TrimRight(parsed.Path, "/") + "/v1/responses"
}
return strings.TrimRight(parsed.String(), "/")
}
func buildAnthropicMessagesURL(baseURL string) string {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if strings.HasSuffix(baseURL, "/messages") {
return baseURL
}
parsed, err := url.Parse(baseURL)
if err != nil {
return baseURL + "/messages"
}
if strings.HasSuffix(parsed.Path, "/v1") {
parsed.Path += "/messages"
} else {
parsed.Path = strings.TrimRight(parsed.Path, "/") + "/v1/messages"
}
return strings.TrimRight(parsed.String(), "/")
}
func buildGeminiGenerateContentURL(baseURL, model string) string {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
model = strings.TrimSpace(model)
if model == "" {
model = "gemini-3-flash-preview"
}
parsed, err := url.Parse(baseURL)
if err != nil {
return strings.TrimRight(baseURL, "/") + "/v1beta/models/" + model + ":generateContent"
}
if strings.Contains(parsed.Path, "/models/") && strings.HasSuffix(parsed.Path, ":generateContent") {
return parsed.String()
}
if strings.Contains(parsed.Path, "/v1beta") {
parsed.Path = strings.TrimRight(parsed.Path, "/") + "/models/" + model + ":generateContent"
} else {
parsed.Path = strings.TrimRight(parsed.Path, "/") + "/v1beta/models/" + model + ":generateContent"
}
return parsed.String()
}
func thinkingModeForModel(model string) *ThinkingMode {
if strings.EqualFold(strings.TrimSpace(model), "kimi-k2.5") {
return &ThinkingMode{Type: "disabled"}
}
return nil
}
func normalizeClientAPIType(provider, apiType string) string {
if value := strings.ToLower(strings.TrimSpace(apiType)); value != "" {
return value
}
return providercatalog.DefaultAPIType(strings.ToLower(strings.TrimSpace(provider)))
}
func extractAssistantContent(resp openAIChatCompletionResponse) string {
if len(resp.Choices) == 0 {
return ""
}
if content := strings.TrimSpace(resp.Choices[0].Message.Content); content != "" {
return content
}
return strings.TrimSpace(resp.Choices[0].Text)
}
func extractResponsesContent(resp openAIResponsesResponse) string {
if text := strings.TrimSpace(resp.OutputText); text != "" {
return text
}
for _, item := range resp.Output {
for _, content := range item.Content {
if strings.TrimSpace(content.Text) != "" {
return strings.TrimSpace(content.Text)
}
}
}
return ""
}
func extractAnthropicContent(resp anthropicMessagesResponse) string {
var parts []string
for _, item := range resp.Content {
if strings.TrimSpace(item.Text) == "" {
continue
}
parts = append(parts, strings.TrimSpace(item.Text))
}
return strings.TrimSpace(strings.Join(parts, "\n"))
}
func extractGeminiContent(resp geminiGenerateContentResponse) string {
if len(resp.Candidates) == 0 {
return ""
}
var parts []string
for _, part := range resp.Candidates[0].Content.Parts {
if strings.TrimSpace(part.Text) == "" {
continue
}
parts = append(parts, strings.TrimSpace(part.Text))
}
return strings.TrimSpace(strings.Join(parts, "\n"))
}
func toResponsesInput(messages []ChatMessage) []openAIResponsesInput {
result := make([]openAIResponsesInput, 0, len(messages))
for _, message := range messages {
if strings.TrimSpace(message.Content) == "" {
continue
}
result = append(result, openAIResponsesInput{
Role: normalizeRole(message.Role),
Content: []openAIResponsesInputPart{{
Type: "input_text",
Text: message.Content,
}},
})
}
return result
}
func toAnthropicMessages(messages []ChatMessage) (string, []anthropicMessage) {
var systemParts []string
result := make([]anthropicMessage, 0, len(messages))
for _, message := range messages {
content := strings.TrimSpace(message.Content)
if content == "" {
continue
}
role := normalizeRole(message.Role)
if role == "system" {
systemParts = append(systemParts, content)
continue
}
if role != "assistant" {
role = "user"
}
result = append(result, anthropicMessage{
Role: role,
Content: content,
})
}
if len(result) == 0 && len(systemParts) > 0 {
result = append(result, anthropicMessage{
Role: "user",
Content: "Generate one shell command only.",
})
}
return strings.Join(systemParts, "\n\n"), result
}
func toGeminiContents(messages []ChatMessage) []geminiContent {
result := make([]geminiContent, 0, len(messages))
for _, message := range messages {
content := strings.TrimSpace(message.Content)
if content == "" {
continue
}
role := normalizeRole(message.Role)
if role == "assistant" {
role = "model"
} else {
role = "user"
}
result = append(result, geminiContent{
Role: role,
Parts: []geminiContentPart{{
Text: content,
}},
})
}
return result
}
func normalizeRole(role string) string {
switch strings.ToLower(strings.TrimSpace(role)) {
case "assistant", "model":
return "assistant"
case "system":
return "system"
default:
return "user"
}
}
func parseProviderError(statusCode int, body []byte) error {
var errResp openAIErrorResponse
if err := json.Unmarshal(body, &errResp); err == nil && strings.TrimSpace(errResp.Error.Message) != "" {
if strings.TrimSpace(errResp.Error.Code) != "" {
return fmt.Errorf("provider returned %d: %s (%s)", statusCode, errResp.Error.Message, errResp.Error.Code)
}
return fmt.Errorf("provider returned %d: %s", statusCode, errResp.Error.Message)
}
var generic map[string]interface{}
if err := json.Unmarshal(body, &generic); err == nil {
if message := extractErrorMessage(generic); message != "" {
return fmt.Errorf("provider returned %d: %s", statusCode, message)
}
}
message := strings.TrimSpace(string(body))
if message == "" {
message = http.StatusText(statusCode)
}
if !utf8.ValidString(message) {
message = http.StatusText(statusCode)
}
return fmt.Errorf("provider returned %d: %s", statusCode, message)
}
func extractErrorMessage(value map[string]interface{}) string {
if errorValue, ok := value["error"]; ok {
switch typed := errorValue.(type) {
case string:
return strings.TrimSpace(typed)
case map[string]interface{}:
if msg, ok := typed["message"].(string); ok {
return strings.TrimSpace(msg)
}
if msg, ok := typed["status"].(string); ok {
return strings.TrimSpace(msg)
}
}
}
if msg, ok := value["message"].(string); ok {
return strings.TrimSpace(msg)
}
return ""
}
func firstPositive(values ...int) int {
for _, value := range values {
if value > 0 {
return value
}
}
return 0
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}