mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-03-30 01:06:39 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91841a5519 | ||
|
|
eaab1d6824 | ||
|
|
0cfe310df6 | ||
|
|
918b6955e4 | ||
|
|
532fbf00d4 | ||
|
|
45b6fffd7f | ||
|
|
5a3eb08739 | ||
|
|
0dff329162 | ||
|
|
49c1740b47 | ||
|
|
3fbee51e9f | ||
|
|
a3dc56d2a0 | ||
|
|
63643c44a1 | ||
|
|
1d93608dbe | ||
|
|
d125b7de92 | ||
|
|
d5654ee316 | ||
|
|
3b34521ad9 | ||
|
|
7197fb350b | ||
|
|
6e349bfcc7 | ||
|
|
234056072d | ||
|
|
76330f4bff | ||
|
|
d468eec6ec | ||
|
|
9bc6cc5b41 |
@@ -122,7 +122,7 @@ func (rw *ResponseRewriter) Flush() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// modelFieldPaths lists all JSON paths where model name may appear
|
// modelFieldPaths lists all JSON paths where model name may appear
|
||||||
var modelFieldPaths = []string{"model", "modelVersion", "response.modelVersion", "message.model"}
|
var modelFieldPaths = []string{"message.model", "model", "modelVersion", "response.model", "response.modelVersion"}
|
||||||
|
|
||||||
// rewriteModelInResponse replaces all occurrences of the mapped model with the original model in JSON
|
// rewriteModelInResponse replaces all occurrences of the mapped model with the original model in JSON
|
||||||
// It also suppresses "thinking" blocks if "tool_use" is present to ensure Amp client compatibility
|
// It also suppresses "thinking" blocks if "tool_use" is present to ensure Amp client compatibility
|
||||||
|
|||||||
110
internal/api/modules/amp/response_rewriter_test.go
Normal file
110
internal/api/modules/amp/response_rewriter_test.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package amp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRewriteModelInResponse_TopLevel(t *testing.T) {
|
||||||
|
rw := &ResponseRewriter{originalModel: "gpt-5.2-codex"}
|
||||||
|
|
||||||
|
input := []byte(`{"id":"resp_1","model":"gpt-5.3-codex","output":[]}`)
|
||||||
|
result := rw.rewriteModelInResponse(input)
|
||||||
|
|
||||||
|
expected := `{"id":"resp_1","model":"gpt-5.2-codex","output":[]}`
|
||||||
|
if string(result) != expected {
|
||||||
|
t.Errorf("expected %s, got %s", expected, string(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteModelInResponse_ResponseModel(t *testing.T) {
|
||||||
|
rw := &ResponseRewriter{originalModel: "gpt-5.2-codex"}
|
||||||
|
|
||||||
|
input := []byte(`{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5.3-codex","status":"completed"}}`)
|
||||||
|
result := rw.rewriteModelInResponse(input)
|
||||||
|
|
||||||
|
expected := `{"type":"response.completed","response":{"id":"resp_1","model":"gpt-5.2-codex","status":"completed"}}`
|
||||||
|
if string(result) != expected {
|
||||||
|
t.Errorf("expected %s, got %s", expected, string(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteModelInResponse_ResponseCreated(t *testing.T) {
|
||||||
|
rw := &ResponseRewriter{originalModel: "gpt-5.2-codex"}
|
||||||
|
|
||||||
|
input := []byte(`{"type":"response.created","response":{"id":"resp_1","model":"gpt-5.3-codex","status":"in_progress"}}`)
|
||||||
|
result := rw.rewriteModelInResponse(input)
|
||||||
|
|
||||||
|
expected := `{"type":"response.created","response":{"id":"resp_1","model":"gpt-5.2-codex","status":"in_progress"}}`
|
||||||
|
if string(result) != expected {
|
||||||
|
t.Errorf("expected %s, got %s", expected, string(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteModelInResponse_NoModelField(t *testing.T) {
|
||||||
|
rw := &ResponseRewriter{originalModel: "gpt-5.2-codex"}
|
||||||
|
|
||||||
|
input := []byte(`{"type":"response.output_item.added","item":{"id":"item_1","type":"message"}}`)
|
||||||
|
result := rw.rewriteModelInResponse(input)
|
||||||
|
|
||||||
|
if string(result) != string(input) {
|
||||||
|
t.Errorf("expected no modification, got %s", string(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteModelInResponse_EmptyOriginalModel(t *testing.T) {
|
||||||
|
rw := &ResponseRewriter{originalModel: ""}
|
||||||
|
|
||||||
|
input := []byte(`{"model":"gpt-5.3-codex"}`)
|
||||||
|
result := rw.rewriteModelInResponse(input)
|
||||||
|
|
||||||
|
if string(result) != string(input) {
|
||||||
|
t.Errorf("expected no modification when originalModel is empty, got %s", string(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteStreamChunk_SSEWithResponseModel(t *testing.T) {
|
||||||
|
rw := &ResponseRewriter{originalModel: "gpt-5.2-codex"}
|
||||||
|
|
||||||
|
chunk := []byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"model\":\"gpt-5.3-codex\",\"status\":\"completed\"}}\n\n")
|
||||||
|
result := rw.rewriteStreamChunk(chunk)
|
||||||
|
|
||||||
|
expected := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"model\":\"gpt-5.2-codex\",\"status\":\"completed\"}}\n\n"
|
||||||
|
if string(result) != expected {
|
||||||
|
t.Errorf("expected %s, got %s", expected, string(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteStreamChunk_MultipleEvents(t *testing.T) {
|
||||||
|
rw := &ResponseRewriter{originalModel: "gpt-5.2-codex"}
|
||||||
|
|
||||||
|
chunk := []byte("data: {\"type\":\"response.created\",\"response\":{\"model\":\"gpt-5.3-codex\"}}\n\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"item_1\"}}\n\n")
|
||||||
|
result := rw.rewriteStreamChunk(chunk)
|
||||||
|
|
||||||
|
if string(result) == string(chunk) {
|
||||||
|
t.Error("expected response.model to be rewritten in SSE stream")
|
||||||
|
}
|
||||||
|
if !contains(result, []byte(`"model":"gpt-5.2-codex"`)) {
|
||||||
|
t.Errorf("expected rewritten model in output, got %s", string(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewriteStreamChunk_MessageModel(t *testing.T) {
|
||||||
|
rw := &ResponseRewriter{originalModel: "claude-opus-4.5"}
|
||||||
|
|
||||||
|
chunk := []byte("data: {\"message\":{\"model\":\"claude-sonnet-4\",\"role\":\"assistant\"}}\n\n")
|
||||||
|
result := rw.rewriteStreamChunk(chunk)
|
||||||
|
|
||||||
|
expected := "data: {\"message\":{\"model\":\"claude-opus-4.5\",\"role\":\"assistant\"}}\n\n"
|
||||||
|
if string(result) != expected {
|
||||||
|
t.Errorf("expected %s, got %s", expected, string(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(data, substr []byte) bool {
|
||||||
|
for i := 0; i <= len(data)-len(substr); i++ {
|
||||||
|
if string(data[i:i+len(substr)]) == string(substr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -980,10 +983,6 @@ func (s *Server) UpdateClients(cfg *config.Config) {
|
|||||||
|
|
||||||
s.handlers.UpdateClients(&cfg.SDKConfig)
|
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 {
|
if s.mgmt != nil {
|
||||||
s.mgmt.SetConfig(cfg)
|
s.mgmt.SetConfig(cfg)
|
||||||
s.mgmt.SetAuthManager(s.handlers.AuthManager)
|
s.mgmt.SetAuthManager(s.handlers.AuthManager)
|
||||||
|
|||||||
@@ -1161,8 +1161,13 @@ func getOrCreateMapValue(mapNode *yaml.Node, key string) *yaml.Node {
|
|||||||
|
|
||||||
// mergeMappingPreserve merges keys from src into dst mapping node while preserving
|
// 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
|
// 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.
|
// value is non-zero and not a known default to avoid polluting the config with defaults.
|
||||||
func mergeMappingPreserve(dst, src *yaml.Node) {
|
func mergeMappingPreserve(dst, src *yaml.Node, path ...[]string) {
|
||||||
|
var currentPath []string
|
||||||
|
if len(path) > 0 {
|
||||||
|
currentPath = path[0]
|
||||||
|
}
|
||||||
|
|
||||||
if dst == nil || src == nil {
|
if dst == nil || src == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1176,16 +1181,19 @@ func mergeMappingPreserve(dst, src *yaml.Node) {
|
|||||||
sk := src.Content[i]
|
sk := src.Content[i]
|
||||||
sv := src.Content[i+1]
|
sv := src.Content[i+1]
|
||||||
idx := findMapKeyIndex(dst, sk.Value)
|
idx := findMapKeyIndex(dst, sk.Value)
|
||||||
|
childPath := appendPath(currentPath, sk.Value)
|
||||||
if idx >= 0 {
|
if idx >= 0 {
|
||||||
// Merge into existing value node (always update, even to zero values)
|
// Merge into existing value node (always update, even to zero values)
|
||||||
dv := dst.Content[idx+1]
|
dv := dst.Content[idx+1]
|
||||||
mergeNodePreserve(dv, sv)
|
mergeNodePreserve(dv, sv, childPath)
|
||||||
} else {
|
} else {
|
||||||
// New key: only add if value is non-zero to avoid polluting config with defaults
|
// New key: only add if value is non-zero and not a known default
|
||||||
if isZeroValueNode(sv) {
|
candidate := deepCopyNode(sv)
|
||||||
|
pruneKnownDefaultsInNewNode(childPath, candidate)
|
||||||
|
if isKnownDefaultValue(childPath, candidate) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
dst.Content = append(dst.Content, deepCopyNode(sk), deepCopyNode(sv))
|
dst.Content = append(dst.Content, deepCopyNode(sk), candidate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1193,7 +1201,12 @@ func mergeMappingPreserve(dst, src *yaml.Node) {
|
|||||||
// mergeNodePreserve merges src into dst for scalars, mappings and sequences while
|
// mergeNodePreserve merges src into dst for scalars, mappings and sequences while
|
||||||
// reusing destination nodes to keep comments and anchors. For sequences, it updates
|
// reusing destination nodes to keep comments and anchors. For sequences, it updates
|
||||||
// in-place by index.
|
// 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 {
|
if dst == nil || src == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1202,7 +1215,7 @@ func mergeNodePreserve(dst, src *yaml.Node) {
|
|||||||
if dst.Kind != yaml.MappingNode {
|
if dst.Kind != yaml.MappingNode {
|
||||||
copyNodeShallow(dst, src)
|
copyNodeShallow(dst, src)
|
||||||
}
|
}
|
||||||
mergeMappingPreserve(dst, src)
|
mergeMappingPreserve(dst, src, currentPath)
|
||||||
case yaml.SequenceNode:
|
case yaml.SequenceNode:
|
||||||
// Preserve explicit null style if dst was null and src is empty sequence
|
// 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 {
|
if dst.Kind == yaml.ScalarNode && dst.Tag == "!!null" && len(src.Content) == 0 {
|
||||||
@@ -1225,7 +1238,7 @@ func mergeNodePreserve(dst, src *yaml.Node) {
|
|||||||
dst.Content[i] = deepCopyNode(src.Content[i])
|
dst.Content[i] = deepCopyNode(src.Content[i])
|
||||||
continue
|
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 &&
|
if dst.Content[i] != nil && src.Content[i] != nil &&
|
||||||
dst.Content[i].Kind == yaml.MappingNode && src.Content[i].Kind == yaml.MappingNode {
|
dst.Content[i].Kind == yaml.MappingNode && src.Content[i].Kind == yaml.MappingNode {
|
||||||
pruneMissingMapKeys(dst.Content[i], src.Content[i])
|
pruneMissingMapKeys(dst.Content[i], src.Content[i])
|
||||||
@@ -1267,6 +1280,94 @@ func findMapKeyIndex(mapNode *yaml.Node, key string) int {
|
|||||||
return -1
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// 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.
|
// 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.
|
// For mappings and sequences, recursively checks if all children are zero values.
|
||||||
|
|||||||
@@ -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 (
|
||||||
@@ -28,6 +29,7 @@ const (
|
|||||||
defaultManagementFallbackURL = "https://cpamc.router-for.me/"
|
defaultManagementFallbackURL = "https://cpamc.router-for.me/"
|
||||||
managementAssetName = "management.html"
|
managementAssetName = "management.html"
|
||||||
httpUserAgent = "CLIProxyAPI-management-updater"
|
httpUserAgent = "CLIProxyAPI-management-updater"
|
||||||
|
managementSyncMinInterval = 30 * time.Second
|
||||||
updateCheckInterval = 3 * time.Hour
|
updateCheckInterval = 3 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,11 +39,10 @@ const ManagementFileName = managementAssetName
|
|||||||
var (
|
var (
|
||||||
lastUpdateCheckMu sync.Mutex
|
lastUpdateCheckMu sync.Mutex
|
||||||
lastUpdateCheckTime time.Time
|
lastUpdateCheckTime time.Time
|
||||||
|
|
||||||
currentConfigPtr atomic.Pointer[config.Config]
|
currentConfigPtr atomic.Pointer[config.Config]
|
||||||
disableControlPanel atomic.Bool
|
|
||||||
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.
|
||||||
@@ -50,16 +51,7 @@ func SetCurrentConfig(cfg *config.Config) {
|
|||||||
currentConfigPtr.Store(nil)
|
currentConfigPtr.Store(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
prevDisabled := disableControlPanel.Load()
|
|
||||||
currentConfigPtr.Store(cfg)
|
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.
|
// StartAutoUpdater launches a background goroutine that periodically ensures the management asset is up to date.
|
||||||
@@ -92,7 +84,7 @@ func runAutoUpdater(ctx context.Context) {
|
|||||||
log.Debug("management asset auto-updater skipped: config not yet available")
|
log.Debug("management asset auto-updater skipped: config not yet available")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if disableControlPanel.Load() {
|
if cfg.RemoteManagement.DisableControlPanel {
|
||||||
log.Debug("management asset auto-updater skipped: control panel disabled")
|
log.Debug("management asset auto-updater skipped: control panel disabled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -181,103 +173,106 @@ 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.
|
||||||
// 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) bool {
|
||||||
func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string, panelRepository string) {
|
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
}
|
}
|
||||||
|
|
||||||
if disableControlPanel.Load() {
|
|
||||||
log.Debug("management asset sync skipped: control panel disabled by configuration")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
localPath := filepath.Join(staticDir, managementAssetName)
|
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rate limiting: check only once every 3 hours
|
_, _, _ = sfGroup.Do(localPath, func() (interface{}, error) {
|
||||||
lastUpdateCheckMu.Lock()
|
lastUpdateCheckMu.Lock()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
timeSinceLastCheck := now.Sub(lastUpdateCheckTime)
|
timeSinceLastAttempt := now.Sub(lastUpdateCheckTime)
|
||||||
if timeSinceLastCheck < updateCheckInterval {
|
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()
|
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 {
|
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 {
|
||||||
|
|||||||
@@ -277,6 +277,18 @@ func GetGitHubCopilotModels() []*ModelInfo {
|
|||||||
MaxCompletionTokens: 64000,
|
MaxCompletionTokens: 64000,
|
||||||
SupportedEndpoints: []string{"/chat/completions"},
|
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",
|
ID: "claude-sonnet-4",
|
||||||
Object: "model",
|
Object: "model",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -33,11 +34,11 @@ const (
|
|||||||
maxScannerBufferSize = 20_971_520
|
maxScannerBufferSize = 20_971_520
|
||||||
|
|
||||||
// Copilot API header values.
|
// Copilot API header values.
|
||||||
copilotUserAgent = "GithubCopilot/1.0"
|
copilotUserAgent = "GitHubCopilotChat/0.35.0"
|
||||||
copilotEditorVersion = "vscode/1.100.0"
|
copilotEditorVersion = "vscode/1.107.0"
|
||||||
copilotPluginVersion = "copilot/1.300.0"
|
copilotPluginVersion = "copilot-chat/0.35.0"
|
||||||
copilotIntegrationID = "vscode-chat"
|
copilotIntegrationID = "vscode-chat"
|
||||||
copilotOpenAIIntent = "conversation-panel"
|
copilotOpenAIIntent = "conversation-edits"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GitHubCopilotExecutor handles requests to the GitHub Copilot API.
|
// GitHubCopilotExecutor handles requests to the GitHub Copilot API.
|
||||||
@@ -77,7 +78,7 @@ func (e *GitHubCopilotExecutor) PrepareRequest(req *http.Request, auth *cliproxy
|
|||||||
if errToken != nil {
|
if errToken != nil {
|
||||||
return errToken
|
return errToken
|
||||||
}
|
}
|
||||||
e.applyHeaders(req, apiToken)
|
e.applyHeaders(req, apiToken, nil)
|
||||||
return 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)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, false)
|
||||||
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
||||||
body = e.normalizeModel(req.Model, body)
|
body = e.normalizeModel(req.Model, body)
|
||||||
|
body = flattenAssistantContent(body)
|
||||||
requestedModel := payloadRequestedModel(opts, req.Model)
|
requestedModel := payloadRequestedModel(opts, req.Model)
|
||||||
body = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", body, originalTranslated, requestedModel)
|
body = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", body, originalTranslated, requestedModel)
|
||||||
body, _ = sjson.SetBytes(body, "stream", false)
|
body, _ = sjson.SetBytes(body, "stream", false)
|
||||||
@@ -133,7 +135,7 @@ func (e *GitHubCopilotExecutor) Execute(ctx context.Context, auth *cliproxyauth.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
e.applyHeaders(httpReq, apiToken)
|
e.applyHeaders(httpReq, apiToken, body)
|
||||||
|
|
||||||
// Add Copilot-Vision-Request header if the request contains vision content
|
// Add Copilot-Vision-Request header if the request contains vision content
|
||||||
if detectVisionContent(body) {
|
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)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, req.Model, originalPayload, false)
|
||||||
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
|
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
|
||||||
body = e.normalizeModel(req.Model, body)
|
body = e.normalizeModel(req.Model, body)
|
||||||
|
body = flattenAssistantContent(body)
|
||||||
requestedModel := payloadRequestedModel(opts, req.Model)
|
requestedModel := payloadRequestedModel(opts, req.Model)
|
||||||
body = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", body, originalTranslated, requestedModel)
|
body = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", body, originalTranslated, requestedModel)
|
||||||
body, _ = sjson.SetBytes(body, "stream", true)
|
body, _ = sjson.SetBytes(body, "stream", true)
|
||||||
@@ -242,7 +245,7 @@ func (e *GitHubCopilotExecutor) ExecuteStream(ctx context.Context, auth *cliprox
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
e.applyHeaders(httpReq, apiToken)
|
e.applyHeaders(httpReq, apiToken, body)
|
||||||
|
|
||||||
// Add Copilot-Vision-Request header if the request contains vision content
|
// Add Copilot-Vision-Request header if the request contains vision content
|
||||||
if detectVisionContent(body) {
|
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.
|
// 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("Content-Type", "application/json")
|
||||||
r.Header.Set("Authorization", "Bearer "+apiToken)
|
r.Header.Set("Authorization", "Bearer "+apiToken)
|
||||||
r.Header.Set("Accept", "application/json")
|
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("Openai-Intent", copilotOpenAIIntent)
|
||||||
r.Header.Set("Copilot-Integration-Id", copilotIntegrationID)
|
r.Header.Set("Copilot-Integration-Id", copilotIntegrationID)
|
||||||
r.Header.Set("X-Request-Id", uuid.NewString())
|
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.
|
// 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"
|
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).
|
// isHTTPSuccess checks if the status code indicates success (2xx).
|
||||||
func isHTTPSuccess(statusCode int) bool {
|
func isHTTPSuccess(statusCode int) bool {
|
||||||
return statusCode >= 200 && statusCode < 300
|
return statusCode >= 200 && statusCode < 300
|
||||||
|
|||||||
@@ -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