package gpt import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" "github.com/direct-dev-ru/linux-command-gpt/config" ) // Provider интерфейс для работы с разными LLM провайдерами type Provider interface { Chat(messages []Chat) (string, error) Health() error GetAvailableModels() ([]string, error) } // ProxyAPIProvider реализация для прокси API (gin-restapi) type ProxyAPIProvider struct { BaseURL string JWTToken string Model string HTTPClient *http.Client } // ProxyChatRequest структура запроса к прокси API type ProxyChatRequest struct { Messages []Chat `json:"messages"` Model string `json:"model,omitempty"` Temperature float64 `json:"temperature,omitempty"` TopP float64 `json:"top_p,omitempty"` Stream bool `json:"stream,omitempty"` SystemContent string `json:"system_content,omitempty"` UserContent string `json:"user_content,omitempty"` RandomWords []string `json:"random_words,omitempty"` FallbackString string `json:"fallback_string,omitempty"` } // ProxyChatResponse структура ответа от прокси API type ProxyChatResponse struct { Response string `json:"response"` Usage struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` TotalTokens int `json:"total_tokens"` } `json:"usage,omitempty"` Error string `json:"error,omitempty"` Model string `json:"model,omitempty"` Timeout int `json:"timeout_seconds,omitempty"` } // ProxyHealthResponse структура ответа health check type ProxyHealthResponse struct { Status string `json:"status"` Message string `json:"message"` Model string `json:"default_model,omitempty"` Timeout int `json:"default_timeout_seconds,omitempty"` } // OllamaProvider реализация для Ollama API type OllamaProvider struct { BaseURL string Model string Temperature float64 HTTPClient *http.Client } // OllamaTagsResponse структура ответа для получения списка моделей type OllamaTagsResponse struct { Models []struct { Name string `json:"name"` ModifiedAt string `json:"modified_at"` Size int64 `json:"size"` } `json:"models"` } func NewProxyAPIProvider(baseURL, jwtToken, model string, timeout int) *ProxyAPIProvider { return &ProxyAPIProvider{ BaseURL: strings.TrimSuffix(baseURL, "/"), JWTToken: jwtToken, Model: model, HTTPClient: &http.Client{Timeout: time.Duration(timeout) * time.Second}, } } func NewOllamaProvider(baseURL, model string, temperature float64, timeout int) *OllamaProvider { return &OllamaProvider{ BaseURL: strings.TrimSuffix(baseURL, "/"), Model: model, Temperature: temperature, HTTPClient: &http.Client{Timeout: time.Duration(timeout) * time.Second}, } } // Chat для ProxyAPIProvider func (p *ProxyAPIProvider) Chat(messages []Chat) (string, error) { // Используем основной endpoint /api/v1/protected/sberchat/chat payload := ProxyChatRequest{ Messages: messages, Model: p.Model, Temperature: 0.5, TopP: 0.5, Stream: false, RandomWords: []string{"linux", "command", "gpt"}, FallbackString: "I'm sorry, I can't help with that. Please try again.", } jsonData, err := json.Marshal(payload) if err != nil { return "", fmt.Errorf("ошибка маршалинга запроса: %w", err) } req, err := http.NewRequest("POST", p.BaseURL+config.AppConfig.Server.ProxyUrl, bytes.NewBuffer(jsonData)) if err != nil { return "", fmt.Errorf("ошибка создания запроса: %w", err) } req.Header.Set("Content-Type", "application/json") if p.JWTToken != "" { req.Header.Set("Authorization", "Bearer "+p.JWTToken) } if config.AppConfig.MainFlags.Debug { fmt.Println("Chat URL: ", p.BaseURL+config.AppConfig.Server.ProxyUrl) fmt.Println("ProxyChatRequest: ", req) } config.DebugLogf("[REQUEST] POST %s body=%s", p.BaseURL+config.AppConfig.Server.ProxyUrl, jsonData) resp, err := p.HTTPClient.Do(req) if err != nil { config.DebugLogf("[ERROR] POST %s: %v", p.BaseURL+config.AppConfig.Server.ProxyUrl, err) return "", fmt.Errorf("ошибка выполнения запроса: %w", retryable(err)) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("ошибка чтения ответа: %w", err) } config.DebugLogf("[RESPONSE] status=%d body=%s", resp.StatusCode, body) if resp.StatusCode != http.StatusOK { err := fmt.Errorf("ошибка API: %d - %s", resp.StatusCode, string(body)) if isRetryableStatus(resp.StatusCode) { err = retryable(err) } return "", err } var response ProxyChatResponse if err := json.Unmarshal(body, &response); err != nil { return "", fmt.Errorf("ошибка парсинга ответа: %w", err) } if response.Error != "" { return "", fmt.Errorf("ошибка прокси API: %s", response.Error) } if response.Response == "" { return "", fmt.Errorf("пустой ответ от API") } return strings.TrimSpace(response.Response), nil } // Health для ProxyAPIProvider func (p *ProxyAPIProvider) Health() error { url := p.BaseURL + config.AppConfig.Server.HealthUrl config.DebugLogf("[REQUEST] GET %s", url) req, err := http.NewRequest("GET", url, nil) if err != nil { return fmt.Errorf("ошибка создания health check запроса: %w", err) } if p.JWTToken != "" { req.Header.Set("Authorization", "Bearer "+p.JWTToken) } resp, err := p.HTTPClient.Do(req) if err != nil { config.DebugLogf("[ERROR] GET %s: %v", url, err) return fmt.Errorf("health check: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("ошибка чтения health check ответа: %w", err) } config.DebugLogf("[RESPONSE] status=%d body=%s", resp.StatusCode, body) if resp.StatusCode != http.StatusOK { return fmt.Errorf("health check failed: %d", resp.StatusCode) } var healthResponse ProxyHealthResponse if err := json.Unmarshal(body, &healthResponse); err != nil { return fmt.Errorf("ошибка парсинга health check ответа: %w", err) } if healthResponse.Status != "ok" { return fmt.Errorf("health check status: %s - %s", healthResponse.Status, healthResponse.Message) } return nil } // Chat для OllamaProvider func (o *OllamaProvider) Chat(messages []Chat) (string, error) { think := config.AppConfig.Think var payload interface{} if think { payload = Gpt3Request{ Model: o.Model, Messages: messages, Stream: false, Options: Gpt3Options{o.Temperature}, } } else { payload = Gpt3ThinkRequest{ Model: o.Model, Messages: messages, Stream: false, Think: false, Options: Gpt3Options{o.Temperature}, } } jsonData, err := json.Marshal(payload) if err != nil { return "", fmt.Errorf("ошибка маршалинга запроса: %w", err) } req, err := http.NewRequest("POST", o.BaseURL+"/api/chat", bytes.NewBuffer(jsonData)) if err != nil { return "", fmt.Errorf("ошибка создания запроса: %w", err) } req.Header.Set("Content-Type", "application/json") url := o.BaseURL + "/api/chat" config.DebugLogf("[REQUEST] POST %s body=%s", url, jsonData) resp, err := o.HTTPClient.Do(req) if err != nil { config.DebugLogf("[ERROR] POST %s: %v", url, err) return "", fmt.Errorf("ошибка выполнения запроса: %w", retryable(err)) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("ошибка чтения ответа: %w", err) } config.DebugLogf("[RESPONSE] status=%d body=%s", resp.StatusCode, body) if resp.StatusCode != http.StatusOK { err := fmt.Errorf("ошибка API: %d - %s", resp.StatusCode, string(body)) if isRetryableStatus(resp.StatusCode) { err = retryable(err) } return "", err } var response OllamaResponse if err := json.Unmarshal(body, &response); err != nil { return "", fmt.Errorf("ошибка парсинга ответа: %w", err) } return strings.TrimSpace(response.Message.Content), nil } // Health для OllamaProvider func (o *OllamaProvider) Health() error { url := o.BaseURL + "/api/tags" config.DebugLogf("[REQUEST] GET %s", url) req, err := http.NewRequest("GET", url, nil) if err != nil { return fmt.Errorf("ошибка создания health check запроса: %w", err) } resp, err := o.HTTPClient.Do(req) if err != nil { config.DebugLogf("[ERROR] GET %s: %v", url, err) return fmt.Errorf("ошибка health check: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("ошибка чтения health check ответа: %w", err) } config.DebugLogf("[RESPONSE] status=%d body=%s", resp.StatusCode, body) if resp.StatusCode != http.StatusOK { return fmt.Errorf("health check failed: %d", resp.StatusCode) } return nil } // GetAvailableModels для ProxyAPIProvider возвращает фиксированный список func (p *ProxyAPIProvider) GetAvailableModels() ([]string, error) { return []string{"GigaChat-2", "GigaChat-2-Pro", "GigaChat-2-Max"}, nil } // GetAvailableModels возвращает список доступных моделей для провайдера func (o *OllamaProvider) GetAvailableModels() ([]string, error) { url := o.BaseURL + "/api/tags" config.DebugLogf("[REQUEST] GET %s", url) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("ошибка создания запроса: %w", err) } resp, err := o.HTTPClient.Do(req) if err != nil { config.DebugLogf("[ERROR] GET %s: %v", url, err) return nil, fmt.Errorf("ошибка получения моделей: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("ошибка чтения ответа: %w", err) } config.DebugLogf("[RESPONSE] status=%d body=%s", resp.StatusCode, body) if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("ошибка API: %d - %s", resp.StatusCode, string(body)) } var response OllamaTagsResponse if err := json.Unmarshal(body, &response); err != nil { return nil, fmt.Errorf("ошибка парсинга ответа: %w", err) } var models []string for _, model := range response.Models { models = append(models, model.Name) } return models, nil } // ApimiraProvider реализация для OpenAI-совместимого прокси apimira type ApimiraProvider struct { BaseURL string APIKey string KeyFile string Model string CompletionsPath string Temperature float64 HTTPClient *http.Client modelErr error } // ApimiraChatRequest структура запроса в формате OpenAI Chat Completions type ApimiraChatRequest struct { Model string `json:"model"` Messages []Chat `json:"messages"` Temperature float64 `json:"temperature,omitempty"` Stream bool `json:"stream"` } // ApimiraChatResponse структура ответа OpenAI Chat Completions type ApimiraChatResponse struct { Choices []struct { Message Chat `json:"message"` FinishReason string `json:"finish_reason"` } `json:"choices"` } // ApimiraError структура ошибки в формате OpenAI type ApimiraError struct { Message string `json:"message"` Type string `json:"type"` } // ApimiraErrorResponse обертка для ошибки в формате OpenAI type ApimiraErrorResponse struct { Error ApimiraError `json:"error"` } // ApimiraModelsResponse структура ответа GET /v1/models type ApimiraModelsResponse struct { Data []struct { ID string `json:"id"` } `json:"data"` } func NewApimiraProvider(baseURL, apiKey, model, completionsPath string, temperature float64, timeout int) *ApimiraProvider { keyFile := "" if home, err := os.UserHomeDir(); err == nil { keyFile = filepath.Join(home, config.AppConfig.ApiKeyFile) } model = strings.TrimSpace(model) completionsPath = strings.TrimSpace(completionsPath) if completionsPath == "" { completionsPath = "/v1/chat/completions" } if !strings.HasPrefix(completionsPath, "/") { completionsPath = "/" + completionsPath } return &ApimiraProvider{ BaseURL: strings.TrimSuffix(baseURL, "/"), APIKey: strings.TrimSpace(apiKey), KeyFile: keyFile, Model: model, CompletionsPath: completionsPath, Temperature: temperature, HTTPClient: &http.Client{Timeout: time.Duration(timeout) * time.Second}, modelErr: validateApimiraModel(model), } } // validateApimiraModel проверяет обязательный формат модели поставщик/имя_модели func validateApimiraModel(model string) error { if model == "" { return fmt.Errorf("для провайдера apimira модель обязательна в формате поставщик/имя_модели (например anthropic/claude-opus-4.7): задайте LCG_MODEL") } parts := strings.Split(model, "/") if len(parts) != 2 || parts[0] == "" || parts[1] == "" || strings.ContainsAny(model, " \t\n") { return fmt.Errorf("модель apimira %q указана в неверном формате: ожидается поставщик/имя_модели, например anthropic/claude-opus-4.7", model) } return nil } // resolveKey возвращает ключ из переменной или файла (права файла проверяются и исправляются на 0600) func (p *ApimiraProvider) resolveKey() (string, error) { if p.APIKey != "" { return p.APIKey, nil } if p.KeyFile == "" { return "", nil } data, err := os.ReadFile(p.KeyFile) if err != nil { return "", nil } if info, err := os.Stat(p.KeyFile); err == nil && info.Mode().Perm()&0o077 != 0 { fmt.Fprintf(os.Stderr, "⚠️ Файл с ключом apimira %s имеет права доступа %v, устанавливаю 0600\n", p.KeyFile, info.Mode().Perm()) if err := os.Chmod(p.KeyFile, 0o600); err != nil { fmt.Fprintf(os.Stderr, "⚠️ Не удалось изменить права доступа %s: %v\n", p.KeyFile, err) } } key := strings.TrimSpace(string(data)) p.APIKey = key return key, nil } // requireKey возвращает ключ или понятную ошибку о том, как его задать func (p *ApimiraProvider) requireKey() (string, error) { key, err := p.resolveKey() if err != nil { return "", err } if key == "" { return "", fmt.Errorf("не задан API ключ apimira: установите LCG_JWT_TOKEN или выполните команду update-key (ключ также читается из файла %s, права 0600)", p.KeyFile) } return key, nil } func apimiraHTTPError(status int, body []byte) error { var errResp ApimiraErrorResponse if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error.Message != "" { return fmt.Errorf("ошибка apimira: %d %s", status, errResp.Error.Message) } return fmt.Errorf("ошибка apimira: %d - %s", status, string(body)) } // retryableError маркирует временные ошибки (сеть, 429, 5xx), при которых имеет смысл повторять запрос type retryableError struct { err error } func (e *retryableError) Error() string { return e.err.Error() } func (e *retryableError) Unwrap() error { return e.err } func retryable(err error) error { return &retryableError{err: err} } func isRetryableStatus(status int) bool { return status == http.StatusTooManyRequests || status >= http.StatusInternalServerError } // IsRetryable сообщает, можно ли повторять запрос после этой ошибки func IsRetryable(err error) bool { var re *retryableError return errors.As(err, &re) } // Chat для ApimiraProvider (POST, формат OpenAI Chat Completions, без стриминга) func (p *ApimiraProvider) Chat(messages []Chat) (string, error) { if p.modelErr != nil { return "", p.modelErr } key, err := p.requireKey() if err != nil { return "", err } payload := ApimiraChatRequest{ Model: p.Model, Messages: messages, Temperature: p.Temperature, Stream: false, } jsonData, err := json.Marshal(payload) if err != nil { return "", fmt.Errorf("ошибка маршалинга запроса: %w", err) } url := p.BaseURL + p.CompletionsPath req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) if err != nil { return "", fmt.Errorf("ошибка создания запроса: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+key) if config.AppConfig.MainFlags.Debug { fmt.Println("Chat URL: ", url) } config.DebugLogf("[REQUEST] POST %s body=%s", url, jsonData) resp, err := p.HTTPClient.Do(req) if err != nil { config.DebugLogf("[ERROR] POST %s: %v", url, err) return "", fmt.Errorf("ошибка выполнения запроса: %w", retryable(err)) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("ошибка чтения ответа: %w", err) } config.DebugLogf("[RESPONSE] status=%d body=%s", resp.StatusCode, body) if resp.StatusCode != http.StatusOK { err := apimiraHTTPError(resp.StatusCode, body) if isRetryableStatus(resp.StatusCode) { err = retryable(err) } return "", err } var response ApimiraChatResponse if err := json.Unmarshal(body, &response); err != nil { return "", fmt.Errorf("ошибка парсинга ответа apimira: %w", err) } if len(response.Choices) == 0 { return "", fmt.Errorf("пустой ответ от apimira: нет choices в ответе") } if strings.TrimSpace(response.Choices[0].Message.Content) == "" { return "", fmt.Errorf("пустой ответ от apimira (finish_reason: %s)", response.Choices[0].FinishReason) } return strings.TrimSpace(response.Choices[0].Message.Content), nil } // Health для ApimiraProvider — проверка доступности через GET /v1/models func (p *ApimiraProvider) Health() error { if p.modelErr != nil { return p.modelErr } key, err := p.requireKey() if err != nil { return err } req, err := http.NewRequest("GET", p.BaseURL+"/v1/models", nil) if err != nil { return fmt.Errorf("ошибка создания health check запроса: %w", err) } req.Header.Set("Authorization", "Bearer "+key) config.DebugLogf("[REQUEST] GET %s", p.BaseURL+"/v1/models") resp, err := p.HTTPClient.Do(req) if err != nil { config.DebugLogf("[ERROR] GET %s: %v", p.BaseURL+"/v1/models", err) return fmt.Errorf("health check: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("ошибка чтения health check ответа: %w", err) } config.DebugLogf("[RESPONSE] status=%d body=%s", resp.StatusCode, body) if resp.StatusCode != http.StatusOK { return apimiraHTTPError(resp.StatusCode, body) } return nil } // GetAvailableModels для ApimiraProvider — GET /v1/models в формате OpenAI func (p *ApimiraProvider) GetAvailableModels() ([]string, error) { key, err := p.requireKey() if err != nil { return nil, err } req, err := http.NewRequest("GET", p.BaseURL+"/v1/models", nil) if err != nil { return nil, fmt.Errorf("ошибка создания запроса: %w", err) } req.Header.Set("Authorization", "Bearer "+key) config.DebugLogf("[REQUEST] GET %s", p.BaseURL+"/v1/models") resp, err := p.HTTPClient.Do(req) if err != nil { config.DebugLogf("[ERROR] GET %s: %v", p.BaseURL+"/v1/models", err) return nil, fmt.Errorf("ошибка получения моделей: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("ошибка чтения ответа: %w", err) } config.DebugLogf("[RESPONSE] status=%d body=%s", resp.StatusCode, body) if resp.StatusCode != http.StatusOK { return nil, apimiraHTTPError(resp.StatusCode, body) } var response ApimiraModelsResponse if err := json.Unmarshal(body, &response); err != nil { return nil, fmt.Errorf("ошибка парсинга ответа: %w", err) } var models []string for _, model := range response.Data { models = append(models, model.ID) } return models, nil }