Compare commits
6 Commits
c009d401fb
...
37949e560b
| Author | SHA1 | Date | |
|---|---|---|---|
| 37949e560b | |||
| f04cb6b8f2 | |||
| 10262c0e5a | |||
| d345f8c41d | |||
| d1f7a57f58 | |||
| dc9af4d79c |
@@ -122,6 +122,16 @@ func (db *DB) RunMigrations() error {
|
|||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_used_at DATETIME,
|
last_used_at DATETIME,
|
||||||
FOREIGN KEY (client_id) REFERENCES clients(client_id) ON DELETE CASCADE
|
FOREIGN KEY (client_id) REFERENCES clients(client_id) ON DELETE CASCADE
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS model_groups (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
strategy TEXT NOT NULL DEFAULT 'heuristic',
|
||||||
|
selector_model TEXT,
|
||||||
|
targets TEXT NOT NULL DEFAULT '[]',
|
||||||
|
complexity_threshold INTEGER,
|
||||||
|
heuristic_rules TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)`,
|
)`,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +187,19 @@ func (db *DB) RunMigrations() error {
|
|||||||
return fmt.Errorf("failed to insert default client: %w", err)
|
return fmt.Errorf("failed to insert default client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seed default model groups
|
||||||
|
defaultGroups := []struct {
|
||||||
|
id, strategy, targets string
|
||||||
|
}{
|
||||||
|
{"deepseek-auto", "heuristic", `["deepseek-chat","deepseek-reasoner"]`},
|
||||||
|
{"openai-auto", "heuristic", `["gpt-4o-mini","gpt-4o"]`},
|
||||||
|
{"gemini-auto", "heuristic", `["gemini-2.0-flash","gemini-2.5-pro"]`},
|
||||||
|
}
|
||||||
|
for _, g := range defaultGroups {
|
||||||
|
db.Exec(`INSERT OR IGNORE INTO model_groups (id, strategy, targets) VALUES (?, ?, ?)`,
|
||||||
|
g.id, g.strategy, g.targets)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,3 +285,14 @@ type ClientToken struct {
|
|||||||
CreatedAt time.Time `db:"created_at"`
|
CreatedAt time.Time `db:"created_at"`
|
||||||
LastUsedAt *time.Time `db:"last_used_at"`
|
LastUsedAt *time.Time `db:"last_used_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ModelGroup struct {
|
||||||
|
ID string `db:"id" json:"id"`
|
||||||
|
Strategy string `db:"strategy" json:"strategy"`
|
||||||
|
SelectorModel *string `db:"selector_model" json:"selector_model"`
|
||||||
|
Targets string `db:"targets" json:"targets"` // JSON array
|
||||||
|
ComplexityThreshold *int `db:"complexity_threshold" json:"complexity_threshold"`
|
||||||
|
HeuristicRules *string `db:"heuristic_rules" json:"heuristic_rules"`
|
||||||
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gophergate/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
const classifierSystemPrompt = `You are a task complexity classifier. Rate the following user message on a scale of 1 to %d, where:
|
||||||
|
1 = trivial/simple (basic facts, greetings, simple math)
|
||||||
|
%d = highly complex (multi-step reasoning, code generation, architecture design)
|
||||||
|
|
||||||
|
Reply with ONLY the number. No explanation.`
|
||||||
|
|
||||||
|
func routeClassifier(ctx context.Context, classify ClassifierFunc, group db.ModelGroup, targets []string, userMessage string) (*Decision, error) {
|
||||||
|
maxRating := len(targets)
|
||||||
|
if maxRating < 2 {
|
||||||
|
maxRating = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := fmt.Sprintf(classifierSystemPrompt, maxRating, maxRating)
|
||||||
|
ratingStr, err := classify(ctx, getSelectorModel(group, targets), prompt, userMessage)
|
||||||
|
if err != nil {
|
||||||
|
// Classifier failed — fall back to heuristic
|
||||||
|
return routeHeuristic(group, targets, userMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
rating, err := strconv.Atoi(strings.TrimSpace(ratingStr))
|
||||||
|
if err != nil || rating < 1 {
|
||||||
|
rating = 1
|
||||||
|
}
|
||||||
|
if rating > maxRating {
|
||||||
|
rating = maxRating
|
||||||
|
}
|
||||||
|
|
||||||
|
idx := rating - 1 // 0-based index into targets
|
||||||
|
return &Decision{
|
||||||
|
SelectedModel: targets[idx],
|
||||||
|
Strategy: "classifier",
|
||||||
|
Reason: fmt.Sprintf("complexity rating: %d/%d", rating, maxRating),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSelectorModel(group db.ModelGroup, targets []string) string {
|
||||||
|
if group.SelectorModel != nil && *group.SelectorModel != "" {
|
||||||
|
return *group.SelectorModel
|
||||||
|
}
|
||||||
|
// Default: use the first (cheapest) target model as the selector
|
||||||
|
return targets[0]
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gophergate/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HeuristicRule defines a pattern-based routing rule.
|
||||||
|
type HeuristicRule struct {
|
||||||
|
Pattern string `json:"pattern"`
|
||||||
|
TargetIdx int `json:"target"`
|
||||||
|
CaseSensitive bool `json:"case_sensitive,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func routeHeuristic(group db.ModelGroup, targets []string, userMessage string) (*Decision, error) {
|
||||||
|
selected := targets[0]
|
||||||
|
reason := "default (first target)"
|
||||||
|
|
||||||
|
// If heuristic_rules is set, use them
|
||||||
|
if group.HeuristicRules != nil && *group.HeuristicRules != "" {
|
||||||
|
var rules []HeuristicRule
|
||||||
|
if err := json.Unmarshal([]byte(*group.HeuristicRules), &rules); err == nil {
|
||||||
|
searchMsg := userMessage
|
||||||
|
for _, rule := range rules {
|
||||||
|
pattern := rule.Pattern
|
||||||
|
msg := searchMsg
|
||||||
|
if !rule.CaseSensitive {
|
||||||
|
pattern = strings.ToLower(pattern)
|
||||||
|
msg = strings.ToLower(msg)
|
||||||
|
}
|
||||||
|
if strings.Contains(msg, pattern) {
|
||||||
|
if rule.TargetIdx >= 0 && rule.TargetIdx < len(targets) {
|
||||||
|
selected = targets[rule.TargetIdx]
|
||||||
|
reason = "matched heuristic rule: " + rule.Pattern
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-in fallback heuristics
|
||||||
|
if reason == "default (first target)" && len(targets) > 1 {
|
||||||
|
msgLower := strings.ToLower(userMessage)
|
||||||
|
complexIndicators := []string{
|
||||||
|
"step by step", "explain in detail", "reason through",
|
||||||
|
"think carefully", "analyze", "debug", "write code",
|
||||||
|
"implement", "refactor", "architecture",
|
||||||
|
}
|
||||||
|
for _, indicator := range complexIndicators {
|
||||||
|
if strings.Contains(msgLower, indicator) {
|
||||||
|
selected = targets[len(targets)-1]
|
||||||
|
reason = "complex task indicator: " + indicator
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Decision{
|
||||||
|
SelectedModel: selected,
|
||||||
|
Strategy: "heuristic",
|
||||||
|
Reason: reason,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gophergate/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Decision holds the result of a routing decision.
|
||||||
|
type Decision struct {
|
||||||
|
SelectedModel string `json:"selected_model"`
|
||||||
|
Strategy string `json:"strategy"` // "heuristic" or "classifier"
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClassifierFunc is the callback for classifier-based routing.
|
||||||
|
type ClassifierFunc func(ctx context.Context, selectorModel, systemPrompt, userMessage string) (string, error)
|
||||||
|
|
||||||
|
// Router resolves model groups to concrete models.
|
||||||
|
type Router struct {
|
||||||
|
groups map[string]db.ModelGroup
|
||||||
|
classify ClassifierFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a Router. classify may be nil if no classifier groups exist.
|
||||||
|
func New(groups []db.ModelGroup, classify ClassifierFunc) *Router {
|
||||||
|
r := &Router{
|
||||||
|
groups: make(map[string]db.ModelGroup),
|
||||||
|
classify: classify,
|
||||||
|
}
|
||||||
|
for _, g := range groups {
|
||||||
|
r.groups[g.ID] = g
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsGroup returns true if the model name is a group ID.
|
||||||
|
func (r *Router) IsGroup(modelID string) bool {
|
||||||
|
_, ok := r.groups[modelID]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route resolves a group to a concrete model.
|
||||||
|
func (r *Router) Route(ctx context.Context, groupID string, userMessage string) (*Decision, error) {
|
||||||
|
group, ok := r.groups[groupID]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown model group: %s", groupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var targets []string
|
||||||
|
if err := json.Unmarshal([]byte(group.Targets), &targets); err != nil || len(targets) == 0 {
|
||||||
|
return nil, fmt.Errorf("invalid or empty targets for group %s", groupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch group.Strategy {
|
||||||
|
case "heuristic":
|
||||||
|
return routeHeuristic(group, targets, userMessage)
|
||||||
|
case "classifier":
|
||||||
|
if r.classify == nil {
|
||||||
|
return routeHeuristic(group, targets, userMessage)
|
||||||
|
}
|
||||||
|
return routeClassifier(ctx, r.classify, group, targets, userMessage)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown strategy: %s", group.Strategy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload replaces the group definitions without recreating the router.
|
||||||
|
func (r *Router) Reload(groups []db.ModelGroup) {
|
||||||
|
r.groups = make(map[string]db.ModelGroup)
|
||||||
|
for _, g := range groups {
|
||||||
|
r.groups[g.ID] = g
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"gophergate/internal/db"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleGetModelGroups(c *gin.Context) {
|
||||||
|
var groups []db.ModelGroup
|
||||||
|
if err := s.database.Select(&groups, "SELECT * FROM model_groups ORDER BY id"); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if groups == nil {
|
||||||
|
groups = []db.ModelGroup{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, groups)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreateModelGroup(c *gin.Context) {
|
||||||
|
var group db.ModelGroup
|
||||||
|
if err := c.ShouldBindJSON(&group); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.database.Exec(`
|
||||||
|
INSERT INTO model_groups (id, strategy, selector_model, targets, complexity_threshold, heuristic_rules)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
group.ID, group.Strategy, group.SelectorModel, group.Targets,
|
||||||
|
group.ComplexityThreshold, group.HeuristicRules)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.refreshRouter()
|
||||||
|
c.JSON(http.StatusCreated, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdateModelGroup(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var group db.ModelGroup
|
||||||
|
if err := c.ShouldBindJSON(&group); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.database.Exec(`
|
||||||
|
UPDATE model_groups SET strategy=?, selector_model=?, targets=?, complexity_threshold=?, heuristic_rules=?, updated_at=CURRENT_TIMESTAMP
|
||||||
|
WHERE id=?`,
|
||||||
|
group.Strategy, group.SelectorModel, group.Targets,
|
||||||
|
group.ComplexityThreshold, group.HeuristicRules, id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.refreshRouter()
|
||||||
|
c.JSON(http.StatusOK, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteModelGroup(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
_, err := s.database.Exec("DELETE FROM model_groups WHERE id=?", id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.refreshRouter()
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
"gophergate/internal/middleware"
|
"gophergate/internal/middleware"
|
||||||
"gophergate/internal/models"
|
"gophergate/internal/models"
|
||||||
"gophergate/internal/providers"
|
"gophergate/internal/providers"
|
||||||
|
"gophergate/internal/router"
|
||||||
"gophergate/internal/utils"
|
"gophergate/internal/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -30,6 +32,7 @@ type Server struct {
|
|||||||
logger *RequestLogger
|
logger *RequestLogger
|
||||||
registry *models.ModelRegistry
|
registry *models.ModelRegistry
|
||||||
registryMu sync.RWMutex
|
registryMu sync.RWMutex
|
||||||
|
modelRouter *router.Router
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(cfg *config.Config, database *db.DB) *Server {
|
func NewServer(cfg *config.Config, database *db.DB) *Server {
|
||||||
@@ -64,6 +67,9 @@ func NewServer(cfg *config.Config, database *db.DB) *Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.setupRoutes()
|
s.setupRoutes()
|
||||||
|
|
||||||
|
// Initialize model group router
|
||||||
|
s.refreshRouter()
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,9 +174,51 @@ func (s *Server) RefreshProviders() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.refreshRouter()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) refreshRouter() {
|
||||||
|
var groups []db.ModelGroup
|
||||||
|
if err := s.database.Select(&groups, "SELECT * FROM model_groups"); err != nil {
|
||||||
|
fmt.Printf("Warning: Failed to load model groups: %v\n", err)
|
||||||
|
groups = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var classifyFn router.ClassifierFunc
|
||||||
|
if openaiProvider, ok := s.providers["openai"]; ok {
|
||||||
|
classifyFn = func(ctx context.Context, selectorModel, systemPrompt, userMessage string) (string, error) {
|
||||||
|
req := &models.UnifiedRequest{
|
||||||
|
Model: selectorModel,
|
||||||
|
Messages: []models.UnifiedMessage{
|
||||||
|
{Role: "system", Content: []models.UnifiedContentPart{{Type: "text", Text: systemPrompt}}},
|
||||||
|
{Role: "user", Content: []models.UnifiedContentPart{{Type: "text", Text: userMessage}}},
|
||||||
|
},
|
||||||
|
MaxTokens: uint32Ptr(5),
|
||||||
|
Stream: false,
|
||||||
|
}
|
||||||
|
resp, err := openaiProvider.ChatCompletion(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(resp.Choices) == 0 {
|
||||||
|
return "", fmt.Errorf("no choices in classifier response")
|
||||||
|
}
|
||||||
|
content, ok := resp.Choices[0].Message.Content.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("classifier response content is not a string")
|
||||||
|
}
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.modelRouter == nil {
|
||||||
|
s.modelRouter = router.New(groups, classifyFn)
|
||||||
|
} else {
|
||||||
|
s.modelRouter.Reload(groups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) setupRoutes() {
|
func (s *Server) setupRoutes() {
|
||||||
// Static files
|
// Static files
|
||||||
s.router.StaticFile("/", "./static/index.html")
|
s.router.StaticFile("/", "./static/index.html")
|
||||||
@@ -228,6 +276,11 @@ func (s *Server) setupRoutes() {
|
|||||||
admin.GET("/models", s.handleGetModels)
|
admin.GET("/models", s.handleGetModels)
|
||||||
admin.PUT("/models/:id", s.handleUpdateModel)
|
admin.PUT("/models/:id", s.handleUpdateModel)
|
||||||
|
|
||||||
|
admin.GET("/model-groups", s.handleGetModelGroups)
|
||||||
|
admin.POST("/model-groups", s.handleCreateModelGroup)
|
||||||
|
admin.PUT("/model-groups/:id", s.handleUpdateModelGroup)
|
||||||
|
admin.DELETE("/model-groups/:id", s.handleDeleteModelGroup)
|
||||||
|
|
||||||
admin.GET("/users", s.handleGetUsers)
|
admin.GET("/users", s.handleGetUsers)
|
||||||
admin.POST("/users", s.handleCreateUser)
|
admin.POST("/users", s.handleCreateUser)
|
||||||
admin.PUT("/users/:id", s.handleUpdateUser)
|
admin.PUT("/users/:id", s.handleUpdateUser)
|
||||||
@@ -474,6 +527,18 @@ func (s *Server) handleChatCompletions(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if model is a group and route to a concrete model
|
||||||
|
if s.modelRouter != nil && s.modelRouter.IsGroup(modelID) {
|
||||||
|
userMessage := extractUserMessage(req.Messages)
|
||||||
|
decision, err := s.modelRouter.Route(c.Request.Context(), modelID, userMessage)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("model routing failed: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modelID = decision.SelectedModel
|
||||||
|
log.Printf("[ROUTER] %s -> %s (%s: %s)", req.Model, modelID, decision.Strategy, decision.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
// Convert ChatCompletionRequest to UnifiedRequest
|
// Convert ChatCompletionRequest to UnifiedRequest
|
||||||
unifiedReq := &models.UnifiedRequest{
|
unifiedReq := &models.UnifiedRequest{
|
||||||
Model: modelID,
|
Model: modelID,
|
||||||
@@ -633,6 +698,20 @@ if unifiedReq.MaxTokens == nil {
|
|||||||
c.JSON(http.StatusOK, resp)
|
c.JSON(http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractUserMessage(messages []models.ChatMessage) string {
|
||||||
|
for i := len(messages) - 1; i >= 0; i-- {
|
||||||
|
if messages[i].Role == "user" {
|
||||||
|
switch c := messages[i].Content.(type) {
|
||||||
|
case string:
|
||||||
|
return c
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleImageGenerations(c *gin.Context) {
|
func (s *Server) handleImageGenerations(c *gin.Context) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
var req models.ImageGenerationRequest
|
var req models.ImageGenerationRequest
|
||||||
@@ -799,3 +878,5 @@ func (s *Server) Run() error {
|
|||||||
addr := fmt.Sprintf("%s:%d", s.cfg.Server.Host, s.cfg.Server.Port)
|
addr := fmt.Sprintf("%s:%d", s.cfg.Server.Host, s.cfg.Server.Port)
|
||||||
return s.router.Run(addr)
|
return s.router.Run(addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func uint32Ptr(v uint32) *uint32 { return &v }
|
||||||
|
|||||||
+6
-1
@@ -89,6 +89,10 @@
|
|||||||
<i class="fas fa-brain"></i>
|
<i class="fas fa-brain"></i>
|
||||||
<span>Models</span>
|
<span>Models</span>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="menu-item" data-page="model-groups">
|
||||||
|
<i class="fas fa-code-branch"></i>
|
||||||
|
<span>Model Groups</span>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -164,7 +168,7 @@
|
|||||||
<script src="/js/auth.js?v=7"></script>
|
<script src="/js/auth.js?v=7"></script>
|
||||||
<script src="/js/charts.js?v=7"></script>
|
<script src="/js/charts.js?v=7"></script>
|
||||||
<script src="/js/websocket.js?v=7"></script>
|
<script src="/js/websocket.js?v=7"></script>
|
||||||
<script src="/js/dashboard.js?v=7"></script>
|
<script src="/js/dashboard.js?v=8"></script>
|
||||||
|
|
||||||
<!-- Page Modules -->
|
<!-- Page Modules -->
|
||||||
<script src="/js/pages/overview.js?v=7"></script>
|
<script src="/js/pages/overview.js?v=7"></script>
|
||||||
@@ -177,5 +181,6 @@
|
|||||||
<script src="/js/pages/settings.js?v=7"></script>
|
<script src="/js/pages/settings.js?v=7"></script>
|
||||||
<script src="/js/pages/logs.js?v=7"></script>
|
<script src="/js/pages/logs.js?v=7"></script>
|
||||||
<script src="/js/pages/users.js?v=7"></script>
|
<script src="/js/pages/users.js?v=7"></script>
|
||||||
|
<script src="/js/pages/model_groups.js?v=8"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ class Dashboard {
|
|||||||
'settings': 'Settings',
|
'settings': 'Settings',
|
||||||
'logs': 'Logs',
|
'logs': 'Logs',
|
||||||
'models': 'Models',
|
'models': 'Models',
|
||||||
|
'model-groups': 'Model Groups',
|
||||||
'users': 'User Management'
|
'users': 'User Management'
|
||||||
};
|
};
|
||||||
if (titleElement) titleElement.textContent = titles[page] || 'Dashboard';
|
if (titleElement) titleElement.textContent = titles[page] || 'Dashboard';
|
||||||
@@ -130,6 +131,11 @@ class Dashboard {
|
|||||||
if (content) {
|
if (content) {
|
||||||
content.innerHTML = await this.getPageTemplate(page);
|
content.innerHTML = await this.getPageTemplate(page);
|
||||||
await this.initializePageScript(page);
|
await this.initializePageScript(page);
|
||||||
|
|
||||||
|
// Model Groups page uses its own render method
|
||||||
|
if (page === 'model-groups' && typeof modelGroupsPage !== 'undefined') {
|
||||||
|
await modelGroupsPage.render();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error loading page ${page}:`, error);
|
console.error(`Error loading page ${page}:`, error);
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// Model Groups Management Page
|
||||||
|
|
||||||
|
class ModelGroupsPage {
|
||||||
|
constructor() {
|
||||||
|
this.container = document.getElementById('page-content');
|
||||||
|
}
|
||||||
|
|
||||||
|
async render() {
|
||||||
|
this.container.innerHTML = `
|
||||||
|
<div class="page-header">
|
||||||
|
<h3>Model Groups</h3>
|
||||||
|
<p class="text-muted">Define auto-routing groups that pick the best model for each request.</p>
|
||||||
|
<button class="btn btn-primary" onclick="modelGroupsPage.showCreateForm()">
|
||||||
|
<i class="fas fa-plus"></i> Add Group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="model-groups-list" class="table-container"></div>
|
||||||
|
<div id="model-group-form" class="form-container" style="display:none;"></div>
|
||||||
|
`;
|
||||||
|
await this.loadGroups();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadGroups() {
|
||||||
|
try {
|
||||||
|
const groups = await api.get('/api/model-groups');
|
||||||
|
const list = document.getElementById('model-groups-list');
|
||||||
|
if (!groups || groups.length === 0) {
|
||||||
|
list.innerHTML = '<div class="empty-state">No model groups defined. Create one to enable auto-routing.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<table class="data-table"><thead><tr>';
|
||||||
|
html += '<th>Group ID</th><th>Strategy</th><th>Targets</th><th>Actions</th>';
|
||||||
|
html += '</tr></thead><tbody>';
|
||||||
|
|
||||||
|
groups.forEach(g => {
|
||||||
|
html += '<tr>';
|
||||||
|
html += '<td><code>' + this.esc(g.id) + '</code></td>';
|
||||||
|
html += '<td><span class="badge">' + this.esc(g.strategy) + '</span></td>';
|
||||||
|
html += '<td><code>' + this.esc(g.targets) + '</code></td>';
|
||||||
|
html += '<td>';
|
||||||
|
html += '<button class="btn btn-sm" onclick="modelGroupsPage.showEditForm(\'' + this.esc(g.id) + '\')">Edit</button> ';
|
||||||
|
html += '<button class="btn btn-sm btn-danger" onclick="modelGroupsPage.deleteGroup(\'' + this.esc(g.id) + '\')">Delete</button>';
|
||||||
|
html += '</td></tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table>';
|
||||||
|
list.innerHTML = html;
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('model-groups-list').innerHTML =
|
||||||
|
'<div class="error-message">Failed to load model groups: ' + this.esc(err.message) + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showCreateForm() {
|
||||||
|
this.renderForm(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async showEditForm(id) {
|
||||||
|
try {
|
||||||
|
const groups = await api.get('/api/model-groups');
|
||||||
|
const group = groups.find(g => g.id === id);
|
||||||
|
if (group) this.renderForm(group);
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to load group: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderForm(group) {
|
||||||
|
const isEdit = !!group;
|
||||||
|
const form = document.getElementById('model-group-form');
|
||||||
|
form.style.display = 'block';
|
||||||
|
form.innerHTML = `
|
||||||
|
<h4>${isEdit ? 'Edit' : 'Create'} Model Group</h4>
|
||||||
|
<form onsubmit="modelGroupsPage.saveGroup(event, ${isEdit})">
|
||||||
|
<div class="form-control">
|
||||||
|
<label>Group ID</label>
|
||||||
|
<input type="text" id="mg-id" value="${this.esc(group ? group.id : '')}" ${isEdit ? 'readonly' : 'required'}
|
||||||
|
placeholder="e.g. deepseek-auto">
|
||||||
|
<small>Clients use this as the model name.</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label>Strategy</label>
|
||||||
|
<select id="mg-strategy">
|
||||||
|
<option value="heuristic" ${group && group.strategy === 'heuristic' ? 'selected' : ''}>Heuristic (rules-based)</option>
|
||||||
|
<option value="classifier" ${group && group.strategy === 'classifier' ? 'selected' : ''}>Classifier (LLM judge)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label>Targets (JSON array)</label>
|
||||||
|
<input type="text" id="mg-targets" value='${this.esc(group ? group.targets : '["cheap-model","smart-model"]')}' required>
|
||||||
|
<small>First target = cheapest/fastest. Last target = smartest/most expensive.</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-control" id="mg-selector-row" style="${group && group.strategy === 'classifier' ? '' : 'display:none'}">
|
||||||
|
<label>Selector Model</label>
|
||||||
|
<input type="text" id="mg-selector-model" value="${this.esc(group && group.selector_model ? group.selector_model : 'gpt-4o-mini')}"
|
||||||
|
placeholder="Model used to judge task complexity">
|
||||||
|
</div>
|
||||||
|
<div class="form-control" id="mg-threshold-row" style="${group && group.strategy === 'classifier' ? '' : 'display:none'}">
|
||||||
|
<label>Complexity Threshold</label>
|
||||||
|
<input type="number" id="mg-threshold" value="${group && group.complexity_threshold ? group.complexity_threshold : ''}" min="1"
|
||||||
|
placeholder="Tasks rated >= this go to the smart model">
|
||||||
|
</div>
|
||||||
|
<div class="form-control" id="mg-rules-row" style="${group && group.strategy === 'heuristic' ? '' : 'display:none'}">
|
||||||
|
<label>Heuristic Rules (JSON array)</label>
|
||||||
|
<textarea id="mg-rules" rows="4" placeholder='[{"pattern":"step by step","target":1}]'>${group && group.heuristic_rules ? group.heuristic_rules : ''}</textarea>
|
||||||
|
<small>Pattern to match in user messages. target = index into targets array.</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<button type="button" class="btn" onclick="document.getElementById('model-group-form').style.display='none'">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('mg-strategy').onchange = function() {
|
||||||
|
var isClassifier = this.value === 'classifier';
|
||||||
|
document.getElementById('mg-selector-row').style.display = isClassifier ? '' : 'none';
|
||||||
|
document.getElementById('mg-threshold-row').style.display = isClassifier ? '' : 'none';
|
||||||
|
document.getElementById('mg-rules-row').style.display = isClassifier ? 'none' : '';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveGroup(event, isEdit) {
|
||||||
|
event.preventDefault();
|
||||||
|
var id = document.getElementById('mg-id').value.trim();
|
||||||
|
var strategy = document.getElementById('mg-strategy').value;
|
||||||
|
var targets = document.getElementById('mg-targets').value;
|
||||||
|
var selectorModel = document.getElementById('mg-selector-model').value.trim() || null;
|
||||||
|
var thresholdVal = document.getElementById('mg-threshold').value;
|
||||||
|
var rules = document.getElementById('mg-rules').value.trim() || null;
|
||||||
|
|
||||||
|
try { JSON.parse(targets); } catch (e) { alert('Targets must be valid JSON array'); return; }
|
||||||
|
if (rules) { try { JSON.parse(rules); } catch (e) { alert('Heuristic rules must be valid JSON'); return; } }
|
||||||
|
|
||||||
|
var body = { id: id, strategy: strategy, targets: targets, selector_model: selectorModel, heuristic_rules: rules };
|
||||||
|
if (thresholdVal) body.complexity_threshold = parseInt(thresholdVal);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEdit) {
|
||||||
|
await api.put('/api/model-groups/' + encodeURIComponent(id), body);
|
||||||
|
} else {
|
||||||
|
await api.post('/api/model-groups', body);
|
||||||
|
}
|
||||||
|
document.getElementById('model-group-form').style.display = 'none';
|
||||||
|
await this.loadGroups();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to save: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteGroup(id) {
|
||||||
|
if (!confirm('Delete model group "' + id + '"? This cannot be undone.')) return;
|
||||||
|
try {
|
||||||
|
await api.delete('/api/model-groups/' + encodeURIComponent(id));
|
||||||
|
await this.loadGroups();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to delete: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
esc(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var modelGroupsPage = new ModelGroupsPage();
|
||||||
Reference in New Issue
Block a user