Исправления в ветке main

This commit is contained in:
2026-10-22 12:42:32 +06:00
parent 933e5ccb58
commit 9b05edc263
31 changed files with 2433 additions and 114 deletions

View File

@@ -5,6 +5,9 @@ import (
"os"
"path/filepath"
"strings"
"time"
"github.com/direct-dev-ru/linux-command-gpt/config"
)
// ProxySimpleChatRequest структура для простого запроса
@@ -34,7 +37,8 @@ type Gpt3 struct {
ApiKeyFile string
ApiKey string
Temperature float64
ProviderType string // "ollama", "proxy"
Context string // дополнительные данные к запросу, отправляются отдельным сообщением
ProviderType string // "ollama", "proxy", "apimira"
}
type Chat struct {
@@ -94,7 +98,7 @@ func (gpt3 *Gpt3) deleteApiKey() {
func (gpt3 *Gpt3) updateApiKey(apiKey string) {
filePath := gpt3.HomeDir + string(filepath.Separator) + gpt3.ApiKeyFile
file, err := os.OpenFile(filePath, os.O_WRONLY|os.O_TRUNC, 0644)
file, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
panic(err)
}
@@ -105,6 +109,9 @@ func (gpt3 *Gpt3) updateApiKey(apiKey string) {
if err != nil {
panic(err)
}
if err := os.Chmod(filePath, 0600); err != nil {
panic(err)
}
gpt3.ApiKey = apiKey
}
@@ -113,7 +120,7 @@ func (gpt3 *Gpt3) storeApiKey(apiKey string) {
return
}
filePath := gpt3.HomeDir + string(filepath.Separator) + gpt3.ApiKeyFile
file, err := os.Create(filePath)
file, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
panic(err)
}
@@ -124,6 +131,9 @@ func (gpt3 *Gpt3) storeApiKey(apiKey string) {
if err != nil {
panic(err)
}
if err := os.Chmod(filePath, 0600); err != nil {
panic(err)
}
gpt3.ApiKey = apiKey
}
@@ -144,7 +154,11 @@ func (gpt3 *Gpt3) loadApiKey() bool {
func (gpt3 *Gpt3) UpdateKey() {
var apiKey string
fmt.Print("OpenAI API Key: ")
if gpt3.ProviderType == "apimira" {
fmt.Print("apimira API Key: ")
} else {
fmt.Print("OpenAI API Key: ")
}
fmt.Scanln(&apiKey)
gpt3.updateApiKey(apiKey)
}
@@ -159,8 +173,10 @@ func (gpt3 *Gpt3) DeleteKey() {
}
func (gpt3 *Gpt3) InitKey() {
// Для ollama и proxy провайдеров не нужен API ключ
if gpt3.ProviderType == "ollama" || gpt3.ProviderType == "proxy" {
// Для ollama, proxy и apimira провайдеров ключ не запрашивается интерактивно:
// ollama и proxy не требуют ключа, apimira читает его из LCG_JWT_TOKEN или
// ключевого файла (задать через команду update-key)
if gpt3.ProviderType == "ollama" || gpt3.ProviderType == "proxy" || gpt3.ProviderType == "apimira" {
return
}
@@ -181,16 +197,22 @@ func NewGpt3(providerType, host, apiKey, model, prompt string, temperature float
switch providerType {
case "proxy":
provider = NewProxyAPIProvider(host, apiKey, model, timeout) // apiKey используется как JWT токен
case "apimira":
provider = NewApimiraProvider(host, apiKey, model, config.AppConfig.Completions, temperature, timeout)
case "ollama":
provider = NewOllamaProvider(host, model, temperature, timeout)
default:
provider = NewOllamaProvider(host, model, temperature, timeout)
}
homeDir, _ := os.UserHomeDir()
return &Gpt3{
Provider: provider,
Prompt: prompt,
Model: model,
HomeDir: homeDir,
ApiKeyFile: config.AppConfig.ApiKeyFile,
ApiKey: apiKey,
Temperature: temperature,
ProviderType: providerType,
@@ -201,15 +223,41 @@ func NewGpt3(providerType, host, apiKey, model, prompt string, temperature float
func (gpt3 *Gpt3) Completions(ask string) string {
messages := []Chat{
{"system", gpt3.Prompt},
{"user", ask + ". " + gpt3.Prompt},
}
if gpt3.Context != "" {
messages = append(messages, Chat{"user", gpt3.Context})
}
messages = append(messages, Chat{"user", ask + ". " + gpt3.Prompt})
retries := config.AppConfig.Retries
if retries < 0 {
retries = 0
}
var response string
var err error
for attempt := 0; ; attempt++ {
response, err = gpt3.Provider.Chat(messages)
if err == nil {
break
}
if attempt >= retries || !IsRetryable(err) {
break
}
delay := time.Duration(5<<attempt) * time.Second // 5с, 10с, 20с, 40с, ...
msg := fmt.Sprintf("⚠️ Попытка %d из %d не удалась: %v — повтор через %s...", attempt+1, retries+1, err, delay)
fmt.Printf("\r%-60s\n", msg)
config.DebugLogf("[RETRY] %s", msg)
time.Sleep(delay)
}
response, err := gpt3.Provider.Chat(messages)
if err != nil {
config.DebugLogf("[ERROR] completions: %v", err)
fmt.Printf("Ошибка при выполнении запроса: %v\n", err)
return ""
}
config.DebugLogf("[RESULT] %s", response)
return response
}

View File

@@ -3,9 +3,12 @@ package gpt
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
@@ -129,9 +132,11 @@ func (p *ProxyAPIProvider) Chat(messages []Chat) (string, error) {
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 {
return "", fmt.Errorf("ошибка выполнения запроса: %w", err)
config.DebugLogf("[ERROR] POST %s: %v", p.BaseURL+config.AppConfig.Server.ProxyUrl, err)
return "", fmt.Errorf("ошибка выполнения запроса: %w", retryable(err))
}
defer resp.Body.Close()
@@ -140,8 +145,13 @@ func (p *ProxyAPIProvider) Chat(messages []Chat) (string, error) {
return "", fmt.Errorf("ошибка чтения ответа: %w", err)
}
config.DebugLogf("[RESPONSE] status=%d body=%s", resp.StatusCode, body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("ошибка API: %d - %s", resp.StatusCode, string(body))
err := fmt.Errorf("ошибка API: %d - %s", resp.StatusCode, string(body))
if isRetryableStatus(resp.StatusCode) {
err = retryable(err)
}
return "", err
}
var response ProxyChatResponse
@@ -162,7 +172,9 @@ func (p *ProxyAPIProvider) Chat(messages []Chat) (string, error) {
// Health для ProxyAPIProvider
func (p *ProxyAPIProvider) Health() error {
req, err := http.NewRequest("GET", p.BaseURL+config.AppConfig.Server.HealthUrl, nil)
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)
}
@@ -173,20 +185,21 @@ func (p *ProxyAPIProvider) Health() error {
resp, err := p.HTTPClient.Do(req)
if err != nil {
return fmt.Errorf("ошибка health check: %w", err)
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
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("ошибка чтения health check ответа: %w", err)
}
if err := json.Unmarshal(body, &healthResponse); err != nil {
return fmt.Errorf("ошибка парсинга health check ответа: %w", err)
}
@@ -233,9 +246,12 @@ func (o *OllamaProvider) Chat(messages []Chat) (string, error) {
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 {
return "", fmt.Errorf("ошибка выполнения запроса: %w", err)
config.DebugLogf("[ERROR] POST %s: %v", url, err)
return "", fmt.Errorf("ошибка выполнения запроса: %w", retryable(err))
}
defer resp.Body.Close()
@@ -244,8 +260,13 @@ func (o *OllamaProvider) Chat(messages []Chat) (string, error) {
return "", fmt.Errorf("ошибка чтения ответа: %w", err)
}
config.DebugLogf("[RESPONSE] status=%d body=%s", resp.StatusCode, body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("ошибка API: %d - %s", resp.StatusCode, string(body))
err := fmt.Errorf("ошибка API: %d - %s", resp.StatusCode, string(body))
if isRetryableStatus(resp.StatusCode) {
err = retryable(err)
}
return "", err
}
var response OllamaResponse
@@ -258,17 +279,26 @@ func (o *OllamaProvider) Chat(messages []Chat) (string, error) {
// Health для OllamaProvider
func (o *OllamaProvider) Health() error {
req, err := http.NewRequest("GET", o.BaseURL+"/api/tags", nil)
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)
}
@@ -283,13 +313,16 @@ func (p *ProxyAPIProvider) GetAvailableModels() ([]string, error) {
// GetAvailableModels возвращает список доступных моделей для провайдера
func (o *OllamaProvider) GetAvailableModels() ([]string, error) {
req, err := http.NewRequest("GET", o.BaseURL+"/api/tags", nil)
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()
@@ -299,6 +332,7 @@ func (o *OllamaProvider) GetAvailableModels() ([]string, error) {
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))
}
@@ -315,3 +349,307 @@ func (o *OllamaProvider) GetAvailableModels() ([]string, error) {
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
}