From 26fbb77901420592c1cd3bc8c4a17a7fa9b4f9f0 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Wed, 24 Dec 2025 15:21:03 +0800 Subject: [PATCH 1/2] refactor(sdk/auth): rename manager.go to conductor.go --- internal/api/modules/amp/amp.go | 38 +++--- internal/api/modules/amp/model_mapping.go | 80 ++++++------- .../api/modules/amp/model_mapping_test.go | 110 +++++++++--------- internal/config/config.go | 8 +- .../auth/{manager.go => conductor.go} | 0 5 files changed, 118 insertions(+), 118 deletions(-) rename sdk/cliproxy/auth/{manager.go => conductor.go} (100%) diff --git a/internal/api/modules/amp/amp.go b/internal/api/modules/amp/amp.go index 2a2ccb13..924b3452 100644 --- a/internal/api/modules/amp/amp.go +++ b/internal/api/modules/amp/amp.go @@ -279,26 +279,26 @@ func (m *AmpModule) hasModelMappingsChanged(old *config.AmpCode, new *config.Amp return true } - // Build map for efficient and robust comparison - type mappingInfo struct { - to string - regex bool - } - oldMap := make(map[string]mappingInfo, len(old.ModelMappings)) - for _, mapping := range old.ModelMappings { - oldMap[strings.TrimSpace(mapping.From)] = mappingInfo{ - to: strings.TrimSpace(mapping.To), - regex: mapping.Regex, - } - } + // Build map for efficient and robust comparison + type mappingInfo struct { + to string + regex bool + } + oldMap := make(map[string]mappingInfo, len(old.ModelMappings)) + for _, mapping := range old.ModelMappings { + oldMap[strings.TrimSpace(mapping.From)] = mappingInfo{ + to: strings.TrimSpace(mapping.To), + regex: mapping.Regex, + } + } - for _, mapping := range new.ModelMappings { - from := strings.TrimSpace(mapping.From) - to := strings.TrimSpace(mapping.To) - if oldVal, exists := oldMap[from]; !exists || oldVal.to != to || oldVal.regex != mapping.Regex { - return true - } - } + for _, mapping := range new.ModelMappings { + from := strings.TrimSpace(mapping.From) + to := strings.TrimSpace(mapping.To) + if oldVal, exists := oldMap[from]; !exists || oldVal.to != to || oldVal.regex != mapping.Regex { + return true + } + } return false } diff --git a/internal/api/modules/amp/model_mapping.go b/internal/api/modules/amp/model_mapping.go index 0741a52c..4b629b62 100644 --- a/internal/api/modules/amp/model_mapping.go +++ b/internal/api/modules/amp/model_mapping.go @@ -3,7 +3,7 @@ package amp import ( - "regexp" + "regexp" "strings" "sync" @@ -27,15 +27,15 @@ type ModelMapper interface { // DefaultModelMapper implements ModelMapper with thread-safe mapping storage. type DefaultModelMapper struct { mu sync.RWMutex - mappings map[string]string // exact: from -> to (normalized lowercase keys) - regexps []regexMapping // regex rules evaluated in order + mappings map[string]string // exact: from -> to (normalized lowercase keys) + regexps []regexMapping // regex rules evaluated in order } // NewModelMapper creates a new model mapper with the given initial mappings. func NewModelMapper(mappings []config.AmpModelMapping) *DefaultModelMapper { m := &DefaultModelMapper{ - mappings: make(map[string]string), - regexps: nil, + mappings: make(map[string]string), + regexps: nil, } m.UpdateMappings(mappings) return m @@ -58,18 +58,18 @@ func (m *DefaultModelMapper) MapModel(requestedModel string) string { // Check for direct mapping targetModel, exists := m.mappings[normalizedRequest] if !exists { - // Try regex mappings in order - base, _ := util.NormalizeThinkingModel(requestedModel) - for _, rm := range m.regexps { - if rm.re.MatchString(requestedModel) || (base != "" && rm.re.MatchString(base)) { - targetModel = rm.to - exists = true - break - } - } - if !exists { - return "" - } + // Try regex mappings in order + base, _ := util.NormalizeThinkingModel(requestedModel) + for _, rm := range m.regexps { + if rm.re.MatchString(requestedModel) || (base != "" && rm.re.MatchString(base)) { + targetModel = rm.to + exists = true + break + } + } + if !exists { + return "" + } } // Verify target model has available providers @@ -91,8 +91,8 @@ func (m *DefaultModelMapper) UpdateMappings(mappings []config.AmpModelMapping) { defer m.mu.Unlock() // Clear and rebuild mappings - m.mappings = make(map[string]string, len(mappings)) - m.regexps = make([]regexMapping, 0, len(mappings)) + m.mappings = make(map[string]string, len(mappings)) + m.regexps = make([]regexMapping, 0, len(mappings)) for _, mapping := range mappings { from := strings.TrimSpace(mapping.From) @@ -103,30 +103,30 @@ func (m *DefaultModelMapper) UpdateMappings(mappings []config.AmpModelMapping) { continue } - if mapping.Regex { - // Compile case-insensitive regex; wrap with (?i) to match behavior of exact lookups - pattern := "(?i)" + from - re, err := regexp.Compile(pattern) - if err != nil { - log.Warnf("amp model mapping: invalid regex %q: %v", from, err) - continue - } - m.regexps = append(m.regexps, regexMapping{re: re, to: to}) - log.Debugf("amp model regex mapping registered: /%s/ -> %s", from, to) - } else { - // Store with normalized lowercase key for case-insensitive lookup - normalizedFrom := strings.ToLower(from) - m.mappings[normalizedFrom] = to - log.Debugf("amp model mapping registered: %s -> %s", from, to) - } + if mapping.Regex { + // Compile case-insensitive regex; wrap with (?i) to match behavior of exact lookups + pattern := "(?i)" + from + re, err := regexp.Compile(pattern) + if err != nil { + log.Warnf("amp model mapping: invalid regex %q: %v", from, err) + continue + } + m.regexps = append(m.regexps, regexMapping{re: re, to: to}) + log.Debugf("amp model regex mapping registered: /%s/ -> %s", from, to) + } else { + // Store with normalized lowercase key for case-insensitive lookup + normalizedFrom := strings.ToLower(from) + m.mappings[normalizedFrom] = to + log.Debugf("amp model mapping registered: %s -> %s", from, to) + } } if len(m.mappings) > 0 { log.Infof("amp model mapping: loaded %d mapping(s)", len(m.mappings)) } - if n := len(m.regexps); n > 0 { - log.Infof("amp model mapping: loaded %d regex mapping(s)", n) - } + if n := len(m.regexps); n > 0 { + log.Infof("amp model mapping: loaded %d regex mapping(s)", n) + } } // GetMappings returns a copy of current mappings (for debugging/status). @@ -142,6 +142,6 @@ func (m *DefaultModelMapper) GetMappings() map[string]string { } type regexMapping struct { - re *regexp.Regexp - to string + re *regexp.Regexp + to string } diff --git a/internal/api/modules/amp/model_mapping_test.go b/internal/api/modules/amp/model_mapping_test.go index f4691448..1b36f212 100644 --- a/internal/api/modules/amp/model_mapping_test.go +++ b/internal/api/modules/amp/model_mapping_test.go @@ -205,79 +205,79 @@ func TestModelMapper_GetMappings_ReturnsCopy(t *testing.T) { } func TestModelMapper_Regex_MatchBaseWithoutParens(t *testing.T) { - reg := registry.GetGlobalRegistry() - reg.RegisterClient("test-client-regex-1", "gemini", []*registry.ModelInfo{ - {ID: "gemini-2.5-pro", OwnedBy: "google", Type: "gemini"}, - }) - defer reg.UnregisterClient("test-client-regex-1") + reg := registry.GetGlobalRegistry() + reg.RegisterClient("test-client-regex-1", "gemini", []*registry.ModelInfo{ + {ID: "gemini-2.5-pro", OwnedBy: "google", Type: "gemini"}, + }) + defer reg.UnregisterClient("test-client-regex-1") - mappings := []config.AmpModelMapping{ - {From: "^gpt-5$", To: "gemini-2.5-pro", Regex: true}, - } + mappings := []config.AmpModelMapping{ + {From: "^gpt-5$", To: "gemini-2.5-pro", Regex: true}, + } - mapper := NewModelMapper(mappings) + mapper := NewModelMapper(mappings) - // Incoming model has reasoning suffix but should match base via regex - result := mapper.MapModel("gpt-5(high)") - if result != "gemini-2.5-pro" { - t.Errorf("Expected gemini-2.5-pro, got %s", result) - } + // Incoming model has reasoning suffix but should match base via regex + result := mapper.MapModel("gpt-5(high)") + if result != "gemini-2.5-pro" { + t.Errorf("Expected gemini-2.5-pro, got %s", result) + } } func TestModelMapper_Regex_ExactPrecedence(t *testing.T) { - reg := registry.GetGlobalRegistry() - reg.RegisterClient("test-client-regex-2", "claude", []*registry.ModelInfo{ - {ID: "claude-sonnet-4", OwnedBy: "anthropic", Type: "claude"}, - }) - reg.RegisterClient("test-client-regex-3", "gemini", []*registry.ModelInfo{ - {ID: "gemini-2.5-pro", OwnedBy: "google", Type: "gemini"}, - }) - defer reg.UnregisterClient("test-client-regex-2") - defer reg.UnregisterClient("test-client-regex-3") + reg := registry.GetGlobalRegistry() + reg.RegisterClient("test-client-regex-2", "claude", []*registry.ModelInfo{ + {ID: "claude-sonnet-4", OwnedBy: "anthropic", Type: "claude"}, + }) + reg.RegisterClient("test-client-regex-3", "gemini", []*registry.ModelInfo{ + {ID: "gemini-2.5-pro", OwnedBy: "google", Type: "gemini"}, + }) + defer reg.UnregisterClient("test-client-regex-2") + defer reg.UnregisterClient("test-client-regex-3") - mappings := []config.AmpModelMapping{ - {From: "gpt-5", To: "claude-sonnet-4"}, // exact - {From: "^gpt-5.*$", To: "gemini-2.5-pro", Regex: true}, // regex - } + mappings := []config.AmpModelMapping{ + {From: "gpt-5", To: "claude-sonnet-4"}, // exact + {From: "^gpt-5.*$", To: "gemini-2.5-pro", Regex: true}, // regex + } - mapper := NewModelMapper(mappings) + mapper := NewModelMapper(mappings) - // Exact match should win over regex - result := mapper.MapModel("gpt-5") - if result != "claude-sonnet-4" { - t.Errorf("Expected claude-sonnet-4, got %s", result) - } + // Exact match should win over regex + result := mapper.MapModel("gpt-5") + if result != "claude-sonnet-4" { + t.Errorf("Expected claude-sonnet-4, got %s", result) + } } func TestModelMapper_Regex_InvalidPattern_Skipped(t *testing.T) { - // Invalid regex should be skipped and not cause panic - mappings := []config.AmpModelMapping{ - {From: "(", To: "target", Regex: true}, - } + // Invalid regex should be skipped and not cause panic + mappings := []config.AmpModelMapping{ + {From: "(", To: "target", Regex: true}, + } - mapper := NewModelMapper(mappings) + mapper := NewModelMapper(mappings) - result := mapper.MapModel("anything") - if result != "" { - t.Errorf("Expected empty result due to invalid regex, got %s", result) - } + result := mapper.MapModel("anything") + if result != "" { + t.Errorf("Expected empty result due to invalid regex, got %s", result) + } } func TestModelMapper_Regex_CaseInsensitive(t *testing.T) { - reg := registry.GetGlobalRegistry() - reg.RegisterClient("test-client-regex-4", "claude", []*registry.ModelInfo{ - {ID: "claude-sonnet-4", OwnedBy: "anthropic", Type: "claude"}, - }) - defer reg.UnregisterClient("test-client-regex-4") + reg := registry.GetGlobalRegistry() + reg.RegisterClient("test-client-regex-4", "claude", []*registry.ModelInfo{ + {ID: "claude-sonnet-4", OwnedBy: "anthropic", Type: "claude"}, + }) + defer reg.UnregisterClient("test-client-regex-4") - mappings := []config.AmpModelMapping{ - {From: "^CLAUDE-OPUS-.*$", To: "claude-sonnet-4", Regex: true}, - } + mappings := []config.AmpModelMapping{ + {From: "^CLAUDE-OPUS-.*$", To: "claude-sonnet-4", Regex: true}, + } - mapper := NewModelMapper(mappings) + mapper := NewModelMapper(mappings) - result := mapper.MapModel("claude-opus-4.5") - if result != "claude-sonnet-4" { - t.Errorf("Expected claude-sonnet-4, got %s", result) - } + result := mapper.MapModel("claude-opus-4.5") + if result != "claude-sonnet-4" { + t.Errorf("Expected claude-sonnet-4, got %s", result) + } } diff --git a/internal/config/config.go b/internal/config/config.go index 9d0ad606..bc6ae9d8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -145,10 +145,10 @@ type AmpModelMapping struct { // The target model must have available providers in the registry. To string `yaml:"to" json:"to"` - // Regex indicates whether the 'from' field should be interpreted as a regular - // expression for matching model names. When true, this mapping is evaluated - // after exact matches and in the order provided. Defaults to false (exact match). - Regex bool `yaml:"regex,omitempty" json:"regex,omitempty"` + // Regex indicates whether the 'from' field should be interpreted as a regular + // expression for matching model names. When true, this mapping is evaluated + // after exact matches and in the order provided. Defaults to false (exact match). + Regex bool `yaml:"regex,omitempty" json:"regex,omitempty"` } // AmpCode groups Amp CLI integration settings including upstream routing, diff --git a/sdk/cliproxy/auth/manager.go b/sdk/cliproxy/auth/conductor.go similarity index 100% rename from sdk/cliproxy/auth/manager.go rename to sdk/cliproxy/auth/conductor.go From 2a6d8b78d41e3079ed232779a1e061abf42a0224 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Wed, 24 Dec 2025 19:24:51 +0800 Subject: [PATCH 2/2] feat(api): add endpoint to retrieve request logs by ID --- internal/api/handlers/management/logs.go | 88 ++++++++++++++++++++++++ internal/api/server.go | 1 + 2 files changed, 89 insertions(+) diff --git a/internal/api/handlers/management/logs.go b/internal/api/handlers/management/logs.go index 5819e460..2612318a 100644 --- a/internal/api/handlers/management/logs.go +++ b/internal/api/handlers/management/logs.go @@ -209,6 +209,94 @@ func (h *Handler) GetRequestErrorLogs(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"files": files}) } +// GetRequestLogByID finds and downloads a request log file by its request ID. +// The ID is matched against the suffix of log file names (format: *-{requestID}.log). +func (h *Handler) GetRequestLogByID(c *gin.Context) { + if h == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "handler unavailable"}) + return + } + if h.cfg == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "configuration unavailable"}) + return + } + + dir := h.logDirectory() + if strings.TrimSpace(dir) == "" { + c.JSON(http.StatusInternalServerError, gin.H{"error": "log directory not configured"}) + return + } + + requestID := strings.TrimSpace(c.Param("id")) + if requestID == "" { + requestID = strings.TrimSpace(c.Query("id")) + } + if requestID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing request ID"}) + return + } + if strings.ContainsAny(requestID, "/\\") { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request ID"}) + return + } + + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "log directory not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list log directory: %v", err)}) + return + } + + suffix := "-" + requestID + ".log" + var matchedFile string + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if strings.HasSuffix(name, suffix) { + matchedFile = name + break + } + } + + if matchedFile == "" { + c.JSON(http.StatusNotFound, gin.H{"error": "log file not found for the given request ID"}) + return + } + + dirAbs, errAbs := filepath.Abs(dir) + if errAbs != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to resolve log directory: %v", errAbs)}) + return + } + fullPath := filepath.Clean(filepath.Join(dirAbs, matchedFile)) + prefix := dirAbs + string(os.PathSeparator) + if !strings.HasPrefix(fullPath, prefix) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid log file path"}) + return + } + + info, errStat := os.Stat(fullPath) + if errStat != nil { + if os.IsNotExist(errStat) { + c.JSON(http.StatusNotFound, gin.H{"error": "log file not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to read log file: %v", errStat)}) + return + } + if info.IsDir() { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid log file"}) + return + } + + c.FileAttachment(fullPath, matchedFile) +} + // DownloadRequestErrorLog downloads a specific error request log file by name. func (h *Handler) DownloadRequestErrorLog(c *gin.Context) { if h == nil { diff --git a/internal/api/server.go b/internal/api/server.go index 094da118..80c30ebc 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -518,6 +518,7 @@ func (s *Server) registerManagementRoutes() { mgmt.DELETE("/logs", s.mgmt.DeleteLogs) mgmt.GET("/request-error-logs", s.mgmt.GetRequestErrorLogs) mgmt.GET("/request-error-logs/:name", s.mgmt.DownloadRequestErrorLog) + mgmt.GET("/request-log-by-id/:id", s.mgmt.GetRequestLogByID) mgmt.GET("/request-log", s.mgmt.GetRequestLog) mgmt.PUT("/request-log", s.mgmt.PutRequestLog) mgmt.PATCH("/request-log", s.mgmt.PutRequestLog)