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