diff --git a/.gitignore b/.gitignore index c96435d..99d571b 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ *.swo .DS_Store +config.json +pievr +junk/ diff --git a/MEMORY.md b/MEMORY.md new file mode 100644 index 0000000..1d70b23 --- /dev/null +++ b/MEMORY.md @@ -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/` diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5540851 --- /dev/null +++ b/Makefile @@ -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 diff --git a/cmd/pievr/main.go b/cmd/pievr/main.go new file mode 100644 index 0000000..515f32f --- /dev/null +++ b/cmd/pievr/main.go @@ -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) +} diff --git a/config.json.example b/config.json.example new file mode 100644 index 0000000..0c5a2d9 --- /dev/null +++ b/config.json.example @@ -0,0 +1,5 @@ +{ + "telegram_token": "YOUR_TELEGRAM_BOT_TOKEN", + "gemini_api_key": "YOUR_GEMINI_API_KEY", + "gemini_base_url": "" +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..68e0a3d --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..66b85ff --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/bot/bot.go b/internal/bot/bot.go new file mode 100644 index 0000000..69246bc --- /dev/null +++ b/internal/bot/bot.go @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..117f47c --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..90b721f --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +} diff --git a/internal/gemini/client.go b/internal/gemini/client.go new file mode 100644 index 0000000..aff02f1 --- /dev/null +++ b/internal/gemini/client.go @@ -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) +}