From 9bc6cc5b4169fc5dc40465d097f5ce3308ec6de6 Mon Sep 17 00:00:00 2001 From: Ravindra Barthwal Date: Sat, 7 Feb 2026 14:58:34 +0530 Subject: [PATCH 1/9] feat: add Claude Opus 4.6 to GitHub Copilot models GitHub Copilot now supports claude-opus-4.6 but it was missing from the proxy's model definitions. Fixes #196. --- internal/registry/model_definitions.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index 0967db60..d26cffce 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -277,6 +277,18 @@ func GetGitHubCopilotModels() []*ModelInfo { MaxCompletionTokens: 64000, SupportedEndpoints: []string{"/chat/completions"}, }, + { + ID: "claude-opus-4.6", + Object: "model", + Created: now, + OwnedBy: "github-copilot", + Type: "github-copilot", + DisplayName: "Claude Opus 4.6", + Description: "Anthropic Claude Opus 4.6 via GitHub Copilot", + ContextLength: 200000, + MaxCompletionTokens: 64000, + SupportedEndpoints: []string{"/chat/completions"}, + }, { ID: "claude-sonnet-4", Object: "model", From d468eec6ecefbec0c9a50f821ae33fb9e1221c78 Mon Sep 17 00:00:00 2001 From: rico <565636992@qq.com> Date: Sun, 8 Feb 2026 02:22:10 +0800 Subject: [PATCH 2/9] fix(copilot): prevent premium request count inflation for Claude models > Copilot Premium usage significantly amplified when using amp - Add X-Initiator header (user/agent) based on last message role to prevent Copilot from billing all requests as premium user-initiated - Add flattenAssistantContent() to convert assistant content from array to string, preventing Claude from re-answering all previous prompts - Align Copilot headers (User-Agent, Editor-Version, Openai-Intent) with pi-ai reference implementation Closes #113 Amp-Thread-ID: https://ampcode.com/threads/T-019c392b-736e-7489-a06b-f94f7c75f7c0 Co-authored-by: Amp --- .../executor/github_copilot_executor.go | 65 ++++++++++++++++--- 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/internal/runtime/executor/github_copilot_executor.go b/internal/runtime/executor/github_copilot_executor.go index ad93f488..b43e1909 100644 --- a/internal/runtime/executor/github_copilot_executor.go +++ b/internal/runtime/executor/github_copilot_executor.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "strings" "sync" "time" @@ -33,11 +34,11 @@ const ( maxScannerBufferSize = 20_971_520 // Copilot API header values. - copilotUserAgent = "GithubCopilot/1.0" - copilotEditorVersion = "vscode/1.100.0" - copilotPluginVersion = "copilot/1.300.0" + copilotUserAgent = "GitHubCopilotChat/0.35.0" + copilotEditorVersion = "vscode/1.107.0" + copilotPluginVersion = "copilot-chat/0.35.0" copilotIntegrationID = "vscode-chat" - copilotOpenAIIntent = "conversation-panel" + copilotOpenAIIntent = "conversation-edits" ) // GitHubCopilotExecutor handles requests to the GitHub Copilot API. @@ -77,7 +78,7 @@ func (e *GitHubCopilotExecutor) PrepareRequest(req *http.Request, auth *cliproxy if errToken != nil { return errToken } - e.applyHeaders(req, apiToken) + e.applyHeaders(req, apiToken, nil) return nil } @@ -120,6 +121,7 @@ func (e *GitHubCopilotExecutor) Execute(ctx context.Context, auth *cliproxyauth. originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, false) body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) body = e.normalizeModel(req.Model, body) + body = flattenAssistantContent(body) requestedModel := payloadRequestedModel(opts, req.Model) body = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", body, originalTranslated, requestedModel) body, _ = sjson.SetBytes(body, "stream", false) @@ -133,7 +135,7 @@ func (e *GitHubCopilotExecutor) Execute(ctx context.Context, auth *cliproxyauth. if err != nil { return resp, err } - e.applyHeaders(httpReq, apiToken) + e.applyHeaders(httpReq, apiToken, body) // Add Copilot-Vision-Request header if the request contains vision content if detectVisionContent(body) { @@ -225,6 +227,7 @@ func (e *GitHubCopilotExecutor) ExecuteStream(ctx context.Context, auth *cliprox originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, false) body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true) body = e.normalizeModel(req.Model, body) + body = flattenAssistantContent(body) requestedModel := payloadRequestedModel(opts, req.Model) body = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", body, originalTranslated, requestedModel) body, _ = sjson.SetBytes(body, "stream", true) @@ -242,7 +245,7 @@ func (e *GitHubCopilotExecutor) ExecuteStream(ctx context.Context, auth *cliprox if err != nil { return nil, err } - e.applyHeaders(httpReq, apiToken) + e.applyHeaders(httpReq, apiToken, body) // Add Copilot-Vision-Request header if the request contains vision content if detectVisionContent(body) { @@ -414,7 +417,7 @@ func (e *GitHubCopilotExecutor) ensureAPIToken(ctx context.Context, auth *clipro } // applyHeaders sets the required headers for GitHub Copilot API requests. -func (e *GitHubCopilotExecutor) applyHeaders(r *http.Request, apiToken string) { +func (e *GitHubCopilotExecutor) applyHeaders(r *http.Request, apiToken string, body []byte) { r.Header.Set("Content-Type", "application/json") r.Header.Set("Authorization", "Bearer "+apiToken) r.Header.Set("Accept", "application/json") @@ -424,6 +427,20 @@ func (e *GitHubCopilotExecutor) applyHeaders(r *http.Request, apiToken string) { r.Header.Set("Openai-Intent", copilotOpenAIIntent) r.Header.Set("Copilot-Integration-Id", copilotIntegrationID) r.Header.Set("X-Request-Id", uuid.NewString()) + + initiator := "user" + if len(body) > 0 { + if messages := gjson.GetBytes(body, "messages"); messages.Exists() && messages.IsArray() { + arr := messages.Array() + if len(arr) > 0 { + lastRole := arr[len(arr)-1].Get("role").String() + if lastRole != "" && lastRole != "user" { + initiator = "agent" + } + } + } + } + r.Header.Set("X-Initiator", initiator) } // detectVisionContent checks if the request body contains vision/image content. @@ -464,6 +481,38 @@ func useGitHubCopilotResponsesEndpoint(sourceFormat sdktranslator.Format) bool { return sourceFormat.String() == "openai-response" } +// flattenAssistantContent converts assistant message content from array format +// to a joined string. GitHub Copilot requires assistant content as a string; +// sending it as an array causes Claude models to re-answer all previous prompts. +func flattenAssistantContent(body []byte) []byte { + messages := gjson.GetBytes(body, "messages") + if !messages.Exists() || !messages.IsArray() { + return body + } + result := body + for i, msg := range messages.Array() { + if msg.Get("role").String() != "assistant" { + continue + } + content := msg.Get("content") + if !content.Exists() || !content.IsArray() { + continue + } + var textParts []string + for _, part := range content.Array() { + if part.Get("type").String() == "text" { + if t := part.Get("text").String(); t != "" { + textParts = append(textParts, t) + } + } + } + joined := strings.Join(textParts, "") + path := fmt.Sprintf("messages.%d.content", i) + result, _ = sjson.SetBytes(result, path, joined) + } + return result +} + // isHTTPSuccess checks if the status code indicates success (2xx). func isHTTPSuccess(statusCode int) bool { return statusCode >= 200 && statusCode < 300 From 76330f4bfffd4dc39142d179d80df9c0987f5bbb Mon Sep 17 00:00:00 2001 From: rico <565636992@qq.com> Date: Sun, 8 Feb 2026 02:38:06 +0800 Subject: [PATCH 3/9] feat(copilot): add Claude Opus 4.6 model definition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > 添加 copilot claude opus 4.6 支持 (ref: PR #199) --- internal/registry/model_definitions.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index 0967db60..d26cffce 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -277,6 +277,18 @@ func GetGitHubCopilotModels() []*ModelInfo { MaxCompletionTokens: 64000, SupportedEndpoints: []string{"/chat/completions"}, }, + { + ID: "claude-opus-4.6", + Object: "model", + Created: now, + OwnedBy: "github-copilot", + Type: "github-copilot", + DisplayName: "Claude Opus 4.6", + Description: "Anthropic Claude Opus 4.6 via GitHub Copilot", + ContextLength: 200000, + MaxCompletionTokens: 64000, + SupportedEndpoints: []string{"/chat/completions"}, + }, { ID: "claude-sonnet-4", Object: "model", From 234056072da3048acf1dcbfa83ad8b2a2425f926 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sun, 8 Feb 2026 10:42:49 +0800 Subject: [PATCH 4/9] refactor(management): streamline control panel management and implement sync throttling --- internal/api/server.go | 4 --- internal/managementasset/updater.go | 47 +++++++++++------------------ 2 files changed, 17 insertions(+), 34 deletions(-) diff --git a/internal/api/server.go b/internal/api/server.go index 5e194c56..e1e7a14d 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -952,10 +952,6 @@ func (s *Server) UpdateClients(cfg *config.Config) { s.handlers.UpdateClients(&cfg.SDKConfig) - if !cfg.RemoteManagement.DisableControlPanel { - staticDir := managementasset.StaticDir(s.configFilePath) - go managementasset.EnsureLatestManagementHTML(context.Background(), staticDir, cfg.ProxyURL, cfg.RemoteManagement.PanelGitHubRepository) - } if s.mgmt != nil { s.mgmt.SetConfig(cfg) s.mgmt.SetAuthManager(s.handlers.AuthManager) diff --git a/internal/managementasset/updater.go b/internal/managementasset/updater.go index c941da02..2fbaab12 100644 --- a/internal/managementasset/updater.go +++ b/internal/managementasset/updater.go @@ -28,6 +28,7 @@ const ( defaultManagementFallbackURL = "https://cpamc.router-for.me/" managementAssetName = "management.html" httpUserAgent = "CLIProxyAPI-management-updater" + managementSyncMinInterval = 30 * time.Second updateCheckInterval = 3 * time.Hour ) @@ -37,9 +38,7 @@ const ManagementFileName = managementAssetName var ( lastUpdateCheckMu sync.Mutex lastUpdateCheckTime time.Time - currentConfigPtr atomic.Pointer[config.Config] - disableControlPanel atomic.Bool schedulerOnce sync.Once schedulerConfigPath atomic.Value ) @@ -50,16 +49,7 @@ func SetCurrentConfig(cfg *config.Config) { currentConfigPtr.Store(nil) return } - - prevDisabled := disableControlPanel.Load() currentConfigPtr.Store(cfg) - disableControlPanel.Store(cfg.RemoteManagement.DisableControlPanel) - - if prevDisabled && !cfg.RemoteManagement.DisableControlPanel { - lastUpdateCheckMu.Lock() - lastUpdateCheckTime = time.Time{} - lastUpdateCheckMu.Unlock() - } } // StartAutoUpdater launches a background goroutine that periodically ensures the management asset is up to date. @@ -92,7 +82,7 @@ func runAutoUpdater(ctx context.Context) { log.Debug("management asset auto-updater skipped: config not yet available") return } - if disableControlPanel.Load() { + if cfg.RemoteManagement.DisableControlPanel { log.Debug("management asset auto-updater skipped: control panel disabled") return } @@ -182,23 +172,32 @@ func FilePath(configFilePath string) string { // EnsureLatestManagementHTML checks the latest management.html asset and updates the local copy when needed. // The function is designed to run in a background goroutine and will never panic. -// It enforces a 3-hour rate limit to avoid frequent checks on config/auth file changes. func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string, panelRepository string) { if ctx == nil { ctx = context.Background() } - if disableControlPanel.Load() { - log.Debug("management asset sync skipped: control panel disabled by configuration") - return - } - staticDir = strings.TrimSpace(staticDir) if staticDir == "" { log.Debug("management asset sync skipped: empty static directory") return } + lastUpdateCheckMu.Lock() + now := time.Now() + timeSinceLastAttempt := now.Sub(lastUpdateCheckTime) + if !lastUpdateCheckTime.IsZero() && timeSinceLastAttempt < managementSyncMinInterval { + lastUpdateCheckMu.Unlock() + log.Debugf( + "management asset sync skipped by throttle: last attempt %v ago (interval %v)", + timeSinceLastAttempt.Round(time.Second), + managementSyncMinInterval, + ) + return + } + lastUpdateCheckTime = now + lastUpdateCheckMu.Unlock() + localPath := filepath.Join(staticDir, managementAssetName) localFileMissing := false if _, errStat := os.Stat(localPath); errStat != nil { @@ -209,18 +208,6 @@ func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL } } - // Rate limiting: check only once every 3 hours - lastUpdateCheckMu.Lock() - now := time.Now() - timeSinceLastCheck := now.Sub(lastUpdateCheckTime) - if timeSinceLastCheck < updateCheckInterval { - lastUpdateCheckMu.Unlock() - log.Debugf("management asset update check skipped: last check was %v ago (interval: %v)", timeSinceLastCheck.Round(time.Second), updateCheckInterval) - return - } - lastUpdateCheckTime = now - lastUpdateCheckMu.Unlock() - if errMkdirAll := os.MkdirAll(staticDir, 0o755); errMkdirAll != nil { log.WithError(errMkdirAll).Warn("failed to prepare static directory for management asset") return From 6e349bfcc78410166d5d10777fcdb6bda60f436b Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sun, 8 Feb 2026 18:47:44 +0800 Subject: [PATCH 5/9] fix(config): avoid writing known defaults during merge --- internal/config/config.go | 77 +++++++++++++++++++++++++++++++++++---- 1 file changed, 69 insertions(+), 8 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 706bb991..64508ae5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1098,8 +1098,13 @@ func getOrCreateMapValue(mapNode *yaml.Node, key string) *yaml.Node { // mergeMappingPreserve merges keys from src into dst mapping node while preserving // key order and comments of existing keys in dst. New keys are only added if their -// value is non-zero to avoid polluting the config with defaults. -func mergeMappingPreserve(dst, src *yaml.Node) { +// value is non-zero and not a known default to avoid polluting the config with defaults. +func mergeMappingPreserve(dst, src *yaml.Node, path ...[]string) { + var currentPath []string + if len(path) > 0 { + currentPath = path[0] + } + if dst == nil || src == nil { return } @@ -1113,13 +1118,14 @@ func mergeMappingPreserve(dst, src *yaml.Node) { sk := src.Content[i] sv := src.Content[i+1] idx := findMapKeyIndex(dst, sk.Value) + childPath := appendPath(currentPath, sk.Value) if idx >= 0 { // Merge into existing value node (always update, even to zero values) dv := dst.Content[idx+1] - mergeNodePreserve(dv, sv) + mergeNodePreserve(dv, sv, childPath) } else { - // New key: only add if value is non-zero to avoid polluting config with defaults - if isZeroValueNode(sv) { + // New key: only add if value is non-zero and not a known default + if isKnownDefaultValue(childPath, sv) { continue } dst.Content = append(dst.Content, deepCopyNode(sk), deepCopyNode(sv)) @@ -1130,7 +1136,12 @@ func mergeMappingPreserve(dst, src *yaml.Node) { // mergeNodePreserve merges src into dst for scalars, mappings and sequences while // reusing destination nodes to keep comments and anchors. For sequences, it updates // in-place by index. -func mergeNodePreserve(dst, src *yaml.Node) { +func mergeNodePreserve(dst, src *yaml.Node, path ...[]string) { + var currentPath []string + if len(path) > 0 { + currentPath = path[0] + } + if dst == nil || src == nil { return } @@ -1139,7 +1150,7 @@ func mergeNodePreserve(dst, src *yaml.Node) { if dst.Kind != yaml.MappingNode { copyNodeShallow(dst, src) } - mergeMappingPreserve(dst, src) + mergeMappingPreserve(dst, src, currentPath) case yaml.SequenceNode: // Preserve explicit null style if dst was null and src is empty sequence if dst.Kind == yaml.ScalarNode && dst.Tag == "!!null" && len(src.Content) == 0 { @@ -1162,7 +1173,7 @@ func mergeNodePreserve(dst, src *yaml.Node) { dst.Content[i] = deepCopyNode(src.Content[i]) continue } - mergeNodePreserve(dst.Content[i], src.Content[i]) + mergeNodePreserve(dst.Content[i], src.Content[i], currentPath) if dst.Content[i] != nil && src.Content[i] != nil && dst.Content[i].Kind == yaml.MappingNode && src.Content[i].Kind == yaml.MappingNode { pruneMissingMapKeys(dst.Content[i], src.Content[i]) @@ -1204,6 +1215,56 @@ func findMapKeyIndex(mapNode *yaml.Node, key string) int { return -1 } +// appendPath appends a key to the path, returning a new slice to avoid modifying the original. +func appendPath(path []string, key string) []string { + if len(path) == 0 { + return []string{key} + } + newPath := make([]string, len(path)+1) + copy(newPath, path) + newPath[len(path)] = key + return newPath +} + +// isKnownDefaultValue returns true if the given node at the specified path +// represents a known default value that should not be written to the config file. +// This prevents non-zero defaults from polluting the config. +func isKnownDefaultValue(path []string, node *yaml.Node) bool { + // First check if it's a zero value + if isZeroValueNode(node) { + return true + } + + // Match known non-zero defaults by exact dotted path. + if len(path) == 0 { + return false + } + + fullPath := strings.Join(path, ".") + + // Check string defaults + if node.Kind == yaml.ScalarNode && node.Tag == "!!str" { + switch fullPath { + case "pprof.addr": + return node.Value == DefaultPprofAddr + case "remote-management.panel-github-repository": + return node.Value == DefaultPanelGitHubRepository + case "routing.strategy": + return node.Value == "round-robin" + } + } + + // Check integer defaults + if node.Kind == yaml.ScalarNode && node.Tag == "!!int" { + switch fullPath { + case "error-logs-max-files": + return node.Value == "10" + } + } + + return false +} + // isZeroValueNode returns true if the YAML node represents a zero/default value // that should not be written as a new key to preserve config cleanliness. // For mappings and sequences, recursively checks if all children are zero values. From 7197fb350b436bc2ad8043898602635e7bd05797 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Sun, 8 Feb 2026 19:05:52 +0800 Subject: [PATCH 6/9] fix(config): prune default descendants when merging new yaml nodes --- internal/config/config.go | 44 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 64508ae5..fec58fe5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1125,10 +1125,12 @@ func mergeMappingPreserve(dst, src *yaml.Node, path ...[]string) { mergeNodePreserve(dv, sv, childPath) } else { // New key: only add if value is non-zero and not a known default - if isKnownDefaultValue(childPath, sv) { + candidate := deepCopyNode(sv) + pruneKnownDefaultsInNewNode(childPath, candidate) + if isKnownDefaultValue(childPath, candidate) { continue } - dst.Content = append(dst.Content, deepCopyNode(sk), deepCopyNode(sv)) + dst.Content = append(dst.Content, deepCopyNode(sk), candidate) } } } @@ -1265,6 +1267,44 @@ func isKnownDefaultValue(path []string, node *yaml.Node) bool { return false } +// pruneKnownDefaultsInNewNode removes default-valued descendants from a new node +// before it is appended into the destination YAML tree. +func pruneKnownDefaultsInNewNode(path []string, node *yaml.Node) { + if node == nil { + return + } + + switch node.Kind { + case yaml.MappingNode: + filtered := make([]*yaml.Node, 0, len(node.Content)) + for i := 0; i+1 < len(node.Content); i += 2 { + keyNode := node.Content[i] + valueNode := node.Content[i+1] + if keyNode == nil || valueNode == nil { + continue + } + + childPath := appendPath(path, keyNode.Value) + if isKnownDefaultValue(childPath, valueNode) { + continue + } + + pruneKnownDefaultsInNewNode(childPath, valueNode) + if (valueNode.Kind == yaml.MappingNode || valueNode.Kind == yaml.SequenceNode) && + len(valueNode.Content) == 0 { + continue + } + + filtered = append(filtered, keyNode, valueNode) + } + node.Content = filtered + case yaml.SequenceNode: + for _, child := range node.Content { + pruneKnownDefaultsInNewNode(path, child) + } + } +} + // isZeroValueNode returns true if the YAML node represents a zero/default value // that should not be written as a new key to preserve config cleanliness. // For mappings and sequences, recursively checks if all children are zero values. From 63643c44a1430e0a4fea29c871988cc00864e7f8 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Mon, 9 Feb 2026 02:05:38 +0800 Subject: [PATCH 7/9] Fixed: #1484 fix(translator): restructure message content handling to support multiple content types - Consolidated `input_text` and `output_text` handling into a single case. - Added support for processing `input_image` content with associated URLs. --- .../openai_openai-responses_request.go | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/internal/translator/openai/openai/responses/openai_openai-responses_request.go b/internal/translator/openai/openai/responses/openai_openai-responses_request.go index 35445163..9a64798b 100644 --- a/internal/translator/openai/openai/responses/openai_openai-responses_request.go +++ b/internal/translator/openai/openai/responses/openai_openai-responses_request.go @@ -70,7 +70,7 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu if role == "developer" { role = "user" } - message := `{"role":"","content":""}` + message := `{"role":"","content":[]}` message, _ = sjson.Set(message, "role", role) if content := item.Get("content"); content.Exists() && content.IsArray() { @@ -84,20 +84,16 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu } switch contentType { - case "input_text": + case "input_text", "output_text": text := contentItem.Get("text").String() - if messageContent != "" { - messageContent += "\n" + text - } else { - messageContent = text - } - case "output_text": - text := contentItem.Get("text").String() - if messageContent != "" { - messageContent += "\n" + text - } else { - messageContent = text - } + contentPart := `{"type":"text","text":""}` + contentPart, _ = sjson.Set(contentPart, "text", text) + message, _ = sjson.SetRaw(message, "content.-1", contentPart) + case "input_image": + imageURL := contentItem.Get("image_url").String() + contentPart := `{"type":"image_url","image_url":{"url":""}}` + contentPart, _ = sjson.Set(contentPart, "image_url.url", imageURL) + message, _ = sjson.SetRaw(message, "content.-1", contentPart) } return true }) From 3fbee51e9fe2ff6983b1f477bd6f9573ab9b280c Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Mon, 9 Feb 2026 08:32:58 +0800 Subject: [PATCH 8/9] fix(management): ensure management.html is available synchronously and improve asset sync handling --- go.mod | 2 +- internal/api/server.go | 15 +-- internal/managementasset/updater.go | 156 +++++++++++++++------------- 3 files changed, 92 insertions(+), 81 deletions(-) diff --git a/go.mod b/go.mod index 32080fd7..38a499be 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( golang.org/x/crypto v0.45.0 golang.org/x/net v0.47.0 golang.org/x/oauth2 v0.30.0 + golang.org/x/sync v0.18.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 ) @@ -69,7 +70,6 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect google.golang.org/protobuf v1.34.1 // indirect diff --git a/internal/api/server.go b/internal/api/server.go index e1e7a14d..3eb09366 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -655,14 +655,17 @@ func (s *Server) serveManagementControlPanel(c *gin.Context) { if _, err := os.Stat(filePath); err != nil { if os.IsNotExist(err) { - go managementasset.EnsureLatestManagementHTML(context.Background(), managementasset.StaticDir(s.configFilePath), cfg.ProxyURL, cfg.RemoteManagement.PanelGitHubRepository) - c.AbortWithStatus(http.StatusNotFound) + // Synchronously ensure management.html is available with a detached context. + // Control panel bootstrap should not be canceled by client disconnects. + if !managementasset.EnsureLatestManagementHTML(context.Background(), managementasset.StaticDir(s.configFilePath), cfg.ProxyURL, cfg.RemoteManagement.PanelGitHubRepository) { + c.AbortWithStatus(http.StatusNotFound) + return + } + } else { + log.WithError(err).Error("failed to stat management control panel asset") + c.AbortWithStatus(http.StatusInternalServerError) return } - - log.WithError(err).Error("failed to stat management control panel asset") - c.AbortWithStatus(http.StatusInternalServerError) - return } c.File(filePath) diff --git a/internal/managementasset/updater.go b/internal/managementasset/updater.go index 2fbaab12..7284b729 100644 --- a/internal/managementasset/updater.go +++ b/internal/managementasset/updater.go @@ -21,6 +21,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/util" sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" log "github.com/sirupsen/logrus" + "golang.org/x/sync/singleflight" ) const ( @@ -41,6 +42,7 @@ var ( currentConfigPtr atomic.Pointer[config.Config] schedulerOnce sync.Once schedulerConfigPath atomic.Value + sfGroup singleflight.Group ) // SetCurrentConfig stores the latest configuration snapshot for management asset decisions. @@ -171,8 +173,8 @@ func FilePath(configFilePath string) string { } // EnsureLatestManagementHTML checks the latest management.html asset and updates the local copy when needed. -// The function is designed to run in a background goroutine and will never panic. -func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string, panelRepository string) { +// It coalesces concurrent sync attempts and returns whether the asset exists after the sync attempt. +func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string, panelRepository string) bool { if ctx == nil { ctx = context.Background() } @@ -180,91 +182,97 @@ func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL staticDir = strings.TrimSpace(staticDir) if staticDir == "" { log.Debug("management asset sync skipped: empty static directory") - return + return false } - - lastUpdateCheckMu.Lock() - now := time.Now() - timeSinceLastAttempt := now.Sub(lastUpdateCheckTime) - if !lastUpdateCheckTime.IsZero() && timeSinceLastAttempt < managementSyncMinInterval { - lastUpdateCheckMu.Unlock() - log.Debugf( - "management asset sync skipped by throttle: last attempt %v ago (interval %v)", - timeSinceLastAttempt.Round(time.Second), - managementSyncMinInterval, - ) - return - } - lastUpdateCheckTime = now - lastUpdateCheckMu.Unlock() - localPath := filepath.Join(staticDir, managementAssetName) - localFileMissing := false - if _, errStat := os.Stat(localPath); errStat != nil { - if errors.Is(errStat, os.ErrNotExist) { - localFileMissing = true - } else { - log.WithError(errStat).Debug("failed to stat local management asset") + + _, _, _ = sfGroup.Do(localPath, func() (interface{}, error) { + lastUpdateCheckMu.Lock() + now := time.Now() + timeSinceLastAttempt := now.Sub(lastUpdateCheckTime) + if !lastUpdateCheckTime.IsZero() && timeSinceLastAttempt < managementSyncMinInterval { + lastUpdateCheckMu.Unlock() + log.Debugf( + "management asset sync skipped by throttle: last attempt %v ago (interval %v)", + timeSinceLastAttempt.Round(time.Second), + managementSyncMinInterval, + ) + return nil, nil } - } + lastUpdateCheckTime = now + lastUpdateCheckMu.Unlock() - if errMkdirAll := os.MkdirAll(staticDir, 0o755); errMkdirAll != nil { - log.WithError(errMkdirAll).Warn("failed to prepare static directory for management asset") - return - } - - releaseURL := resolveReleaseURL(panelRepository) - client := newHTTPClient(proxyURL) - - localHash, err := fileSHA256(localPath) - if err != nil { - if !errors.Is(err, os.ErrNotExist) { - log.WithError(err).Debug("failed to read local management asset hash") - } - localHash = "" - } - - asset, remoteHash, err := fetchLatestAsset(ctx, client, releaseURL) - if err != nil { - if localFileMissing { - log.WithError(err).Warn("failed to fetch latest management release information, trying fallback page") - if ensureFallbackManagementHTML(ctx, client, localPath) { - return + localFileMissing := false + if _, errStat := os.Stat(localPath); errStat != nil { + if errors.Is(errStat, os.ErrNotExist) { + localFileMissing = true + } else { + log.WithError(errStat).Debug("failed to stat local management asset") } - return } - log.WithError(err).Warn("failed to fetch latest management release information") - return - } - if remoteHash != "" && localHash != "" && strings.EqualFold(remoteHash, localHash) { - log.Debug("management asset is already up to date") - return - } + if errMkdirAll := os.MkdirAll(staticDir, 0o755); errMkdirAll != nil { + log.WithError(errMkdirAll).Warn("failed to prepare static directory for management asset") + return nil, nil + } - data, downloadedHash, err := downloadAsset(ctx, client, asset.BrowserDownloadURL) - if err != nil { - if localFileMissing { - log.WithError(err).Warn("failed to download management asset, trying fallback page") - if ensureFallbackManagementHTML(ctx, client, localPath) { - return + releaseURL := resolveReleaseURL(panelRepository) + client := newHTTPClient(proxyURL) + + localHash, err := fileSHA256(localPath) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + log.WithError(err).Debug("failed to read local management asset hash") } - return + localHash = "" } - log.WithError(err).Warn("failed to download management asset") - return - } - if remoteHash != "" && !strings.EqualFold(remoteHash, downloadedHash) { - log.Warnf("remote digest mismatch for management asset: expected %s got %s", remoteHash, downloadedHash) - } + asset, remoteHash, err := fetchLatestAsset(ctx, client, releaseURL) + if err != nil { + if localFileMissing { + log.WithError(err).Warn("failed to fetch latest management release information, trying fallback page") + if ensureFallbackManagementHTML(ctx, client, localPath) { + return nil, nil + } + return nil, nil + } + log.WithError(err).Warn("failed to fetch latest management release information") + return nil, nil + } - if err = atomicWriteFile(localPath, data); err != nil { - log.WithError(err).Warn("failed to update management asset on disk") - return - } + if remoteHash != "" && localHash != "" && strings.EqualFold(remoteHash, localHash) { + log.Debug("management asset is already up to date") + return nil, nil + } - log.Infof("management asset updated successfully (hash=%s)", downloadedHash) + data, downloadedHash, err := downloadAsset(ctx, client, asset.BrowserDownloadURL) + if err != nil { + if localFileMissing { + log.WithError(err).Warn("failed to download management asset, trying fallback page") + if ensureFallbackManagementHTML(ctx, client, localPath) { + return nil, nil + } + return nil, nil + } + log.WithError(err).Warn("failed to download management asset") + return nil, nil + } + + if remoteHash != "" && !strings.EqualFold(remoteHash, downloadedHash) { + log.Warnf("remote digest mismatch for management asset: expected %s got %s", remoteHash, downloadedHash) + } + + if err = atomicWriteFile(localPath, data); err != nil { + log.WithError(err).Warn("failed to update management asset on disk") + return nil, nil + } + + log.Infof("management asset updated successfully (hash=%s)", downloadedHash) + return nil, nil + }) + + _, err := os.Stat(localPath) + return err == nil } func ensureFallbackManagementHTML(ctx context.Context, client *http.Client, localPath string) bool { From 49c1740b47eb7e07818c50fe6fd90b1259929601 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:29:42 +0800 Subject: [PATCH 9/9] feat(executor): add session ID and HMAC-SHA256 signature generation for iFlow API requests --- internal/runtime/executor/iflow_executor.go | 35 +++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/internal/runtime/executor/iflow_executor.go b/internal/runtime/executor/iflow_executor.go index 77e8d160..30c37726 100644 --- a/internal/runtime/executor/iflow_executor.go +++ b/internal/runtime/executor/iflow_executor.go @@ -4,12 +4,16 @@ import ( "bufio" "bytes" "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "fmt" "io" "net/http" "strings" "time" + "github.com/google/uuid" iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" @@ -453,6 +457,20 @@ func applyIFlowHeaders(r *http.Request, apiKey string, stream bool) { r.Header.Set("Content-Type", "application/json") r.Header.Set("Authorization", "Bearer "+apiKey) r.Header.Set("User-Agent", iflowUserAgent) + + // Generate session-id + sessionID := "session-" + generateUUID() + r.Header.Set("session-id", sessionID) + + // Generate timestamp and signature + timestamp := time.Now().UnixMilli() + r.Header.Set("x-iflow-timestamp", fmt.Sprintf("%d", timestamp)) + + signature := createIFlowSignature(iflowUserAgent, sessionID, timestamp, apiKey) + if signature != "" { + r.Header.Set("x-iflow-signature", signature) + } + if stream { r.Header.Set("Accept", "text/event-stream") } else { @@ -460,6 +478,23 @@ func applyIFlowHeaders(r *http.Request, apiKey string, stream bool) { } } +// createIFlowSignature generates HMAC-SHA256 signature for iFlow API requests. +// The signature payload format is: userAgent:sessionId:timestamp +func createIFlowSignature(userAgent, sessionID string, timestamp int64, apiKey string) string { + if apiKey == "" { + return "" + } + payload := fmt.Sprintf("%s:%s:%d", userAgent, sessionID, timestamp) + h := hmac.New(sha256.New, []byte(apiKey)) + h.Write([]byte(payload)) + return hex.EncodeToString(h.Sum(nil)) +} + +// generateUUID generates a random UUID v4 string. +func generateUUID() string { + return uuid.New().String() +} + func iflowCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { if a == nil { return "", ""