126 lines
3.5 KiB
Go
126 lines
3.5 KiB
Go
// 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
|
|
}
|