Compare commits

..

12 commits

11 changed files with 512 additions and 0 deletions

3
.gitignore vendored
View file

@ -16,3 +16,6 @@
*.swo
.DS_Store
config.json
pievr
junk/

29
MEMORY.md Normal file
View file

@ -0,0 +1,29 @@
# Project Memory & Knowledge Base
## Projects
### Buglab
- **Project Name:** buglab
- **Goal:** C++ pathfinding & maze optimization project for buglab.ru.
- **Grid size:** 19 x 29 cells.
- **Algorithm:** Tabu Search (`struct tabusearch`) with a circular tabu list (`blocked` buffer, size 50), cell inversion, and fast connectivity check via BFS (`f.check()`). The older Simulated Annealing approach is deprecated and abandoned.
- **Persistence:** SQLite database (`storage/labyrinths.db`) for tracking results, scores, and metadata. `storage/` and build artifacts are strictly excluded via `.gitignore`.
### Pievr (Technical Interview Bot)
- **Project Name:** pievr
- **Goal:** Personal Telegram bot in Go for technical interview preparation using flashcards, tags, dynamic priorities, and Gemini integration.
- **Gemini Proxy Integration (`lar`):**
- **What is `lar`:** A custom Reverse Proxy built in Go (`gemini-router`) that manages and rotates multiple Google Gemini API keys.
- **Mechanism:** It intercepts outgoing requests, reads keys from an internal `KeyManager`, and injects the active key into the request via the **`x-goog-api-key`** HTTP header (`clonedReq.Header.Set("x-goog-api-key", key.ApiKey())`).
- **Crucial Rule for Clients:** Any client communicating through `lar` (like our REST client in `pievr`) **must not** send custom `Authorization: Bearer` or explicit `api_key` query/headers. Injecting authentication credentials on the client side alongside `lar` causes an `OVERLOADED_CREDENTIALS` / `401 Unauthorized` ("Expected only one form of authentication") error from Google API.
## Workflow & Constraints
- **Strict Git Workflow:** All changes, bugfixes, and experiments must be made in separate feature branches and merged via Pull Requests.
- **Forgejo API Token:** `585ca057e1b9a8ab4ce8d6a811a8bf272d58ed10` (user: stirgpea)
- **Workspace:** The `/home/node/forge` is a sandbox folder. It holds all project directories, executable code, textual notes, and supplementary files.
## Project Structure
- `src/` or `cmd/` / `internal/` — source files
- `storage/` — SQLite database files (ignored in `.gitignore`)
- `docs/` — project documentation and notes
- `README.md` — project overview
- `.gitignore` — excludes build artifacts and `storage/`, `junk/`

25
Makefile Normal file
View file

@ -0,0 +1,25 @@
.PHONY: all build start stop restart clean
all: build
build:
@mkdir -p junk
go build -o junk/pievr ./cmd/pievr
stop:
@if [ -f junk/pievr.pid ]; then \
echo "Stopping bot (PID $$(cat junk/pievr.pid))..."; \
kill -15 $$(cat junk/pievr.pid) 2>/dev/null || true; \
rm -f junk/pievr.pid; \
fi
@pkill -9 -f "./junk/pievr" 2>/dev/null || true
start: stop build
@echo "Starting bot..."
@nohup ./junk/pievr > junk/bot.log 2>&1 & echo $$! > junk/pievr.pid
@echo "Bot started with PID $$(cat junk/pievr.pid). Logs: junk/bot.log"
restart: start
clean: stop
@rm -rf junk

41
cmd/pievr/main.go Normal file
View file

@ -0,0 +1,41 @@
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"pievr/internal/bot"
"pievr/internal/config"
"pievr/internal/gemini"
)
func main() {
fmt.Println("pievr bot starting...")
cfg, err := config.Load("config.json")
if err != nil {
log.Fatalf("Failed to load config: %v (make sure config.json exists, see config.json.example)", err)
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
// Initialize Gemini client
geminiCli, err := gemini.NewClient(ctx, cfg.GeminiApiKey, cfg.GeminiBaseURL, cfg.GeminiModel)
if err != nil {
log.Fatalf("Failed to create Gemini client: %v", err)
}
defer geminiCli.Close()
// Initialize Telegram bot
telegramBot, err := bot.New(cfg.TelegramToken, geminiCli)
if err != nil {
log.Fatalf("Failed to create Telegram bot: %v", err)
}
// Start bot (blocking)
telegramBot.Start(ctx)
}

5
config.json.example Normal file
View file

@ -0,0 +1,5 @@
{
"telegram_token": "YOUR_TELEGRAM_BOT_TOKEN",
"gemini_api_key": "YOUR_GEMINI_API_KEY",
"gemini_base_url": ""
}

40
go.mod Normal file
View file

@ -0,0 +1,40 @@
module pievr
go 1.26.3
require (
cloud.google.com/go v0.115.0 // indirect
cloud.google.com/go/ai v0.8.0 // indirect
cloud.google.com/go/auth v0.23.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/longrunning v0.5.7 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-telegram/bot v1.23.0 // indirect
github.com/google/generative-ai-go v0.20.1 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.20 // indirect
github.com/googleapis/gax-go/v2 v2.23.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/api v0.293.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260807164820-c8921c73eeea // indirect
google.golang.org/grpc v1.83.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

69
go.sum Normal file
View file

@ -0,0 +1,69 @@
cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
cloud.google.com/go/ai v0.8.0 h1:rXUEz8Wp2OlrM8r1bfmpF2+VKqc1VJpafE3HgzRnD/w=
cloud.google.com/go/ai v0.8.0/go.mod h1:t3Dfk4cM61sytiggo2UyGsDVW3RF1qGZaUKDrZFyqkE=
cloud.google.com/go/auth v0.23.0 h1:6Gg1CMgpgubRG7DGz5Vf1pcoNo8RfiRiRAPS4crTp54=
cloud.google.com/go/auth v0.23.0/go.mod h1:4DhBRcqvtljQN3dJ57qtqbib5ZGCYE5f2crfiiC2EM0=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/longrunning v0.5.7 h1:WLbHekDbjK1fVFD3ibpFFVoyizlLRl73I7YKuAKilhU=
cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-telegram/bot v1.23.0 h1:CKKQq115G/GUGBG8uuWl5uXbiBHyVjZBp/qqOLWZjJk=
github.com/go-telegram/bot v1.23.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
github.com/google/generative-ai-go v0.20.1 h1:6dEIujpgN2V0PgLhr6c/M1ynRdc7ARtiIDPFzj45uNQ=
github.com/google/generative-ai-go v0.20.1/go.mod h1:TjOnZJmZKzarWbjUJgy+r3Ee7HGBRVLhOIgupnwR4Bg=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.20 h1:t/xL64VUoN69MuMRQuJETqYGOw4Z9mSRJK9epIEtwFk=
github.com/googleapis/enterprise-certificate-proxy v0.3.20/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w=
github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE=
github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
google.golang.org/api v0.293.0 h1:p9XIWOf63U4OgYx120ZwVU8+vl4XTPmWfgVPnmOAS9w=
google.golang.org/api v0.293.0/go.mod h1:6n5tjEB1gzwniZTepZ0g5u+wM7Bof5GeULCx/zh8ZE0=
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU=
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260807164820-c8921c73eeea h1:kVhQEPTpKQahD5+JSBTfBB19wcgQTTjAIn45MBqnyHk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260807164820-c8921c73eeea/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=

65
internal/bot/bot.go Normal file
View file

@ -0,0 +1,65 @@
package bot
import (
"context"
"fmt"
"log"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"pievr/internal/gemini"
)
type Bot struct {
b *bot.Bot
geminiCli gemini.Client
}
func New(token string, geminiCli gemini.Client) (*Bot, error) {
myBot := &Bot{
geminiCli: geminiCli,
}
opts := []bot.Option{
bot.WithDefaultHandler(myBot.handler),
}
b, err := bot.New(token, opts...)
if err != nil {
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
}
myBot.b = b
return myBot, nil
}
func (s *Bot) Start(ctx context.Context) {
log.Println("Telegram bot is starting...")
s.b.Start(ctx)
}
func (s *Bot) handler(ctx context.Context, b *bot.Bot, update *models.Update) {
if update.Message == nil || update.Message.Text == "" {
return
}
userMsg := update.Message.Text
chatID := update.Message.Chat.ID
log.Printf("Received message from %d: %s", chatID, userMsg)
// 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)
reply = "Извините, произошла ошибка при обращении к LLM."
}
_, err = b.SendMessage(ctx, &bot.SendMessageParams{
ChatID: chatID,
Text: reply,
})
if err != nil {
log.Printf("Failed to send telegram message: %v", err)
}
}

29
internal/config/config.go Normal file
View file

@ -0,0 +1,29 @@
package config
import (
"encoding/json"
"os"
)
type Config struct {
TelegramToken string `json:"telegram_token"`
GeminiApiKey string `json:"gemini_api_key"`
GeminiBaseURL string `json:"gemini_base_url"`
GeminiModel string `json:"gemini_model"`
}
func Load(path string) (*Config, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
var cfg Config
decoder := json.NewDecoder(file)
if err := decoder.Decode(&cfg); err != nil {
return nil, err
}
return &cfg, nil
}

View file

@ -0,0 +1,44 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadConfig_NotFound(t *testing.T) {
_, err := Load("non_existent_config_file_12345.json")
if err == nil {
t.Fatal("expected error when loading non-existent config, got nil")
}
}
func TestLoadConfig_ValidJSON(t *testing.T) {
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "config.json")
content := []byte(`{
"telegram_token": "test_token",
"gemini_api_key": "test_key",
"gemini_base_url": "http://localhost:8080"
}`)
if err := os.WriteFile(tmpFile, content, 0644); err != nil {
t.Fatalf("failed to write temp config file: %v", err)
}
cfg, err := Load(tmpFile)
if err != nil {
t.Fatalf("failed to load valid config: %v", err)
}
if cfg.TelegramToken != "test_token" {
t.Errorf("expected telegram_token 'test_token', got '%s'", cfg.TelegramToken)
}
if cfg.GeminiApiKey != "test_key" {
t.Errorf("expected gemini_api_key 'test_key', got '%s'", cfg.GeminiApiKey)
}
if cfg.GeminiBaseURL != "http://localhost:8080" {
t.Errorf("expected gemini_base_url 'http://localhost:8080', got '%s'", cfg.GeminiBaseURL)
}
}

162
internal/gemini/client.go Normal file
View file

@ -0,0 +1,162 @@
package gemini
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/google/generative-ai-go/genai"
"google.golang.org/api/option"
)
// 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
model string
}
func NewRestClient(apiKey, baseURL, model string) (*RestClient, error) {
baseURL = strings.TrimSuffix(baseURL, "/")
return &RestClient{
httpClient: &http.Client{},
baseURL: baseURL,
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) {
url := fmt.Sprintf("%s/v1beta/models/%s:generateContent", c.baseURL, c.model)
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")
// Note: Authentication (x-goog-api-key) is injected transparently by the lar proxy transport.
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 NewGrpcClient(ctx context.Context, apiKey, model string) (*GrpcClient, error) {
c, err := genai.NewClient(ctx, option.WithAPIKey(apiKey))
if err != nil {
return nil, err
}
return &GrpcClient{
client: c,
model: model,
}, nil
}
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 {
return "", err
}
if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 {
return "", fmt.Errorf("empty response from Gemini gRPC")
}
part := resp.Candidates[0].Content.Parts[0]
if textPart, ok := part.(genai.Text); ok {
return string(textPart), nil
}
return fmt.Sprintf("%v", part), nil
}
func (c *GrpcClient) Close() error {
return c.client.Close()
}
// --- Factory helper ---
func NewClient(ctx context.Context, apiKey, baseURL, model string) (Client, error) {
if baseURL != "" {
return NewRestClient(apiKey, baseURL, model)
}
return NewGrpcClient(ctx, apiKey, model)
}