Создана структура документации, описывающая функциональность, установку, использование CLI, архитектуру и интеграции с TorrAPI и OpenRouter. Добавлены примеры конфигурации и метаданных, а также описание структуры выходных данных.
485 lines
14 KiB
Go
485 lines
14 KiB
Go
// Package infrastructure содержит адаптеры для внешних систем.
|
||
package infrastructure
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/fofanov/genaudiobookinfo/internal/domain"
|
||
)
|
||
|
||
// OpenRouterClient реализует интерфейс domain.LLMClient для работы с OpenRouter API.
|
||
type OpenRouterClient struct {
|
||
apiKey string
|
||
baseURL string
|
||
httpClient *http.Client
|
||
defaultModel string
|
||
systemPrompt string
|
||
maxRetries int
|
||
backoffBase time.Duration
|
||
backoffMax time.Duration
|
||
}
|
||
|
||
// OpenRouterConfig содержит настройки для OpenRouter клиента.
|
||
type OpenRouterConfig struct {
|
||
APIKey string
|
||
BaseURL string // по умолчанию: https://openrouter.ai/api/v1
|
||
Timeout time.Duration
|
||
Model string // модель по умолчанию
|
||
Prompt string // system prompt для валидации метаданных
|
||
|
||
MaxRetries int
|
||
RetryBackoff time.Duration
|
||
RetryBackoffMax time.Duration
|
||
}
|
||
|
||
// NewOpenRouterClient создаёт новый экземпляр OpenRouter клиента.
|
||
func NewOpenRouterClient(cfg OpenRouterConfig) *OpenRouterClient {
|
||
if cfg.BaseURL == "" {
|
||
cfg.BaseURL = "https://openrouter.ai/api/v1"
|
||
}
|
||
if cfg.Timeout == 0 {
|
||
cfg.Timeout = 120 * time.Second
|
||
}
|
||
if cfg.Model == "" {
|
||
cfg.Model = "openai/gpt-4o-mini"
|
||
}
|
||
if cfg.MaxRetries <= 0 {
|
||
cfg.MaxRetries = 3
|
||
}
|
||
if cfg.RetryBackoff <= 0 {
|
||
cfg.RetryBackoff = 1 * time.Second
|
||
}
|
||
if cfg.RetryBackoffMax <= 0 {
|
||
cfg.RetryBackoffMax = 8 * time.Second
|
||
}
|
||
|
||
return &OpenRouterClient{
|
||
apiKey: cfg.APIKey,
|
||
baseURL: cfg.BaseURL,
|
||
defaultModel: cfg.Model,
|
||
systemPrompt: cfg.Prompt,
|
||
maxRetries: cfg.MaxRetries,
|
||
backoffBase: cfg.RetryBackoff,
|
||
backoffMax: cfg.RetryBackoffMax,
|
||
httpClient: &http.Client{
|
||
Timeout: cfg.Timeout,
|
||
},
|
||
}
|
||
}
|
||
|
||
// openRouterRequest соответствует структуре запроса OpenRouter API.
|
||
type openRouterRequest struct {
|
||
Model string `json:"model"`
|
||
Messages []openRouterMessage `json:"messages"`
|
||
Temperature *float64 `json:"temperature,omitempty"`
|
||
MaxTokens *int `json:"max_tokens,omitempty"`
|
||
TopP *float64 `json:"top_p,omitempty"`
|
||
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
|
||
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
|
||
Stream bool `json:"stream"`
|
||
}
|
||
|
||
type openRouterMessage struct {
|
||
Role string `json:"role"`
|
||
Content string `json:"content"`
|
||
}
|
||
|
||
// openRouterResponse соответствует структуре ответа OpenRouter API.
|
||
type openRouterResponse struct {
|
||
ID string `json:"id"`
|
||
Model string `json:"model"`
|
||
Choices []openRouterChoice `json:"choices"`
|
||
Usage openRouterUsage `json:"usage"`
|
||
Error *openRouterError `json:"error,omitempty"`
|
||
}
|
||
|
||
type openRouterChoice struct {
|
||
Index int `json:"index"`
|
||
Message openRouterMessage `json:"message"`
|
||
FinishReason string `json:"finish_reason"`
|
||
}
|
||
|
||
type openRouterUsage struct {
|
||
PromptTokens int `json:"prompt_tokens"`
|
||
CompletionTokens int `json:"completion_tokens"`
|
||
TotalTokens int `json:"total_tokens"`
|
||
}
|
||
|
||
type openRouterError struct {
|
||
Code string `json:"code"`
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
// GenerateCompletion отправляет запрос к OpenRouter API и возвращает сгенерированный текст.
|
||
func (c *OpenRouterClient) GenerateCompletion(ctx context.Context, req *domain.LLMRequest) (*domain.LLMResponse, error) {
|
||
if c.apiKey == "" {
|
||
return nil, fmt.Errorf("openrouter: API key не указан")
|
||
}
|
||
|
||
// Если в запросе нет system-сообщения и задан системный промпт — добавляем его
|
||
reqWithPrompt := req
|
||
if c.systemPrompt != "" && !hasSystemMessage(req.Messages) {
|
||
augmented := &domain.LLMRequest{
|
||
Model: req.Model,
|
||
Temperature: req.Temperature,
|
||
MaxTokens: req.MaxTokens,
|
||
TopP: req.TopP,
|
||
FrequencyPenalty: req.FrequencyPenalty,
|
||
PresencePenalty: req.PresencePenalty,
|
||
Messages: append([]domain.LLMMessage{
|
||
{Role: "system", Content: c.systemPrompt},
|
||
}, req.Messages...),
|
||
}
|
||
reqWithPrompt = augmented
|
||
}
|
||
|
||
// Конвертируем domain.LLMRequest в openRouterRequest
|
||
apiReq := c.convertRequest(reqWithPrompt)
|
||
|
||
// Сериализуем запрос
|
||
bodyBytes, err := json.Marshal(apiReq)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("openrouter: ошибка сериализации запроса: %w", err)
|
||
}
|
||
|
||
url := c.baseURL + "/chat/completions"
|
||
|
||
var respBytes []byte
|
||
for attempt := 0; ; attempt++ {
|
||
httpReq, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
|
||
if reqErr != nil {
|
||
return nil, fmt.Errorf("openrouter: ошибка создания HTTP запроса: %w", reqErr)
|
||
}
|
||
|
||
httpReq.Header.Set("Content-Type", "application/json")
|
||
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
|
||
|
||
resp, doErr := c.httpClient.Do(httpReq)
|
||
if doErr != nil {
|
||
if c.shouldRetry(attempt, doErr, 0) {
|
||
if sleepErr := sleepWithContext(ctx, c.nextBackoff(attempt, 0)); sleepErr != nil {
|
||
return nil, fmt.Errorf("openrouter: ошибка выполнения запроса: %w", doErr)
|
||
}
|
||
continue
|
||
}
|
||
return nil, fmt.Errorf("openrouter: ошибка выполнения запроса: %w", doErr)
|
||
}
|
||
|
||
statusCode := resp.StatusCode
|
||
retryAfter := parseRetryAfter(resp.Header.Get("Retry-After"))
|
||
|
||
respBytes, err = io.ReadAll(resp.Body)
|
||
closeErr := resp.Body.Close()
|
||
if err == nil && closeErr != nil {
|
||
err = closeErr
|
||
}
|
||
|
||
if err != nil {
|
||
if c.shouldRetry(attempt, err, statusCode) {
|
||
if sleepErr := sleepWithContext(ctx, c.nextBackoff(attempt, retryAfter)); sleepErr != nil {
|
||
return nil, fmt.Errorf("openrouter: ошибка чтения ответа: %w", err)
|
||
}
|
||
continue
|
||
}
|
||
return nil, fmt.Errorf("openrouter: ошибка чтения ответа: %w", err)
|
||
}
|
||
|
||
if statusCode == http.StatusTooManyRequests && c.shouldRetry(attempt, nil, statusCode) {
|
||
if sleepErr := sleepWithContext(ctx, c.nextBackoff(attempt, retryAfter)); sleepErr != nil {
|
||
return nil, fmt.Errorf("openrouter: HTTP %d: %s", statusCode, string(respBytes))
|
||
}
|
||
continue
|
||
}
|
||
|
||
if statusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("openrouter: HTTP %d: %s", statusCode, string(respBytes))
|
||
}
|
||
|
||
break
|
||
}
|
||
|
||
// Десериализуем ответ
|
||
var apiResp openRouterResponse
|
||
if err := json.Unmarshal(respBytes, &apiResp); err != nil {
|
||
return nil, fmt.Errorf("openrouter: ошибка десериализации ответа: %w", err)
|
||
}
|
||
|
||
// Проверяем наличие ошибок в ответе
|
||
if apiResp.Error != nil {
|
||
return nil, fmt.Errorf("openrouter API error [%s]: %s", apiResp.Error.Code, apiResp.Error.Message)
|
||
}
|
||
|
||
// Конвертируем ответ в domain.LLMResponse
|
||
return c.convertResponse(&apiResp), nil
|
||
}
|
||
|
||
func (c *OpenRouterClient) shouldRetry(attempt int, err error, statusCode int) bool {
|
||
if attempt >= c.maxRetries {
|
||
return false
|
||
}
|
||
|
||
if statusCode == http.StatusTooManyRequests {
|
||
return true
|
||
}
|
||
|
||
if err == nil {
|
||
return false
|
||
}
|
||
|
||
// Не повторяем при явной отмене контекста
|
||
if errors.Is(err, context.Canceled) {
|
||
return false
|
||
}
|
||
|
||
if errors.Is(err, context.DeadlineExceeded) {
|
||
return true
|
||
}
|
||
|
||
var netErr net.Error
|
||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||
return true
|
||
}
|
||
|
||
errText := strings.ToLower(err.Error())
|
||
return strings.Contains(errText, "deadline exceeded") || strings.Contains(errText, "timeout")
|
||
}
|
||
|
||
func (c *OpenRouterClient) nextBackoff(attempt int, retryAfter time.Duration) time.Duration {
|
||
shift := attempt
|
||
if shift > 30 {
|
||
shift = 30
|
||
}
|
||
delay := c.backoffBase << shift
|
||
if delay <= 0 {
|
||
delay = c.backoffBase
|
||
}
|
||
if c.backoffMax > 0 && delay > c.backoffMax {
|
||
delay = c.backoffMax
|
||
}
|
||
if retryAfter > delay {
|
||
delay = retryAfter
|
||
}
|
||
return delay
|
||
}
|
||
|
||
func sleepWithContext(ctx context.Context, wait time.Duration) error {
|
||
if wait <= 0 {
|
||
return nil
|
||
}
|
||
|
||
timer := time.NewTimer(wait)
|
||
defer timer.Stop()
|
||
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
case <-timer.C:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
func parseRetryAfter(v string) time.Duration {
|
||
v = strings.TrimSpace(v)
|
||
if v == "" {
|
||
return 0
|
||
}
|
||
|
||
if seconds, err := strconv.Atoi(v); err == nil && seconds > 0 {
|
||
return time.Duration(seconds) * time.Second
|
||
}
|
||
|
||
if t, err := http.ParseTime(v); err == nil {
|
||
wait := time.Until(t)
|
||
if wait > 0 {
|
||
return wait
|
||
}
|
||
}
|
||
|
||
return 0
|
||
}
|
||
|
||
// convertRequest конвертирует domain.LLMRequest в openRouterRequest.
|
||
func (c *OpenRouterClient) convertRequest(req *domain.LLMRequest) *openRouterRequest {
|
||
model := req.Model
|
||
if model == "" {
|
||
model = c.defaultModel
|
||
}
|
||
|
||
apiReq := &openRouterRequest{
|
||
Model: model,
|
||
Messages: make([]openRouterMessage, len(req.Messages)),
|
||
Stream: false,
|
||
}
|
||
|
||
for i, msg := range req.Messages {
|
||
apiReq.Messages[i] = openRouterMessage{
|
||
Role: msg.Role,
|
||
Content: msg.Content,
|
||
}
|
||
}
|
||
|
||
if req.Temperature > 0 {
|
||
apiReq.Temperature = &req.Temperature
|
||
}
|
||
if req.MaxTokens > 0 {
|
||
apiReq.MaxTokens = &req.MaxTokens
|
||
}
|
||
if req.TopP > 0 {
|
||
apiReq.TopP = &req.TopP
|
||
}
|
||
if req.FrequencyPenalty != 0 {
|
||
apiReq.FrequencyPenalty = &req.FrequencyPenalty
|
||
}
|
||
if req.PresencePenalty != 0 {
|
||
apiReq.PresencePenalty = &req.PresencePenalty
|
||
}
|
||
|
||
return apiReq
|
||
}
|
||
|
||
// GetDefaultModel возвращает модель по умолчанию, указанную при создании клиента.
|
||
func (c *OpenRouterClient) GetDefaultModel() string {
|
||
return c.defaultModel
|
||
}
|
||
|
||
// convertResponse конвертирует openRouterResponse в domain.LLMResponse.
|
||
func (c *OpenRouterClient) convertResponse(resp *openRouterResponse) *domain.LLMResponse {
|
||
domainResp := &domain.LLMResponse{
|
||
ID: resp.ID,
|
||
Model: resp.Model,
|
||
Choices: make([]domain.LLMChoice, len(resp.Choices)),
|
||
Usage: domain.LLMUsage{
|
||
PromptTokens: resp.Usage.PromptTokens,
|
||
CompletionTokens: resp.Usage.CompletionTokens,
|
||
TotalTokens: resp.Usage.TotalTokens,
|
||
},
|
||
}
|
||
|
||
for i, choice := range resp.Choices {
|
||
domainResp.Choices[i] = domain.LLMChoice{
|
||
Index: choice.Index,
|
||
Message: domain.LLMMessage{
|
||
Role: choice.Message.Role,
|
||
Content: choice.Message.Content,
|
||
},
|
||
FinishReason: choice.FinishReason,
|
||
}
|
||
}
|
||
|
||
return domainResp
|
||
}
|
||
|
||
// MetadataValidationResult содержит результат валидации метаданных.
|
||
type MetadataValidationResult struct {
|
||
Author string `json:"author"`
|
||
Title string `json:"title"`
|
||
AuthorFixed bool `json:"author_fixed"`
|
||
TitleFixed bool `json:"title_fixed"`
|
||
Changes string `json:"changes"`
|
||
}
|
||
|
||
// ValidateMetadata проверяет и исправляет метаданные аудиокниги через LLM.
|
||
// Возвращает nil, если API ключ пуст или промпт не настроен.
|
||
func (c *OpenRouterClient) ValidateMetadata(ctx context.Context, author, title string) (*MetadataValidationResult, error) {
|
||
// Если API ключ пуст, возвращаем nil (проверка отключена)
|
||
if c.apiKey == "" {
|
||
return nil, nil
|
||
}
|
||
|
||
// Если промпт не настроен, возвращаем nil
|
||
if c.systemPrompt == "" {
|
||
return nil, nil
|
||
}
|
||
|
||
// Формируем запрос пользователя
|
||
userMessage := fmt.Sprintf("Автор: %s\nНазвание: %s", author, title)
|
||
|
||
// Создаём запрос к LLM
|
||
req := &domain.LLMRequest{
|
||
Model: c.defaultModel,
|
||
Messages: []domain.LLMMessage{
|
||
{
|
||
Role: "system",
|
||
Content: c.systemPrompt,
|
||
},
|
||
{
|
||
Role: "user",
|
||
Content: userMessage,
|
||
},
|
||
},
|
||
Temperature: 0.3, // Низкая температура для большей точности
|
||
MaxTokens: 500,
|
||
}
|
||
|
||
// Выполняем запрос
|
||
resp, err := c.GenerateCompletion(ctx, req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("ошибка валидации метаданных: %w", err)
|
||
}
|
||
|
||
// Получаем ответ
|
||
if len(resp.Choices) == 0 {
|
||
return nil, fmt.Errorf("пустой ответ от LLM")
|
||
}
|
||
|
||
content := resp.Choices[0].Message.Content
|
||
|
||
// Извлекаем JSON из ответа (LLM может обернуть в ```json ... ```)
|
||
content = extractJSONFromLLM(content)
|
||
|
||
// Парсим JSON ответ
|
||
var result MetadataValidationResult
|
||
if err := json.Unmarshal([]byte(content), &result); err != nil {
|
||
return nil, fmt.Errorf("ошибка парсинга ответа LLM: %w (ответ: %s)", err, content)
|
||
}
|
||
|
||
return &result, nil
|
||
}
|
||
|
||
// extractJSONFromLLM извлекает JSON из ответа LLM, убирая markdown-обёртку.
|
||
func extractJSONFromLLM(s string) string {
|
||
s = strings.TrimSpace(s)
|
||
|
||
// Убираем ```json ... ``` или ``` ... ```
|
||
if strings.HasPrefix(s, "```") {
|
||
if idx := strings.Index(s, "\n"); idx != -1 {
|
||
s = s[idx+1:]
|
||
}
|
||
if idx := strings.LastIndex(s, "```"); idx != -1 {
|
||
s = s[:idx]
|
||
}
|
||
}
|
||
|
||
return strings.TrimSpace(s)
|
||
}
|
||
|
||
// GetSystemPrompt возвращает системный промпт.
|
||
func (c *OpenRouterClient) GetSystemPrompt() string {
|
||
return c.systemPrompt
|
||
}
|
||
|
||
// IsEnabled проверяет, настроен ли клиент для работы (есть API ключ).
|
||
func (c *OpenRouterClient) IsEnabled() bool {
|
||
return c.apiKey != ""
|
||
}
|
||
|
||
// hasSystemMessage проверяет, есть ли в списке сообщений system-сообщение.
|
||
func hasSystemMessage(messages []domain.LLMMessage) bool {
|
||
for _, msg := range messages {
|
||
if msg.Role == "system" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|