mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-09-23 00:00:52 +00:00
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.
794 lines
23 KiB
Go
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 ""
|
|
}
|