mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-04-26 05:46:11 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
532fbf00d4 | ||
|
|
45b6fffd7f | ||
|
|
5a3eb08739 | ||
|
|
0dff329162 | ||
|
|
49c1740b47 | ||
|
|
3fbee51e9f | ||
|
|
a3dc56d2a0 | ||
|
|
63643c44a1 |
@@ -683,14 +683,17 @@ func (s *Server) serveManagementControlPanel(c *gin.Context) {
|
|||||||
|
|
||||||
if _, err := os.Stat(filePath); err != nil {
|
if _, err := os.Stat(filePath); err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
go managementasset.EnsureLatestManagementHTML(context.Background(), managementasset.StaticDir(s.configFilePath), cfg.ProxyURL, cfg.RemoteManagement.PanelGitHubRepository)
|
// Synchronously ensure management.html is available with a detached context.
|
||||||
c.AbortWithStatus(http.StatusNotFound)
|
// 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.WithError(err).Error("failed to stat management control panel asset")
|
|
||||||
c.AbortWithStatus(http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.File(filePath)
|
c.File(filePath)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
"golang.org/x/sync/singleflight"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -41,6 +42,7 @@ var (
|
|||||||
currentConfigPtr atomic.Pointer[config.Config]
|
currentConfigPtr atomic.Pointer[config.Config]
|
||||||
schedulerOnce sync.Once
|
schedulerOnce sync.Once
|
||||||
schedulerConfigPath atomic.Value
|
schedulerConfigPath atomic.Value
|
||||||
|
sfGroup singleflight.Group
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetCurrentConfig stores the latest configuration snapshot for management asset decisions.
|
// 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.
|
// 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 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) {
|
func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string, panelRepository string) bool {
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
}
|
}
|
||||||
@@ -180,91 +182,97 @@ func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL
|
|||||||
staticDir = strings.TrimSpace(staticDir)
|
staticDir = strings.TrimSpace(staticDir)
|
||||||
if staticDir == "" {
|
if staticDir == "" {
|
||||||
log.Debug("management asset sync skipped: empty static directory")
|
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)
|
localPath := filepath.Join(staticDir, managementAssetName)
|
||||||
localFileMissing := false
|
|
||||||
if _, errStat := os.Stat(localPath); errStat != nil {
|
_, _, _ = sfGroup.Do(localPath, func() (interface{}, error) {
|
||||||
if errors.Is(errStat, os.ErrNotExist) {
|
lastUpdateCheckMu.Lock()
|
||||||
localFileMissing = true
|
now := time.Now()
|
||||||
} else {
|
timeSinceLastAttempt := now.Sub(lastUpdateCheckTime)
|
||||||
log.WithError(errStat).Debug("failed to stat local management asset")
|
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 {
|
localFileMissing := false
|
||||||
log.WithError(errMkdirAll).Warn("failed to prepare static directory for management asset")
|
if _, errStat := os.Stat(localPath); errStat != nil {
|
||||||
return
|
if errors.Is(errStat, os.ErrNotExist) {
|
||||||
}
|
localFileMissing = true
|
||||||
|
} else {
|
||||||
releaseURL := resolveReleaseURL(panelRepository)
|
log.WithError(errStat).Debug("failed to stat local management asset")
|
||||||
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
|
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
log.WithError(err).Warn("failed to fetch latest management release information")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if remoteHash != "" && localHash != "" && strings.EqualFold(remoteHash, localHash) {
|
if errMkdirAll := os.MkdirAll(staticDir, 0o755); errMkdirAll != nil {
|
||||||
log.Debug("management asset is already up to date")
|
log.WithError(errMkdirAll).Warn("failed to prepare static directory for management asset")
|
||||||
return
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
data, downloadedHash, err := downloadAsset(ctx, client, asset.BrowserDownloadURL)
|
releaseURL := resolveReleaseURL(panelRepository)
|
||||||
if err != nil {
|
client := newHTTPClient(proxyURL)
|
||||||
if localFileMissing {
|
|
||||||
log.WithError(err).Warn("failed to download management asset, trying fallback page")
|
localHash, err := fileSHA256(localPath)
|
||||||
if ensureFallbackManagementHTML(ctx, client, localPath) {
|
if err != nil {
|
||||||
return
|
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) {
|
asset, remoteHash, err := fetchLatestAsset(ctx, client, releaseURL)
|
||||||
log.Warnf("remote digest mismatch for management asset: expected %s got %s", remoteHash, downloadedHash)
|
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 {
|
if remoteHash != "" && localHash != "" && strings.EqualFold(remoteHash, localHash) {
|
||||||
log.WithError(err).Warn("failed to update management asset on disk")
|
log.Debug("management asset is already up to date")
|
||||||
return
|
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 {
|
func ensureFallbackManagementHTML(ctx context.Context, client *http.Client, localPath string) bool {
|
||||||
|
|||||||
@@ -4,12 +4,16 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow"
|
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/config"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
"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("Content-Type", "application/json")
|
||||||
r.Header.Set("Authorization", "Bearer "+apiKey)
|
r.Header.Set("Authorization", "Bearer "+apiKey)
|
||||||
r.Header.Set("User-Agent", iflowUserAgent)
|
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 {
|
if stream {
|
||||||
r.Header.Set("Accept", "text/event-stream")
|
r.Header.Set("Accept", "text/event-stream")
|
||||||
} else {
|
} 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) {
|
func iflowCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
|
||||||
if a == nil {
|
if a == nil {
|
||||||
return "", ""
|
return "", ""
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
|
|||||||
if role == "developer" {
|
if role == "developer" {
|
||||||
role = "user"
|
role = "user"
|
||||||
}
|
}
|
||||||
message := `{"role":"","content":""}`
|
message := `{"role":"","content":[]}`
|
||||||
message, _ = sjson.Set(message, "role", role)
|
message, _ = sjson.Set(message, "role", role)
|
||||||
|
|
||||||
if content := item.Get("content"); content.Exists() && content.IsArray() {
|
if content := item.Get("content"); content.Exists() && content.IsArray() {
|
||||||
@@ -84,20 +84,16 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch contentType {
|
switch contentType {
|
||||||
case "input_text":
|
case "input_text", "output_text":
|
||||||
text := contentItem.Get("text").String()
|
text := contentItem.Get("text").String()
|
||||||
if messageContent != "" {
|
contentPart := `{"type":"text","text":""}`
|
||||||
messageContent += "\n" + text
|
contentPart, _ = sjson.Set(contentPart, "text", text)
|
||||||
} else {
|
message, _ = sjson.SetRaw(message, "content.-1", contentPart)
|
||||||
messageContent = text
|
case "input_image":
|
||||||
}
|
imageURL := contentItem.Get("image_url").String()
|
||||||
case "output_text":
|
contentPart := `{"type":"image_url","image_url":{"url":""}}`
|
||||||
text := contentItem.Get("text").String()
|
contentPart, _ = sjson.Set(contentPart, "image_url.url", imageURL)
|
||||||
if messageContent != "" {
|
message, _ = sjson.SetRaw(message, "content.-1", contentPart)
|
||||||
messageContent += "\n" + text
|
|
||||||
} else {
|
|
||||||
messageContent = text
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user