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 "" }