Files
GenAudioBookInfo/internal/infrastructure/openrouter_client.go
Dmitriy Fofanov 41fb62f62e Добавлены страницы вики для GenAudioBookInfo: Home, Installation, Makefile, OpenRouter, Output Structure, TorrAPI и Sidebar.
Создана структура документации, описывающая функциональность, установку, использование CLI, архитектуру и интеграции с TorrAPI и OpenRouter.
Добавлены примеры конфигурации и метаданных, а также описание структуры выходных данных.
2026-02-23 13:19:39 +03:00

485 lines
14 KiB
Go
Raw 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 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
}