// internal/ai/openai_compat.go // Дата: 2026-03-12 // OpenAI-совместимый провайдер — работает с Groq, OpenRouter, OpenAI и любым // другим сервисом реализующим /v1/chat/completions API. // Нет геоблока в отличие от Google Gemini. package ai import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "time" ) // OpenAICompatProvider реализует LLMProvider через OpenAI-совместимый API. // Groq endpoint: https://api.groq.com/openai/v1/chat/completions type OpenAICompatProvider struct { endpoint string apiKey string model string httpClient *http.Client } // NewOpenAICompatProvider создаёт провайдера для любого OpenAI-совместимого API. // endpoint — полный URL до /v1/chat/completions // apiKey — Bearer токен (GROQ_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY) // model — название модели (например "llama-3.3-70b-versatile" для Groq) func NewOpenAICompatProvider(endpoint, apiKey, model string) (*OpenAICompatProvider, error) { if apiKey == "" { return nil, fmt.Errorf("GROQ_API_KEY не задан") } if endpoint == "" { endpoint = "https://api.groq.com/openai/v1/chat/completions" } if model == "" { model = "llama-3.3-70b-versatile" } return &OpenAICompatProvider{ endpoint: endpoint, apiKey: apiKey, model: model, httpClient: &http.Client{ Timeout: 60 * time.Second, }, }, nil } func (p *OpenAICompatProvider) Name() string { return fmt.Sprintf("groq/%s", p.model) } // openAIRequest — тело запроса к /v1/chat/completions type openAIRequest struct { Model string `json:"model"` Messages []openAIMessage `json:"messages"` } type openAIMessage struct { Role string `json:"role"` Content string `json:"content"` } // openAIResponse — тело ответа от /v1/chat/completions type openAIResponse struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` Error *struct { Message string `json:"message"` } `json:"error,omitempty"` } // Analyze отправляет промпт в OpenAI-совместимый API и возвращает ответ. func (p *OpenAICompatProvider) Analyze(ctx context.Context, prompt string) (string, error) { reqBody := openAIRequest{ Model: p.model, Messages: []openAIMessage{ {Role: "user", Content: prompt}, }, } bodyBytes, err := json.Marshal(reqBody) if err != nil { return "", fmt.Errorf("marshal error: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.endpoint, bytes.NewReader(bodyBytes)) if err != nil { return "", fmt.Errorf("create request error: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+p.apiKey) resp, err := p.httpClient.Do(req) if err != nil { return "", fmt.Errorf("http error: %w", err) } defer resp.Body.Close() respBytes, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("read response error: %w", err) } var parsed openAIResponse if err := json.Unmarshal(respBytes, &parsed); err != nil { return "", fmt.Errorf("parse response error: %w", err) } if parsed.Error != nil { return "", fmt.Errorf("API ошибка: %s", parsed.Error.Message) } if len(parsed.Choices) == 0 || parsed.Choices[0].Message.Content == "" { return "", fmt.Errorf("пустой ответ от API") } return parsed.Choices[0].Message.Content, nil }