From bd9fb7145cdde65582c368322f90e4c5cfdf0afe Mon Sep 17 00:00:00 2001 From: StirGpea Date: Fri, 21 Aug 2026 13:31:35 +0000 Subject: [PATCH] feat: add modular REST and gRPC Gemini clients with interface --- bot.log | 6 ++ internal/bot/bot.go | 8 +- internal/gemini/client.go | 167 +++++++++++++++++++++++++++----------- 3 files changed, 131 insertions(+), 50 deletions(-) create mode 100644 bot.log diff --git a/bot.log b/bot.log new file mode 100644 index 0000000..9274114 --- /dev/null +++ b/bot.log @@ -0,0 +1,6 @@ +pievr bot starting... +2026/08/21 13:21:01 Telegram bot is starting... +2026/08/21 13:21:29 Received message from 1294124438: /start +2026/08/21 13:21:29 Failed to generate response from Gemini: proto: syntax error (line 1:1): unexpected token +2026/08/21 13:21:34 Received message from 1294124438: Алло? +2026/08/21 13:21:34 Failed to generate response from Gemini: proto: syntax error (line 1:1): unexpected token diff --git a/internal/bot/bot.go b/internal/bot/bot.go index bcae4db..69246bc 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -11,11 +11,11 @@ import ( ) type Bot struct { - b *bot.Bot - geminiCli *gemini.Client + b *bot.Bot + geminiCli gemini.Client } -func New(token string, geminiCli *gemini.Client) (*Bot, error) { +func New(token string, geminiCli gemini.Client) (*Bot, error) { myBot := &Bot{ geminiCli: geminiCli, } @@ -48,7 +48,7 @@ func (s *Bot) handler(ctx context.Context, b *bot.Bot, update *models.Update) { log.Printf("Received message from %d: %s", chatID, userMsg) - // Send prompt to Gemini + // Send prompt to Gemini (via interface) reply, err := s.geminiCli.Generate(ctx, userMsg) if err != nil { log.Printf("Failed to generate response from Gemini: %v", err) diff --git a/internal/gemini/client.go b/internal/gemini/client.go index 3b1c1e6..5af0269 100644 --- a/internal/gemini/client.go +++ b/internal/gemini/client.go @@ -1,53 +1,135 @@ package gemini import ( + "bytes" "context" + "encoding/json" "fmt" + "io" "net/http" - "net/url" + "strings" "github.com/google/generative-ai-go/genai" "google.golang.org/api/option" ) -type Client struct { +// Client defines the common interface for Gemini interactions +type Client interface { + Generate(ctx context.Context, prompt string) (string, error) + Close() error +} + +// --- REST Client (for custom BaseURL / proxy like lar) --- + +type RestClient struct { + httpClient *http.Client + baseURL string + apiKey string + model string +} + +func NewRestClient(apiKey, baseURL, model string) (*RestClient, error) { + baseURL = strings.TrimSuffix(baseURL, "/") + return &RestClient{ + httpClient: &http.Client{}, + baseURL: baseURL, + apiKey: apiKey, + model: model, + }, nil +} + +type restRequest struct { + Contents []restContent `json:"contents"` +} + +type restContent struct { + Parts []restPart `json:"parts"` +} + +type restPart struct { + Text string `json:"text"` +} + +type restResponse struct { + Candidates []restCandidate `json:"candidates"` +} + +type restCandidate struct { + Content restContent `json:"content"` +} + +func (c *RestClient) Generate(ctx context.Context, prompt string) (string, error) { + // API key is passed as a query parameter in Gemini REST API + url := fmt.Sprintf("%s/v1beta/models/%s:generateContent?key=%s", c.baseURL, c.model, c.apiKey) + + reqBody := restRequest{ + Contents: []restContent{ + { + Parts: []restPart{ + {Text: prompt}, + }, + }, + }, + } + + jsonBytes, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("failed to marshal rest request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonBytes)) + if err != nil { + return "", fmt.Errorf("failed to create http request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("http request failed: %w", err) + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("gemini rest api error (status %d): %s", resp.StatusCode, string(bodyBytes)) + } + + var res restResponse + if err := json.Unmarshal(bodyBytes, &res); err != nil { + return "", fmt.Errorf("failed to decode rest response: %w (body: %s)", err, string(bodyBytes)) + } + + if len(res.Candidates) == 0 || len(res.Candidates[0].Content.Parts) == 0 { + return "", fmt.Errorf("empty candidates in rest response") + } + + return res.Candidates[0].Content.Parts[0].Text, nil +} + +func (c *RestClient) Close() error { + return nil +} + + +// --- gRPC Client (official SDK for direct Google API connection) --- + +type GrpcClient struct { client *genai.Client model string } -func NewClient(ctx context.Context, apiKey, baseURL, modelName string) (*Client, error) { - var opts []option.ClientOption - if apiKey != "" { - opts = append(opts, option.WithAPIKey(apiKey)) - } - - if baseURL != "" { - parsedURL, err := url.Parse(baseURL) - if err != nil { - return nil, fmt.Errorf("invalid base url: %w", err) - } - // Custom HTTP client endpoint override for Gemini API proxy/baseURL - customClient := &http.Client{ - Transport: &baseURITransport{ - BaseURL: parsedURL, - Transport: http.DefaultTransport, - }, - } - opts = append(opts, option.WithHTTPClient(customClient)) - } - - c, err := genai.NewClient(ctx, opts...) +func NewGrpcClient(ctx context.Context, apiKey, model string) (*GrpcClient, error) { + c, err := genai.NewClient(ctx, option.WithAPIKey(apiKey)) if err != nil { return nil, err } - - return &Client{ + return &GrpcClient{ client: c, - model: modelName, + model: model, }, nil } -func (c *Client) Generate(ctx context.Context, prompt string) (string, error) { +func (c *GrpcClient) Generate(ctx context.Context, prompt string) (string, error) { model := c.client.GenerativeModel(c.model) resp, err := model.GenerateContent(ctx, genai.Text(prompt)) if err != nil { @@ -55,7 +137,7 @@ func (c *Client) Generate(ctx context.Context, prompt string) (string, error) { } if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 { - return "", fmt.Errorf("empty response from Gemini") + return "", fmt.Errorf("empty response from Gemini gRPC") } part := resp.Candidates[0].Content.Parts[0] @@ -66,25 +148,18 @@ func (c *Client) Generate(ctx context.Context, prompt string) (string, error) { return fmt.Sprintf("%v", part), nil } -func (c *Client) Close() { - c.client.Close() +func (c *GrpcClient) Close() error { + return c.client.Close() } -type baseURITransport struct { - BaseURL *url.URL - Transport http.RoundTripper -} -func (t *baseURITransport) RoundTrip(req *http.Request) (*http.Response, error) { - // Rewrite request URL to use custom BaseURL while preserving path & query - req.URL.Scheme = t.BaseURL.Scheme - req.URL.Host = t.BaseURL.Host - if t.BaseURL.Path != "" { - req.URL.Path = t.BaseURL.Path + req.URL.Path +// --- Factory helper --- + +func NewClient(ctx context.Context, apiKey, baseURL, model string) (Client, error) { + if baseURL != "" { + // If custom base_url is specified (e.g. lar proxy), use REST client + return NewRestClient(apiKey, baseURL, model) } - tr := t.Transport - if tr == nil { - tr = http.DefaultTransport - } - return tr.RoundTrip(req) + // Otherwise use official gRPC client + return NewGrpcClient(ctx, apiKey, model) }