Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,7 @@ func main() {
misc.StartAntigravityVersionUpdater(context.Background())
if !localModel {
registry.StartModelsUpdater(context.Background())
registry.StartOpenRouterEnrichment(context.Background())
}
hook := tui.NewLogHook(2000)
hook.SetFormatter(&logging.LogFormatter{})
Expand Down Expand Up @@ -581,6 +582,7 @@ func main() {
misc.StartAntigravityVersionUpdater(context.Background())
if !localModel {
registry.StartModelsUpdater(context.Background())
registry.StartOpenRouterEnrichment(context.Background())
}
cmd.StartService(cfg, configFilePath, password)
}
Expand Down
91 changes: 91 additions & 0 deletions internal/api/handlers/management/model_definitions.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package management
import (
"net/http"
"strings"
"time"

"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
Expand Down Expand Up @@ -31,3 +32,93 @@ func (h *Handler) GetStaticModelDefinitions(c *gin.Context) {
"models": models,
})
}

// GetModelsHealth returns comprehensive health information for all registered models.
func (h *Handler) GetModelsHealth(c *gin.Context) {
globalRegistry := registry.GetGlobalRegistry()

// Get ALL registered models including suspended/unhealthy for full operator visibility
models := globalRegistry.GetAllRegisteredModels("openai")
// Build enhanced model health with suspension and provider info
enhancedModels := make([]map[string]any, 0, len(models))
for _, model := range models {
modelID, _ := model["id"].(string)
if modelID == "" {
continue
}
// Get health details for this model
providers, suspendedClients, count := globalRegistry.GetModelHealthDetails(modelID)
// Build suspension summary
suspensionSummary := make(map[string]any)
if len(suspendedClients) > 0 {
suspensionSummary["count"] = len(suspendedClients)
suspensionSummary["clients"] = suspendedClients
// Extract unique reasons
reasons := make(map[string]bool)
for _, reason := range suspendedClients {
if reason == "" {
reasons["unknown"] = true
} else {
reasons[strings.ToLower(reason)] = true
}
}
reasonList := make([]string, 0, len(reasons))
for reason := range reasons {
reasonList = append(reasonList, reason)
}
suspensionSummary["reasons"] = reasonList
} else {
suspensionSummary["count"] = 0
suspensionSummary["clients"] = map[string]string{}
suspensionSummary["reasons"] = []string{}
}

// Build provider list
providerList := make([]string, 0, len(providers))
for provider := range providers {
providerList = append(providerList, provider)
}
// Create enhanced model entry
enhancedModel := make(map[string]any)
for k, v := range model {
enhancedModel[k] = v
}
enhancedModel["total_clients"] = count
enhancedModel["providers"] = providerList
enhancedModel["provider_counts"] = providers
enhancedModel["suspension"] = suspensionSummary
enhancedModels = append(enhancedModels, enhancedModel)
}
// Build the sources map indicating where context_length came from
sources := registry.BuildModelSources(globalRegistry)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 Badge Define newly referenced OpenRouter registry functions

This change introduces calls to registry.BuildModelSources, registry.GetOpenRouterLastRefresh, registry.TriggerOpenRouterRefresh, and registry.StartOpenRouterEnrichment, but no corresponding definitions are added in the internal/registry package. That leaves these references unresolved at build time, so server/management packages cannot compile until the symbols are implemented (or the calls are removed).

Useful? React with 👍 / 👎.

// Get last refresh timestamp from OpenRouter enrichment
lastRefresh := registry.GetOpenRouterLastRefresh()
lastRefreshStr := ""
if !lastRefresh.IsZero() {
lastRefreshStr = lastRefresh.Format(time.RFC3339)
}
response := gin.H{
"models": enhancedModels,
"sources": sources,
"last_refresh": lastRefreshStr,
"refresh_interval": "24h",
}
c.JSON(http.StatusOK, response)
}

// RefreshModels triggers an immediate refresh of model metadata from OpenRouter and the enrichment cache.
// This re-fetches context_length from OpenRouter's public /api/v1/models endpoint and enriches registered models that lack this data.
func (h *Handler) RefreshModels(c *gin.Context) {
count := registry.TriggerOpenRouterRefresh(c.Request.Context())
if count == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no models registered"})
return
}
lastRefresh := registry.GetOpenRouterLastRefresh()
c.JSON(http.StatusOK, gin.H{
"status": "refreshed",
"enriched_count": count,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return true enriched count in refresh response

RefreshModels publishes enriched_count from TriggerOpenRouterRefresh, but that function returns the size of the OpenRouter cache (len(contextLength)), not how many registered models were enriched in this refresh. This can report misleadingly large counts (or wrong error semantics when cache is empty due fetch failure), so operators cannot trust the endpoint output.

Useful? React with 👍 / 👎.

"last_refresh": lastRefresh.Format(time.RFC3339),
"total_models": len(registry.GetGlobalRegistry().GetAvailableModels("openai")),
})
}
6 changes: 4 additions & 2 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,8 @@ func (s *Server) registerManagementRoutes() {
mgmt.GET("/auth-files", s.mgmt.ListAuthFiles)
mgmt.GET("/auth-files/models", s.mgmt.GetAuthFileModels)
mgmt.GET("/model-definitions/:channel", s.mgmt.GetStaticModelDefinitions)
mgmt.GET("/models/health", s.mgmt.GetModelsHealth)
mgmt.POST("/models/refresh", s.mgmt.RefreshModels)
mgmt.GET("/auth-files/download", s.mgmt.DownloadAuthFile)
mgmt.POST("/auth-files", s.mgmt.UploadAuthFile)
mgmt.DELETE("/auth-files", s.mgmt.DeleteAuthFile)
Expand Down Expand Up @@ -773,8 +775,8 @@ func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, cl
return func(c *gin.Context) {
userAgent := c.GetHeader("User-Agent")

// Route to Claude handler if User-Agent starts with "claude-cli"
if strings.HasPrefix(userAgent, "claude-cli") {
// Route to Claude handler if User-Agent starts with "claude-cli" or "claude-code"
if strings.HasPrefix(userAgent, "claude-cli") || strings.HasPrefix(userAgent, "claude-code") || strings.HasPrefix(userAgent, "claude/") {
// log.Debugf("Routing /v1/models to Claude handler for User-Agent: %s", userAgent)
claudeHandler.ClaudeModels(c)
} else {
Expand Down
11 changes: 8 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ import (
"syscall"

"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/bcrypt"
"gopkg.in/yaml.v3"
temporal "github.com/router-for-me/CLIProxyAPI/v6/internal/temporal"
log "github.com/sirupsen/logrus"
"golang.org/x/crypto/bcrypt"
"gopkg.in/yaml.v3"
)

const (
Expand Down Expand Up @@ -126,6 +127,10 @@ type Config struct {
// Payload defines default and override rules for provider payload parameters.
Payload PayloadConfig `yaml:"payload" json:"payload"`

// Temporal controls temporal anti-drift injection into LLM request payloads.
// nil means use defaults (enabled, every request).
Temporal *temporal.Config `yaml:"temporal" json:"temporal"`

legacyMigrationPending bool `yaml:"-" json:"-"`
}

Expand Down
74 changes: 74 additions & 0 deletions internal/registry/model_registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,14 @@ func (r *ModelRegistry) RegisterClient(clientID, clientProvider string, models [
if model == nil || model.ID == "" {
continue
}
if staticInfo := LookupStaticModelInfo(model.ID); staticInfo != nil {
if model.ContextLength == 0 && staticInfo.ContextLength > 0 {
model.ContextLength = staticInfo.ContextLength
}
if model.MaxCompletionTokens == 0 && staticInfo.MaxCompletionTokens > 0 {
model.MaxCompletionTokens = staticInfo.MaxCompletionTokens
}
}
rawModelIDs = append(rawModelIDs, model.ID)
newCounts[model.ID]++
if _, exists := newModels[model.ID]; exists {
Expand Down Expand Up @@ -875,6 +883,25 @@ func cloneModelMapValue(value any) any {
//
// Returns:
// - []*ModelInfo: List of available models for the provider
// GetAllRegisteredModels returns metadata for every registered model regardless of
// suspension or quota state. Use this for administrative/health endpoints that need
// visibility into ALL models, including unhealthy ones.
func (r *ModelRegistry) GetAllRegisteredModels(handlerType string) []map[string]any {
r.mutex.RLock()
defer r.mutex.RUnlock()
models := make([]map[string]any, 0, len(r.models))
for _, registration := range r.models {
if registration.Info == nil {
continue
}
model := r.convertModelToMap(registration.Info, handlerType)
if model != nil {
models = append(models, model)
}
}
return models
}

func (r *ModelRegistry) GetAvailableModelsByProvider(provider string) []*ModelInfo {
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "" {
Expand Down Expand Up @@ -1158,6 +1185,14 @@ func (r *ModelRegistry) convertModelToMap(model *ModelInfo, handlerType string)
if model.DisplayName != "" {
result["display_name"] = model.DisplayName
}
if model.ContextLength > 0 {
result["context_length"] = model.ContextLength
result["context_window_size"] = model.ContextLength
}
if model.MaxCompletionTokens > 0 {
result["max_completion_tokens"] = model.MaxCompletionTokens
result["max_output_tokens"] = model.MaxCompletionTokens
}
return result

case "gemini":
Expand Down Expand Up @@ -1315,3 +1350,42 @@ func (r *ModelRegistry) GetModelsForClient(clientID string) []*ModelInfo {
}
return result
}

// GetModelHealthDetails returns detailed health information for a model including
// providers, suspended clients with reasons, and availability counts.
func (r *ModelRegistry) GetModelHealthDetails(modelID string) (providers map[string]int, suspendedClients map[string]string, count int) {
r.mutex.RLock()
defer r.mutex.RUnlock()

if registration, exists := r.models[modelID]; exists && registration != nil {
// Clone providers map
if len(registration.Providers) > 0 {
providers = make(map[string]int, len(registration.Providers))
for k, v := range registration.Providers {
providers[k] = v
}
}
// Clone suspended clients map
if len(registration.SuspendedClients) > 0 {
suspendedClients = make(map[string]string, len(registration.SuspendedClients))
for k, v := range registration.SuspendedClients {
suspendedClients[k] = v
}
}
count = registration.Count
}
return
}

// SetModelContextLength updates the context_length on the live model registration.
// This writes through to the actual stored ModelInfo, not a clone.
func (r *ModelRegistry) SetModelContextLength(modelID string, contextLength int) bool {
r.mutex.Lock()
defer r.mutex.Unlock()
reg, ok := r.models[modelID]
if !ok || reg == nil || reg.Info == nil {
return false
}
reg.Info.ContextLength = contextLength
return true
}
1 change: 1 addition & 0 deletions internal/registry/models/models.json
Original file line number Diff line number Diff line change
Expand Up @@ -2708,6 +2708,7 @@
"display_name": "Gemini 3.1 Flash Image",
"name": "gemini-3.1-flash-image",
"description": "Gemini 3.1 Flash Image",
"context_length": 1048576,
"thinking": {
"min": 128,
"max": 32768,
Expand Down
Loading
Loading