diff --git a/internal/api/server.go b/internal/api/server.go index f6f23b53..651441e6 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -683,14 +683,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) @@ -980,10 +983,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/config/config.go b/internal/config/config.go index 0a82257a..ecc00120 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1161,8 +1161,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 } @@ -1176,16 +1181,19 @@ 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 + 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) } } } @@ -1193,7 +1201,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 } @@ -1202,7 +1215,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 { @@ -1225,7 +1238,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]) @@ -1267,6 +1280,94 @@ 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 +} + +// 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. diff --git a/internal/managementasset/updater.go b/internal/managementasset/updater.go index c941da02..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 ( @@ -28,6 +29,7 @@ const ( defaultManagementFallbackURL = "https://cpamc.router-for.me/" managementAssetName = "management.html" httpUserAgent = "CLIProxyAPI-management-updater" + managementSyncMinInterval = 30 * time.Second updateCheckInterval = 3 * time.Hour ) @@ -37,11 +39,10 @@ const ManagementFileName = managementAssetName var ( lastUpdateCheckMu sync.Mutex lastUpdateCheckTime time.Time - currentConfigPtr atomic.Pointer[config.Config] - disableControlPanel atomic.Bool schedulerOnce sync.Once schedulerConfigPath atomic.Value + sfGroup singleflight.Group ) // SetCurrentConfig stores the latest configuration snapshot for management asset decisions. @@ -50,16 +51,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 +84,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 } @@ -181,103 +173,106 @@ 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) { +// 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() } - 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 + return false } - 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 - lastUpdateCheckMu.Lock() - now := time.Now() - timeSinceLastCheck := now.Sub(lastUpdateCheckTime) - if timeSinceLastCheck < updateCheckInterval { + _, _, _ = 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() - 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 - } - - 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 { 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", 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 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 "", "" 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 })