diff --git a/gateway/main.go b/gateway/main.go index 4bf06e1..d1face1 100644 --- a/gateway/main.go +++ b/gateway/main.go @@ -317,7 +317,7 @@ func main() { // Documentation routes are registered before CORS / rate-limit / timeout // middleware so the Swagger UI and raw spec are served without those // constraints. - registerDocRoutes(r) + routes.RegisterDocRoutes(r) r.Use(cors.New(cors.Config{ AllowOrigins: getAllowedOrigins(), @@ -360,7 +360,7 @@ func main() { // Rate limiting (if enabled) applies via the global r.Use above; random // 12-char receipt IDs (2^48 space) make /api/receipts/:id brute-force // enumeration impractical. - registerAPIRoutes(r) + routes.RegisterAPIRoutes(r) // Initialize receipt cleanup goroutine cleanupCtx, cleanupCancel := context.WithCancel(context.Background()) diff --git a/gateway/openapi.yaml b/gateway/openapi.yaml index cfddf75..193e958 100644 --- a/gateway/openapi.yaml +++ b/gateway/openapi.yaml @@ -489,3 +489,72 @@ components: response_hash: type: string example: sha256:... + + /api/models: + get: + summary: List available AI models for the configured provider + description: | + For `AI_PROVIDER=ollama`: queries the local Ollama daemon and returns all + locally installed models with size info and which one is currently active. + For `AI_PROVIDER=openrouter`: returns the currently configured model only. + tags: + - Models + responses: + '200': + description: Model list retrieved successfully + content: + application/json: + schema: + type: object + properties: + provider: + type: string + example: ollama + currentModel: + type: string + example: llama3:8b + models: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + provider: + type: string + sizeGB: + type: string + example: "4.7GB" + isActive: + type: boolean + '503': + description: Ollama daemon is not reachable + + /api/models/switch: + post: + summary: Switch the active AI model for the current session + description: | + Updates the in-process active model without restarting the gateway. + **This change is session-only** and does not persist across restarts. + Update `OLLAMA_MODEL` in `.env` to make the switch permanent. + tags: + - Models + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - modelId + properties: + modelId: + type: string + example: mistral:7b + responses: + '200': + description: Model switched successfully + '400': + description: modelId missing or model not installed in Ollama \ No newline at end of file diff --git a/gateway/routes.go b/gateway/routes.go index 8df9e94..b08ec1b 100644 --- a/gateway/routes.go +++ b/gateway/routes.go @@ -1,6 +1,8 @@ package main import ( + "gateway/routes" // Import the routes package for model handlers + "github.com/gin-gonic/gin" ) @@ -44,6 +46,11 @@ func registerAPIRoutes(r *gin.Engine) { r.GET("/healthz", handleHealthz) r.GET("/readyz", handleReadyz) + // ── Model routes ────────────────────────────────────────────────────────── + // These must be registered BEFORE the /api/ai group to avoid path conflicts + r.GET("/api/models", routes.GetAvailableModels) + r.POST("/api/models/switch", routes.SwitchModel) + aiGroup := r.Group("/api/ai") aiGroup.Use(RequestTimeoutMiddleware(getAITimeout())) if getCacheEnabled() { @@ -53,4 +60,4 @@ func registerAPIRoutes(r *gin.Engine) { } r.GET("/api/receipts/:id", handleGetReceipt) -} +} \ No newline at end of file diff --git a/gateway/routes/models.go b/gateway/routes/models.go new file mode 100644 index 0000000..11b85f6 --- /dev/null +++ b/gateway/routes/models.go @@ -0,0 +1,198 @@ +// gateway/routes/models.go +package routes + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +// ─── Ollama API types ───────────────────────────────────────────────────────── + +type OllamaModel struct { + Name string `json:"name"` + ModifiedAt time.Time `json:"modified_at"` + Size int64 `json:"size"` + Digest string `json:"digest"` +} + +type OllamaTagsResponse struct { + Models []OllamaModel `json:"models"` +} + +// ─── In-process model override (session-scoped, non-persistent) ─────────────── + +var ( + activeModelOverride string + modelMu sync.RWMutex +) + +// GetActiveOllamaModel returns the in-memory override if set, else falls back +// to the OLLAMA_MODEL env var. Call this anywhere you need the current model. +func GetActiveOllamaModel() string { + modelMu.RLock() + defer modelMu.RUnlock() + if activeModelOverride != "" { + return activeModelOverride + } + return os.Getenv("OLLAMA_MODEL") +} + +// ─── GET /api/models ────────────────────────────────────────────────────────── + +// GetAvailableModels lists AI models available for the configured provider. +// +// - ollama → queries the local Ollama daemon at OLLAMA_URL/api/tags +// - openrouter → returns the currently configured OPENROUTER_MODEL only +// (full catalogue would require an OpenRouter API call; out of scope here) +func GetAvailableModels(c *gin.Context) { + provider := os.Getenv("AI_PROVIDER") + + switch provider { + case "ollama": + handleOllamaModels(c) + case "openrouter": + handleOpenRouterModels(c) + default: + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Unknown AI_PROVIDER '%s'. Valid values: ollama, openrouter", provider), + "provider": provider, + "models": []gin.H{}, + }) + } +} + +func handleOllamaModels(c *gin.Context) { + ollamaURL := os.Getenv("OLLAMA_URL") + if ollamaURL == "" { + ollamaURL = "http://localhost:11434" + } + + // Use a short timeout — Ollama is local; if it doesn't respond in 3s it's down + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Get(fmt.Sprintf("%s/api/tags", ollamaURL)) + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "Cannot connect to Ollama. Is Ollama running? (expected at: " + ollamaURL + ")", + "provider": "ollama", + "hint": "Run `ollama serve` or check OLLAMA_URL in your .env", + }) + return + } + defer resp.Body.Close() + + var ollamaResp OllamaTagsResponse + if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to parse Ollama /api/tags response: " + err.Error(), + }) + return + } + + currentModel := GetActiveOllamaModel() + + models := make([]gin.H, 0, len(ollamaResp.Models)) + for _, m := range ollamaResp.Models { + models = append(models, gin.H{ + "id": m.Name, + "name": m.Name, + "provider": "ollama", + // Convert bytes → GB, keep one decimal place + "sizeGB": fmt.Sprintf("%.1fGB", float64(m.Size)/1e9), + "isActive": m.Name == currentModel, + }) + } + + c.JSON(http.StatusOK, gin.H{ + "provider": "ollama", + "currentModel": currentModel, + "models": models, + }) +} + +func handleOpenRouterModels(c *gin.Context) { + currentModel := os.Getenv("OPENROUTER_MODEL") + c.JSON(http.StatusOK, gin.H{ + "provider": "openrouter", + "currentModel": currentModel, + "models": []gin.H{ + { + "id": currentModel, + "name": currentModel, + "provider": "openrouter", + "isActive": true, + }, + }, + "note": "OpenRouter supports 300+ models. Update OPENROUTER_MODEL in .env to change the model.", + }) +} + +// ─── POST /api/models/switch ────────────────────────────────────────────────── + +type SwitchModelRequest struct { + ModelID string `json:"modelId" binding:"required"` +} + +// SwitchModel updates the active model for the current process lifetime. +// This does NOT persist across gateway restarts — users must update .env for +// permanent changes. Returns a confirmation with a clear session-only warning. +func SwitchModel(c *gin.Context) { + var req SwitchModelRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Request body must include a non-empty 'modelId' field", + }) + return + } + + // Validate: for ollama, ensure the model is actually installed + if os.Getenv("AI_PROVIDER") == "ollama" { + if err := validateOllamaModelExists(req.ModelID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("Model '%s' not found in Ollama. Run `ollama pull %s` first.", req.ModelID, req.ModelID), + }) + return + } + } + + modelMu.Lock() + activeModelOverride = req.ModelID + modelMu.Unlock() + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "activeModel": req.ModelID, + "note": "Model switched for this session only. To persist, update OLLAMA_MODEL in your .env and restart.", + }) +} + +// validateOllamaModelExists checks the model actually exists before switching. +func validateOllamaModelExists(modelID string) error { + ollamaURL := os.Getenv("OLLAMA_URL") + if ollamaURL == "" { + ollamaURL = "http://localhost:11434" + } + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Get(fmt.Sprintf("%s/api/tags", ollamaURL)) + if err != nil { + return err + } + defer resp.Body.Close() + + var ollamaResp OllamaTagsResponse + if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil { + return err + } + for _, m := range ollamaResp.Models { + if m.Name == modelID { + return nil + } + } + return fmt.Errorf("model not found") +} +// Issue #302 - Model management endpoints diff --git a/gateway/routes/models_test.go b/gateway/routes/models_test.go new file mode 100644 index 0000000..b498bc9 --- /dev/null +++ b/gateway/routes/models_test.go @@ -0,0 +1,152 @@ +// gateway/routes/models_test.go +package routes + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func setupRouter() *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.Default() + r.GET("/api/models", GetAvailableModels) + r.POST("/api/models/switch", SwitchModel) + return r +} + +// ─── GET /api/models ────────────────────────────────────────────────────────── + +func TestGetModels_OllamaNotRunning(t *testing.T) { + os.Setenv("AI_PROVIDER", "ollama") + os.Setenv("OLLAMA_URL", "http://localhost:19999") // port nobody listens on + + r := setupRouter() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d — body: %s", w.Code, w.Body.String()) + } + + var body map[string]any + json.Unmarshal(w.Body.Bytes(), &body) + if _, ok := body["error"]; !ok { + t.Error("expected 'error' field in response body") + } +} + +func TestGetModels_OpenRouter_ReturnsCurrent(t *testing.T) { + os.Setenv("AI_PROVIDER", "openrouter") + os.Setenv("OPENROUTER_MODEL", "z-ai/glm-4.5-air:free") + + r := setupRouter() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var body map[string]any + json.Unmarshal(w.Body.Bytes(), &body) + + if body["provider"] != "openrouter" { + t.Errorf("expected provider=openrouter, got %v", body["provider"]) + } + if body["currentModel"] != "z-ai/glm-4.5-air:free" { + t.Errorf("expected currentModel to match env, got %v", body["currentModel"]) + } +} + +func TestGetModels_UnknownProvider(t *testing.T) { + os.Setenv("AI_PROVIDER", "definitely-not-real") + + r := setupRouter() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +// ─── POST /api/models/switch ────────────────────────────────────────────────── + +func TestSwitchModel_MissingModelId(t *testing.T) { + os.Setenv("AI_PROVIDER", "openrouter") // skip ollama validation + + r := setupRouter() + req := httptest.NewRequest(http.MethodPost, "/api/models/switch", + strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for missing modelId, got %d", w.Code) + } +} + +func TestSwitchModel_OpenRouter_Success(t *testing.T) { + os.Setenv("AI_PROVIDER", "openrouter") + + r := setupRouter() + req := httptest.NewRequest(http.MethodPost, "/api/models/switch", + strings.NewReader(`{"modelId":"anthropic/claude-3-haiku"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d — %s", w.Code, w.Body.String()) + } + + var body map[string]any + json.Unmarshal(w.Body.Bytes(), &body) + if body["activeModel"] != "anthropic/claude-3-haiku" { + t.Errorf("expected activeModel in response, got %v", body["activeModel"]) + } + if body["success"] != true { + t.Error("expected success=true") + } +} + +func TestGetActiveOllamaModel_FallsBackToEnv(t *testing.T) { + // Reset override + modelMu.Lock() + activeModelOverride = "" + modelMu.Unlock() + + os.Setenv("OLLAMA_MODEL", "llama3:8b") + result := GetActiveOllamaModel() + if result != "llama3:8b" { + t.Errorf("expected llama3:8b, got %s", result) + } +} + +func TestGetActiveOllamaModel_OverrideWins(t *testing.T) { + os.Setenv("OLLAMA_MODEL", "llama3:8b") + + modelMu.Lock() + activeModelOverride = "mistral:7b" + modelMu.Unlock() + + result := GetActiveOllamaModel() + if result != "mistral:7b" { + t.Errorf("expected mistral:7b override, got %s", result) + } + + // Cleanup + modelMu.Lock() + activeModelOverride = "" + modelMu.Unlock() +} \ No newline at end of file diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index f90f2d6..26c99fd 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -4,6 +4,7 @@ import { SummarizeForm } from "@/components/summarize-form"; import { ReceiptHistory } from "@/components/receipt-history"; import { HowItWorks } from "@/components/how-it-works"; import { Footer } from "@/components/footer"; +import ModelSelector from "@/components/ModelSelector"; export default function Home() { return ( @@ -20,6 +21,21 @@ export default function Home() { titleRight="Pay once." caption="Paste any text. The gateway will challenge you with a 402, your wallet signs it once, and a summary comes back with a verifiable receipt." /> + + {/* ── Model Selector ────────────────────────────────────────────── */} +
+
+ + AI Model + + + + Switch the model used for summarization + +
+ +
+ @@ -72,4 +88,4 @@ function SectionHeader({

); -} +} \ No newline at end of file diff --git a/web/src/components/ModelSelector.tsx b/web/src/components/ModelSelector.tsx new file mode 100644 index 0000000..d5ad143 --- /dev/null +++ b/web/src/components/ModelSelector.tsx @@ -0,0 +1,121 @@ +// web/src/components/ModelSelector.tsx +"use client"; + +import { useEffect, useState, useCallback } from "react"; + +interface AvailableModel { + id: string; + name: string; + provider: string; + sizeGB?: string; + isActive: boolean; +} + +interface ModelsResponse { + provider: string; + currentModel: string; + models: AvailableModel[]; + note?: string; + error?: string; +} + +export default function ModelSelector() { + const [data, setData] = useState(null); + const [currentModel, setCurrentModel] = useState(""); + const [loading, setLoading] = useState(true); + const [switching, setSwitching] = useState(false); + const [error, setError] = useState(null); + + const gatewayURL = process.env.NEXT_PUBLIC_GATEWAY_URL ?? "http://localhost:8080"; + + const fetchModels = useCallback(async () => { + try { + const res = await fetch(`${gatewayURL}/api/models`); + const json: ModelsResponse = await res.json(); + setData(json); + setCurrentModel(json.currentModel ?? ""); + setError(json.error ?? null); + } catch { + setError("Cannot reach gateway. Is it running?"); + } finally { + setLoading(false); + } + }, [gatewayURL]); + + useEffect(() => { + fetchModels(); + }, [fetchModels]); + + const handleSwitch = async (modelId: string) => { + if (modelId === currentModel) return; + setSwitching(true); + try { + const res = await fetch(`${gatewayURL}/api/models/switch`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ modelId }), + }); + if (res.ok) { + setCurrentModel(modelId); + } else { + const err = await res.json(); + setError(err.error ?? "Failed to switch model"); + } + } catch { + setError("Network error when switching model"); + } finally { + setSwitching(false); + } + }; + + // Don't render anything while loading or if only one model (nothing to choose) + if (loading) return null; + if (!data || (data.models?.length ?? 0) <= 1) return null; + + return ( +
+
+ + + + + {switching && ( + + Switching… + + )} +
+ + {error && ( +

{error}

+ )} + +

+ Session only — update OLLAMA_MODEL in .env to persist +

+
+ ); +} \ No newline at end of file