Files
go-lcg/gpt/providers.go

656 lines
20 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}