feat: Ollama adapter + chat rate limiter (30 req/hour)

Ollama adapter (internal/chat/ollama.go):
- Implements model.LLM interface for ADK Go
- Talks to Ollama's OpenAI-compatible API (/v1/chat/completions)
- Full tool/function calling support (tested with Mistral Small 3.2)
- Converts ADK types to OpenAI format (messages, tools, tool_calls)
- Configurable via OLLAMA_HOST and OLLAMA_MODEL env vars

Multi-provider handler:
- MODEL_PROVIDER env: "gemini" (default) or "ollama"
- Gemini: requires GOOGLE_API_KEY (pay-as-you-go recommended)
- Ollama: connects to local or Tailscale-remote instance

Rate limiter:
- 30 requests/hour per IP on /api/chat endpoint
- Uses existing middleware.NewRateLimiter pattern

Tested: Ollama + Mistral Small 3.2 on M4 Pro 64GB — correct answers
This commit is contained in:
juanatsap
2026-04-08 14:47:14 +01:00
parent 4f558ac842
commit 8205a22972
5 changed files with 510 additions and 17 deletions
+60 -15
View File
@@ -13,6 +13,7 @@ import (
"github.com/juanatsap/cv-site/internal/cache"
"google.golang.org/adk/agent"
"google.golang.org/adk/model"
"google.golang.org/adk/model/gemini"
"google.golang.org/adk/runner"
"google.golang.org/adk/session"
@@ -26,26 +27,28 @@ type Handler struct {
enabled bool
}
// NewHandler creates a chat handler. Returns a disabled handler if GOOGLE_API_KEY is not set.
// NewHandler creates a chat handler. Returns a disabled handler if no model provider is configured.
func NewHandler(dataCache *cache.DataCache) *Handler {
apiKey := os.Getenv("GOOGLE_API_KEY")
if apiKey == "" {
log.Println("⚠️ GOOGLE_API_KEY not set — chat feature disabled")
return &Handler{enabled: false}
provider := os.Getenv("MODEL_PROVIDER")
if provider == "" {
provider = "gemini"
}
ctx := context.Background()
var llm model.LLM
var providerLabel string
modelName := os.Getenv("MODEL_NAME")
if modelName == "" {
modelName = "gemini-2.5-flash"
switch provider {
case "ollama":
llm, providerLabel = initOllamaProvider()
default:
var err error
llm, providerLabel, err = initGeminiProvider()
if err != nil {
return &Handler{enabled: false}
}
}
llm, err := gemini.NewModel(ctx, modelName, &genai.ClientConfig{
APIKey: apiKey,
})
if err != nil {
log.Printf("⚠️ Failed to initialize Gemini model: %v — chat disabled", err)
if llm == nil {
return &Handler{enabled: false}
}
@@ -68,7 +71,7 @@ func NewHandler(dataCache *cache.DataCache) *Handler {
return &Handler{enabled: false}
}
log.Printf("💬 Chat agent enabled (model: %s)", modelName)
log.Printf("💬 Chat agent enabled (%s)", providerLabel)
return &Handler{
runner: r,
@@ -77,6 +80,48 @@ func NewHandler(dataCache *cache.DataCache) *Handler {
}
}
// initGeminiProvider initializes the Gemini LLM provider.
func initGeminiProvider() (model.LLM, string, error) {
apiKey := os.Getenv("GOOGLE_API_KEY")
if apiKey == "" {
log.Println("⚠️ GOOGLE_API_KEY not set — chat feature disabled")
return nil, "", fmt.Errorf("no API key")
}
ctx := context.Background()
modelName := os.Getenv("MODEL_NAME")
if modelName == "" {
modelName = "gemini-2.5-flash"
}
llm, err := gemini.NewModel(ctx, modelName, &genai.ClientConfig{
APIKey: apiKey,
})
if err != nil {
log.Printf("⚠️ Failed to initialize Gemini model: %v — chat disabled", err)
return nil, "", err
}
return llm, fmt.Sprintf("gemini: %s", modelName), nil
}
// initOllamaProvider initializes the Ollama LLM provider.
func initOllamaProvider() (model.LLM, string) {
host := os.Getenv("OLLAMA_HOST")
if host == "" {
host = "http://localhost:11434"
}
modelName := os.Getenv("OLLAMA_MODEL")
if modelName == "" {
modelName = "mistral-small3.2"
}
llm := NewOllamaModel(host, modelName)
return llm, fmt.Sprintf("ollama: %s @ %s", modelName, host)
}
// Enabled returns whether the chat feature is available.
func (h *Handler) Enabled() bool {
return h.enabled