mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/1Panel-dev/1Panel.git
synced 2026-09-21 00:24:12 +08:00
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" {
|
|
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 ""
|
|
}
|