Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63643c44a1 | ||
|
|
3b34521ad9 | ||
|
|
7197fb350b | ||
|
|
6e349bfcc7 | ||
|
|
234056072d | ||
|
|
7e9d0db6aa | ||
|
|
2f1874ede5 | ||
|
|
78ef04fcf1 | ||
|
|
b7e4f00c5f | ||
|
|
52364af5bf |
@@ -27,10 +27,6 @@ Get 10% OFF GLM CODING PLAN:https://z.ai/subscribe?ic=8JVLJQFSKB
|
|||||||
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cliproxyapi">this link</a> and enter the "cliproxyapi" promo code during recharge to get 10% off.</td>
|
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cliproxyapi">this link</a> and enter the "cliproxyapi" promo code during recharge to get 10% off.</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa"><img src="./assets/cubence.png" alt="Cubence" width="150"></a></td>
|
|
||||||
<td>Thanks to Cubence for sponsoring this project! Cubence is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. Cubence provides special discounts for our software users: register using <a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa">this link</a> and enter the "CLIPROXYAPI" promo code during recharge to get 10% off.</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=TJNAIF"><img src="./assets/aicodemirror.png" alt="AICodeMirror" width="150"></a></td>
|
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=TJNAIF"><img src="./assets/aicodemirror.png" alt="AICodeMirror" width="150"></a></td>
|
||||||
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for CLIProxyAPI users: register via <a href="https://www.aicodemirror.com/register?invitecode=TJNAIF">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
|
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for CLIProxyAPI users: register via <a href="https://www.aicodemirror.com/register?invitecode=TJNAIF">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -27,10 +27,6 @@ GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元
|
|||||||
<td>感谢 PackyCode 对本项目的赞助!PackyCode 是一家可靠高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种服务的中转。PackyCode 为本软件用户提供了特别优惠:使用<a href="https://www.packyapi.com/register?aff=cliproxyapi">此链接</a>注册,并在充值时输入 "cliproxyapi" 优惠码即可享受九折优惠。</td>
|
<td>感谢 PackyCode 对本项目的赞助!PackyCode 是一家可靠高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种服务的中转。PackyCode 为本软件用户提供了特别优惠:使用<a href="https://www.packyapi.com/register?aff=cliproxyapi">此链接</a>注册,并在充值时输入 "cliproxyapi" 优惠码即可享受九折优惠。</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa"><img src="./assets/cubence.png" alt="Cubence" width="150"></a></td>
|
|
||||||
<td>感谢 Cubence 对本项目的赞助!Cubence 是一家可靠高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种服务的中转。Cubence 为本软件用户提供了特别优惠:使用<a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa">此链接</a>注册,并在充值时输入 "CLIPROXYAPI" 优惠码即可享受九折优惠。</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=TJNAIF"><img src="./assets/aicodemirror.png" alt="AICodeMirror" width="150"></a></td>
|
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=TJNAIF"><img src="./assets/aicodemirror.png" alt="AICodeMirror" width="150"></a></td>
|
||||||
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务,支持企业级高并发、极速开票、7×24 专属技术支持。 Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 CLIProxyAPI 的用户提供了特别福利,通过<a href="https://www.aicodemirror.com/register?invitecode=TJNAIF">此链接</a>注册的用户,可享受首充8折,企业客户最高可享 7.5 折!</td>
|
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务,支持企业级高并发、极速开票、7×24 专属技术支持。 Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 CLIProxyAPI 的用户提供了特别福利,通过<a href="https://www.aicodemirror.com/register?invitecode=TJNAIF">此链接</a>注册的用户,可享受首充8折,企业客户最高可享 7.5 折!</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
@@ -952,10 +952,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)
|
||||||
|
|||||||
@@ -1098,8 +1098,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
|
||||||
}
|
}
|
||||||
@@ -1113,16 +1118,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1130,7 +1138,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
|
||||||
}
|
}
|
||||||
@@ -1139,7 +1152,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 {
|
||||||
@@ -1162,7 +1175,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])
|
||||||
@@ -1204,6 +1217,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.
|
||||||
|
|||||||
@@ -28,6 +28,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,9 +38,7 @@ 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
|
||||||
)
|
)
|
||||||
@@ -50,16 +49,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 +82,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
|
||||||
}
|
}
|
||||||
@@ -182,23 +172,32 @@ 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.
|
// 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) {
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
localFileMissing := false
|
||||||
if _, errStat := os.Stat(localPath); errStat != nil {
|
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 {
|
if errMkdirAll := os.MkdirAll(staticDir, 0o755); errMkdirAll != nil {
|
||||||
log.WithError(errMkdirAll).Warn("failed to prepare static directory for management asset")
|
log.WithError(errMkdirAll).Warn("failed to prepare static directory for management asset")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -79,10 +80,11 @@ func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
|
|||||||
defer reporter.trackFailure(ctx, &err)
|
defer reporter.trackFailure(ctx, &err)
|
||||||
|
|
||||||
to := sdktranslator.FromString("openai")
|
to := sdktranslator.FromString("openai")
|
||||||
originalPayload := bytes.Clone(req.Payload)
|
originalPayloadSource := req.Payload
|
||||||
if len(opts.OriginalRequest) > 0 {
|
if len(opts.OriginalRequest) > 0 {
|
||||||
originalPayload = bytes.Clone(opts.OriginalRequest)
|
originalPayloadSource = opts.OriginalRequest
|
||||||
}
|
}
|
||||||
|
originalPayload := bytes.Clone(originalPayloadSource)
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
|
||||||
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
|
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false)
|
||||||
|
|
||||||
@@ -100,6 +102,10 @@ func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
|
|||||||
|
|
||||||
requestedModel := payloadRequestedModel(opts, req.Model)
|
requestedModel := payloadRequestedModel(opts, req.Model)
|
||||||
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
|
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
|
||||||
|
body, err = normalizeKimiToolMessageLinks(body)
|
||||||
|
if err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
url := kimiauth.KimiAPIBaseURL + "/v1/chat/completions"
|
url := kimiauth.KimiAPIBaseURL + "/v1/chat/completions"
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
@@ -154,7 +160,7 @@ func (e *KimiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
|
|||||||
var param any
|
var param any
|
||||||
// Note: TranslateNonStream uses req.Model (original with suffix) to preserve
|
// Note: TranslateNonStream uses req.Model (original with suffix) to preserve
|
||||||
// the original model name in the response for client compatibility.
|
// the original model name in the response for client compatibility.
|
||||||
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, data, ¶m)
|
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, ¶m)
|
||||||
resp = cliproxyexecutor.Response{Payload: []byte(out)}
|
resp = cliproxyexecutor.Response{Payload: []byte(out)}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
@@ -174,10 +180,11 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
|
|||||||
defer reporter.trackFailure(ctx, &err)
|
defer reporter.trackFailure(ctx, &err)
|
||||||
|
|
||||||
to := sdktranslator.FromString("openai")
|
to := sdktranslator.FromString("openai")
|
||||||
originalPayload := bytes.Clone(req.Payload)
|
originalPayloadSource := req.Payload
|
||||||
if len(opts.OriginalRequest) > 0 {
|
if len(opts.OriginalRequest) > 0 {
|
||||||
originalPayload = bytes.Clone(opts.OriginalRequest)
|
originalPayloadSource = opts.OriginalRequest
|
||||||
}
|
}
|
||||||
|
originalPayload := bytes.Clone(originalPayloadSource)
|
||||||
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
|
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
|
||||||
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
|
body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true)
|
||||||
|
|
||||||
@@ -199,6 +206,10 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
|
|||||||
}
|
}
|
||||||
requestedModel := payloadRequestedModel(opts, req.Model)
|
requestedModel := payloadRequestedModel(opts, req.Model)
|
||||||
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
|
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
|
||||||
|
body, err = normalizeKimiToolMessageLinks(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
url := kimiauth.KimiAPIBaseURL + "/v1/chat/completions"
|
url := kimiauth.KimiAPIBaseURL + "/v1/chat/completions"
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
@@ -259,12 +270,12 @@ func (e *KimiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
|
|||||||
if detail, ok := parseOpenAIStreamUsage(line); ok {
|
if detail, ok := parseOpenAIStreamUsage(line); ok {
|
||||||
reporter.publish(ctx, detail)
|
reporter.publish(ctx, detail)
|
||||||
}
|
}
|
||||||
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone(line), ¶m)
|
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), ¶m)
|
||||||
for i := range chunks {
|
for i := range chunks {
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
|
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
doneChunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, bytes.Clone([]byte("[DONE]")), ¶m)
|
doneChunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m)
|
||||||
for i := range doneChunks {
|
for i := range doneChunks {
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(doneChunks[i])}
|
out <- cliproxyexecutor.StreamChunk{Payload: []byte(doneChunks[i])}
|
||||||
}
|
}
|
||||||
@@ -283,6 +294,150 @@ func (e *KimiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth,
|
|||||||
return e.ClaudeExecutor.CountTokens(ctx, auth, req, opts)
|
return e.ClaudeExecutor.CountTokens(ctx, auth, req, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeKimiToolMessageLinks(body []byte) ([]byte, error) {
|
||||||
|
if len(body) == 0 || !gjson.ValidBytes(body) {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := gjson.GetBytes(body, "messages")
|
||||||
|
if !messages.Exists() || !messages.IsArray() {
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
out := body
|
||||||
|
pending := make([]string, 0)
|
||||||
|
patched := 0
|
||||||
|
patchedReasoning := 0
|
||||||
|
ambiguous := 0
|
||||||
|
latestReasoning := ""
|
||||||
|
hasLatestReasoning := false
|
||||||
|
|
||||||
|
removePending := func(id string) {
|
||||||
|
for idx := range pending {
|
||||||
|
if pending[idx] != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pending = append(pending[:idx], pending[idx+1:]...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs := messages.Array()
|
||||||
|
for msgIdx := range msgs {
|
||||||
|
msg := msgs[msgIdx]
|
||||||
|
role := strings.TrimSpace(msg.Get("role").String())
|
||||||
|
switch role {
|
||||||
|
case "assistant":
|
||||||
|
reasoning := msg.Get("reasoning_content")
|
||||||
|
if reasoning.Exists() {
|
||||||
|
reasoningText := reasoning.String()
|
||||||
|
if strings.TrimSpace(reasoningText) != "" {
|
||||||
|
latestReasoning = reasoningText
|
||||||
|
hasLatestReasoning = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toolCalls := msg.Get("tool_calls")
|
||||||
|
if !toolCalls.Exists() || !toolCalls.IsArray() || len(toolCalls.Array()) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reasoning.Exists() || strings.TrimSpace(reasoning.String()) == "" {
|
||||||
|
reasoningText := fallbackAssistantReasoning(msg, hasLatestReasoning, latestReasoning)
|
||||||
|
path := fmt.Sprintf("messages.%d.reasoning_content", msgIdx)
|
||||||
|
next, err := sjson.SetBytes(out, path, reasoningText)
|
||||||
|
if err != nil {
|
||||||
|
return body, fmt.Errorf("kimi executor: failed to set assistant reasoning_content: %w", err)
|
||||||
|
}
|
||||||
|
out = next
|
||||||
|
patchedReasoning++
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range toolCalls.Array() {
|
||||||
|
id := strings.TrimSpace(tc.Get("id").String())
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pending = append(pending, id)
|
||||||
|
}
|
||||||
|
case "tool":
|
||||||
|
toolCallID := strings.TrimSpace(msg.Get("tool_call_id").String())
|
||||||
|
if toolCallID == "" {
|
||||||
|
toolCallID = strings.TrimSpace(msg.Get("call_id").String())
|
||||||
|
if toolCallID != "" {
|
||||||
|
path := fmt.Sprintf("messages.%d.tool_call_id", msgIdx)
|
||||||
|
next, err := sjson.SetBytes(out, path, toolCallID)
|
||||||
|
if err != nil {
|
||||||
|
return body, fmt.Errorf("kimi executor: failed to set tool_call_id from call_id: %w", err)
|
||||||
|
}
|
||||||
|
out = next
|
||||||
|
patched++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if toolCallID == "" {
|
||||||
|
if len(pending) == 1 {
|
||||||
|
toolCallID = pending[0]
|
||||||
|
path := fmt.Sprintf("messages.%d.tool_call_id", msgIdx)
|
||||||
|
next, err := sjson.SetBytes(out, path, toolCallID)
|
||||||
|
if err != nil {
|
||||||
|
return body, fmt.Errorf("kimi executor: failed to infer tool_call_id: %w", err)
|
||||||
|
}
|
||||||
|
out = next
|
||||||
|
patched++
|
||||||
|
} else if len(pending) > 1 {
|
||||||
|
ambiguous++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if toolCallID != "" {
|
||||||
|
removePending(toolCallID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if patched > 0 || patchedReasoning > 0 {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"patched_tool_messages": patched,
|
||||||
|
"patched_reasoning_messages": patchedReasoning,
|
||||||
|
}).Debug("kimi executor: normalized tool message fields")
|
||||||
|
}
|
||||||
|
if ambiguous > 0 {
|
||||||
|
log.WithFields(log.Fields{
|
||||||
|
"ambiguous_tool_messages": ambiguous,
|
||||||
|
"pending_tool_calls": len(pending),
|
||||||
|
}).Warn("kimi executor: tool messages missing tool_call_id with ambiguous candidates")
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fallbackAssistantReasoning(msg gjson.Result, hasLatest bool, latest string) string {
|
||||||
|
if hasLatest && strings.TrimSpace(latest) != "" {
|
||||||
|
return latest
|
||||||
|
}
|
||||||
|
|
||||||
|
content := msg.Get("content")
|
||||||
|
if content.Type == gjson.String {
|
||||||
|
if text := strings.TrimSpace(content.String()); text != "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if content.IsArray() {
|
||||||
|
parts := make([]string, 0, len(content.Array()))
|
||||||
|
for _, item := range content.Array() {
|
||||||
|
text := strings.TrimSpace(item.Get("text").String())
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts = append(parts, text)
|
||||||
|
}
|
||||||
|
if len(parts) > 0 {
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "[reasoning unavailable]"
|
||||||
|
}
|
||||||
|
|
||||||
// Refresh refreshes the Kimi token using the refresh token.
|
// Refresh refreshes the Kimi token using the refresh token.
|
||||||
func (e *KimiExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
|
func (e *KimiExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
|
||||||
log.Debugf("kimi executor: refresh called")
|
log.Debugf("kimi executor: refresh called")
|
||||||
|
|||||||
205
internal/runtime/executor/kimi_executor_test.go
Normal file
205
internal/runtime/executor/kimi_executor_test.go
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeKimiToolMessageLinks_UsesCallIDFallback(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"messages":[
|
||||||
|
{"role":"assistant","tool_calls":[{"id":"list_directory:1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]},
|
||||||
|
{"role":"tool","call_id":"list_directory:1","content":"[]"}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out, err := normalizeKimiToolMessageLinks(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := gjson.GetBytes(out, "messages.1.tool_call_id").String()
|
||||||
|
if got != "list_directory:1" {
|
||||||
|
t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "list_directory:1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeKimiToolMessageLinks_InferSinglePendingID(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"messages":[
|
||||||
|
{"role":"assistant","tool_calls":[{"id":"call_123","type":"function","function":{"name":"read_file","arguments":"{}"}}]},
|
||||||
|
{"role":"tool","content":"file-content"}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out, err := normalizeKimiToolMessageLinks(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := gjson.GetBytes(out, "messages.1.tool_call_id").String()
|
||||||
|
if got != "call_123" {
|
||||||
|
t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "call_123")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeKimiToolMessageLinks_AmbiguousMissingIDIsNotInferred(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"messages":[
|
||||||
|
{"role":"assistant","tool_calls":[
|
||||||
|
{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}},
|
||||||
|
{"id":"call_2","type":"function","function":{"name":"read_file","arguments":"{}"}}
|
||||||
|
]},
|
||||||
|
{"role":"tool","content":"result-without-id"}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out, err := normalizeKimiToolMessageLinks(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gjson.GetBytes(out, "messages.1.tool_call_id").Exists() {
|
||||||
|
t.Fatalf("messages.1.tool_call_id should be absent for ambiguous case, got %q", gjson.GetBytes(out, "messages.1.tool_call_id").String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeKimiToolMessageLinks_PreservesExistingToolCallID(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"messages":[
|
||||||
|
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]},
|
||||||
|
{"role":"tool","tool_call_id":"call_1","call_id":"different-id","content":"result"}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out, err := normalizeKimiToolMessageLinks(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := gjson.GetBytes(out, "messages.1.tool_call_id").String()
|
||||||
|
if got != "call_1" {
|
||||||
|
t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "call_1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeKimiToolMessageLinks_InheritsPreviousReasoningForAssistantToolCalls(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"messages":[
|
||||||
|
{"role":"assistant","content":"plan","reasoning_content":"previous reasoning"},
|
||||||
|
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out, err := normalizeKimiToolMessageLinks(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := gjson.GetBytes(out, "messages.1.reasoning_content").String()
|
||||||
|
if got != "previous reasoning" {
|
||||||
|
t.Fatalf("messages.1.reasoning_content = %q, want %q", got, "previous reasoning")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeKimiToolMessageLinks_InsertsFallbackReasoningWhenMissing(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"messages":[
|
||||||
|
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out, err := normalizeKimiToolMessageLinks(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reasoning := gjson.GetBytes(out, "messages.0.reasoning_content")
|
||||||
|
if !reasoning.Exists() {
|
||||||
|
t.Fatalf("messages.0.reasoning_content should exist")
|
||||||
|
}
|
||||||
|
if reasoning.String() != "[reasoning unavailable]" {
|
||||||
|
t.Fatalf("messages.0.reasoning_content = %q, want %q", reasoning.String(), "[reasoning unavailable]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeKimiToolMessageLinks_UsesContentAsReasoningFallback(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"messages":[
|
||||||
|
{"role":"assistant","content":[{"type":"text","text":"first line"},{"type":"text","text":"second line"}],"tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}]}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out, err := normalizeKimiToolMessageLinks(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := gjson.GetBytes(out, "messages.0.reasoning_content").String()
|
||||||
|
if got != "first line\nsecond line" {
|
||||||
|
t.Fatalf("messages.0.reasoning_content = %q, want %q", got, "first line\nsecond line")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeKimiToolMessageLinks_ReplacesEmptyReasoningContent(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"messages":[
|
||||||
|
{"role":"assistant","content":"assistant summary","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}],"reasoning_content":""}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out, err := normalizeKimiToolMessageLinks(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := gjson.GetBytes(out, "messages.0.reasoning_content").String()
|
||||||
|
if got != "assistant summary" {
|
||||||
|
t.Fatalf("messages.0.reasoning_content = %q, want %q", got, "assistant summary")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeKimiToolMessageLinks_PreservesExistingAssistantReasoning(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"messages":[
|
||||||
|
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}],"reasoning_content":"keep me"}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out, err := normalizeKimiToolMessageLinks(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := gjson.GetBytes(out, "messages.0.reasoning_content").String()
|
||||||
|
if got != "keep me" {
|
||||||
|
t.Fatalf("messages.0.reasoning_content = %q, want %q", got, "keep me")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeKimiToolMessageLinks_RepairsIDsAndReasoningTogether(t *testing.T) {
|
||||||
|
body := []byte(`{
|
||||||
|
"messages":[
|
||||||
|
{"role":"assistant","tool_calls":[{"id":"call_1","type":"function","function":{"name":"list_directory","arguments":"{}"}}],"reasoning_content":"r1"},
|
||||||
|
{"role":"tool","call_id":"call_1","content":"[]"},
|
||||||
|
{"role":"assistant","tool_calls":[{"id":"call_2","type":"function","function":{"name":"read_file","arguments":"{}"}}]},
|
||||||
|
{"role":"tool","call_id":"call_2","content":"file"}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
out, err := normalizeKimiToolMessageLinks(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeKimiToolMessageLinks() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := gjson.GetBytes(out, "messages.1.tool_call_id").String(); got != "call_1" {
|
||||||
|
t.Fatalf("messages.1.tool_call_id = %q, want %q", got, "call_1")
|
||||||
|
}
|
||||||
|
if got := gjson.GetBytes(out, "messages.3.tool_call_id").String(); got != "call_2" {
|
||||||
|
t.Fatalf("messages.3.tool_call_id = %q, want %q", got, "call_2")
|
||||||
|
}
|
||||||
|
if got := gjson.GetBytes(out, "messages.2.reasoning_content").String(); got != "r1" {
|
||||||
|
t.Fatalf("messages.2.reasoning_content = %q, want %q", got, "r1")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,7 +109,7 @@ func ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJ
|
|||||||
var err error
|
var err error
|
||||||
template, err = sjson.Set(template, "usage.prompt_tokens_details.cached_tokens", cachedTokenCount)
|
template, err = sjson.Set(template, "usage.prompt_tokens_details.cached_tokens", cachedTokenCount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("antigravity openai response: failed to set cached_tokens: %v", err)
|
log.Warnf("gemini-cli openai response: failed to set cached_tokens: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -59,4 +59,3 @@ func TestUpdateAggregatedAvailability_FutureNextRetryBlocksAuth(t *testing.T) {
|
|||||||
t.Fatalf("auth.NextRetryAfter = %v, want %v", auth.NextRetryAfter, next)
|
t.Fatalf("auth.NextRetryAfter = %v, want %v", auth.NextRetryAfter, next)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
package cliproxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestOAuthExcludedModels_KimiOAuth(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
svc := &Service{
|
|
||||||
cfg: &config.Config{
|
|
||||||
OAuthExcludedModels: map[string][]string{
|
|
||||||
"kimi": {"kimi-k2-thinking", "kimi-k2.5"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
got := svc.oauthExcludedModels("kimi", "oauth")
|
|
||||||
if len(got) != 2 {
|
|
||||||
t.Fatalf("expected 2 excluded models, got %d", len(got))
|
|
||||||
}
|
|
||||||
if got[0] != "kimi-k2-thinking" || got[1] != "kimi-k2.5" {
|
|
||||||
t.Fatalf("unexpected excluded models: %#v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOAuthExcludedModels_KimiAPIKeyReturnsNil(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
svc := &Service{
|
|
||||||
cfg: &config.Config{
|
|
||||||
OAuthExcludedModels: map[string][]string{
|
|
||||||
"kimi": {"kimi-k2-thinking"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
got := svc.oauthExcludedModels("kimi", "apikey")
|
|
||||||
if got != nil {
|
|
||||||
t.Fatalf("expected nil for apikey auth kind, got %#v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -90,27 +90,3 @@ func TestApplyOAuthModelAlias_ForkAddsMultipleAliases(t *testing.T) {
|
|||||||
t.Fatalf("expected forked model name %q, got %q", "models/g5-2", out[2].Name)
|
t.Fatalf("expected forked model name %q, got %q", "models/g5-2", out[2].Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyOAuthModelAlias_KimiRename(t *testing.T) {
|
|
||||||
cfg := &config.Config{
|
|
||||||
OAuthModelAlias: map[string][]config.OAuthModelAlias{
|
|
||||||
"kimi": {
|
|
||||||
{Name: "kimi-k2.5", Alias: "k2.5"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
models := []*ModelInfo{
|
|
||||||
{ID: "kimi-k2.5", Name: "models/kimi-k2.5"},
|
|
||||||
}
|
|
||||||
|
|
||||||
out := applyOAuthModelAlias(cfg, "kimi", "oauth", models)
|
|
||||||
if len(out) != 1 {
|
|
||||||
t.Fatalf("expected 1 model, got %d", len(out))
|
|
||||||
}
|
|
||||||
if out[0].ID != "k2.5" {
|
|
||||||
t.Fatalf("expected model id %q, got %q", "k2.5", out[0].ID)
|
|
||||||
}
|
|
||||||
if out[0].Name != "models/k2.5" {
|
|
||||||
t.Fatalf("expected model name %q, got %q", "models/k2.5", out[0].Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
package test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLegacyConfigMigration(t *testing.T) {
|
|
||||||
t.Run("onlyLegacyFields", func(t *testing.T) {
|
|
||||||
path := writeConfig(t, `
|
|
||||||
port: 8080
|
|
||||||
generative-language-api-key:
|
|
||||||
- "legacy-gemini-1"
|
|
||||||
openai-compatibility:
|
|
||||||
- name: "legacy-provider"
|
|
||||||
base-url: "https://example.com"
|
|
||||||
api-keys:
|
|
||||||
- "legacy-openai-1"
|
|
||||||
amp-upstream-url: "https://amp.example.com"
|
|
||||||
amp-upstream-api-key: "amp-legacy-key"
|
|
||||||
amp-restrict-management-to-localhost: false
|
|
||||||
amp-model-mappings:
|
|
||||||
- from: "old-model"
|
|
||||||
to: "new-model"
|
|
||||||
`)
|
|
||||||
cfg, err := config.LoadConfig(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load legacy config: %v", err)
|
|
||||||
}
|
|
||||||
if got := len(cfg.GeminiKey); got != 1 || cfg.GeminiKey[0].APIKey != "legacy-gemini-1" {
|
|
||||||
t.Fatalf("gemini migration mismatch: %+v", cfg.GeminiKey)
|
|
||||||
}
|
|
||||||
if got := len(cfg.OpenAICompatibility); got != 1 {
|
|
||||||
t.Fatalf("expected 1 openai-compat provider, got %d", got)
|
|
||||||
}
|
|
||||||
if entries := cfg.OpenAICompatibility[0].APIKeyEntries; len(entries) != 1 || entries[0].APIKey != "legacy-openai-1" {
|
|
||||||
t.Fatalf("openai-compat migration mismatch: %+v", entries)
|
|
||||||
}
|
|
||||||
if cfg.AmpCode.UpstreamURL != "https://amp.example.com" || cfg.AmpCode.UpstreamAPIKey != "amp-legacy-key" {
|
|
||||||
t.Fatalf("amp migration failed: %+v", cfg.AmpCode)
|
|
||||||
}
|
|
||||||
if cfg.AmpCode.RestrictManagementToLocalhost {
|
|
||||||
t.Fatalf("expected amp restriction to be false after migration")
|
|
||||||
}
|
|
||||||
if got := len(cfg.AmpCode.ModelMappings); got != 1 || cfg.AmpCode.ModelMappings[0].From != "old-model" {
|
|
||||||
t.Fatalf("amp mappings migration mismatch: %+v", cfg.AmpCode.ModelMappings)
|
|
||||||
}
|
|
||||||
updated := readFile(t, path)
|
|
||||||
if strings.Contains(updated, "generative-language-api-key") {
|
|
||||||
t.Fatalf("legacy gemini key still present:\n%s", updated)
|
|
||||||
}
|
|
||||||
if strings.Contains(updated, "amp-upstream-url") || strings.Contains(updated, "amp-restrict-management-to-localhost") {
|
|
||||||
t.Fatalf("legacy amp keys still present:\n%s", updated)
|
|
||||||
}
|
|
||||||
if strings.Contains(updated, "\n api-keys:") {
|
|
||||||
t.Fatalf("legacy openai compat keys still present:\n%s", updated)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("mixedLegacyAndNewFields", func(t *testing.T) {
|
|
||||||
path := writeConfig(t, `
|
|
||||||
gemini-api-key:
|
|
||||||
- api-key: "new-gemini"
|
|
||||||
generative-language-api-key:
|
|
||||||
- "new-gemini"
|
|
||||||
- "legacy-gemini-only"
|
|
||||||
openai-compatibility:
|
|
||||||
- name: "mixed-provider"
|
|
||||||
base-url: "https://mixed.example.com"
|
|
||||||
api-key-entries:
|
|
||||||
- api-key: "new-entry"
|
|
||||||
api-keys:
|
|
||||||
- "legacy-entry"
|
|
||||||
- "new-entry"
|
|
||||||
`)
|
|
||||||
cfg, err := config.LoadConfig(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load mixed config: %v", err)
|
|
||||||
}
|
|
||||||
if got := len(cfg.GeminiKey); got != 2 {
|
|
||||||
t.Fatalf("expected 2 gemini entries, got %d: %+v", got, cfg.GeminiKey)
|
|
||||||
}
|
|
||||||
seen := make(map[string]struct{}, len(cfg.GeminiKey))
|
|
||||||
for _, entry := range cfg.GeminiKey {
|
|
||||||
if _, exists := seen[entry.APIKey]; exists {
|
|
||||||
t.Fatalf("duplicate gemini key %q after migration", entry.APIKey)
|
|
||||||
}
|
|
||||||
seen[entry.APIKey] = struct{}{}
|
|
||||||
}
|
|
||||||
provider := cfg.OpenAICompatibility[0]
|
|
||||||
if got := len(provider.APIKeyEntries); got != 2 {
|
|
||||||
t.Fatalf("expected 2 openai entries, got %d: %+v", got, provider.APIKeyEntries)
|
|
||||||
}
|
|
||||||
entrySeen := make(map[string]struct{}, len(provider.APIKeyEntries))
|
|
||||||
for _, entry := range provider.APIKeyEntries {
|
|
||||||
if _, ok := entrySeen[entry.APIKey]; ok {
|
|
||||||
t.Fatalf("duplicate openai key %q after migration", entry.APIKey)
|
|
||||||
}
|
|
||||||
entrySeen[entry.APIKey] = struct{}{}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("onlyNewFields", func(t *testing.T) {
|
|
||||||
path := writeConfig(t, `
|
|
||||||
gemini-api-key:
|
|
||||||
- api-key: "new-only"
|
|
||||||
openai-compatibility:
|
|
||||||
- name: "new-only-provider"
|
|
||||||
base-url: "https://new-only.example.com"
|
|
||||||
api-key-entries:
|
|
||||||
- api-key: "new-only-entry"
|
|
||||||
ampcode:
|
|
||||||
upstream-url: "https://amp.new"
|
|
||||||
upstream-api-key: "new-amp-key"
|
|
||||||
restrict-management-to-localhost: true
|
|
||||||
model-mappings:
|
|
||||||
- from: "a"
|
|
||||||
to: "b"
|
|
||||||
`)
|
|
||||||
cfg, err := config.LoadConfig(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load new config: %v", err)
|
|
||||||
}
|
|
||||||
if len(cfg.GeminiKey) != 1 || cfg.GeminiKey[0].APIKey != "new-only" {
|
|
||||||
t.Fatalf("unexpected gemini entries: %+v", cfg.GeminiKey)
|
|
||||||
}
|
|
||||||
if len(cfg.OpenAICompatibility) != 1 || len(cfg.OpenAICompatibility[0].APIKeyEntries) != 1 {
|
|
||||||
t.Fatalf("unexpected openai compat entries: %+v", cfg.OpenAICompatibility)
|
|
||||||
}
|
|
||||||
if cfg.AmpCode.UpstreamURL != "https://amp.new" || cfg.AmpCode.UpstreamAPIKey != "new-amp-key" {
|
|
||||||
t.Fatalf("unexpected amp config: %+v", cfg.AmpCode)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("duplicateNamesDifferentBase", func(t *testing.T) {
|
|
||||||
path := writeConfig(t, `
|
|
||||||
openai-compatibility:
|
|
||||||
- name: "dup-provider"
|
|
||||||
base-url: "https://provider-a"
|
|
||||||
api-keys:
|
|
||||||
- "key-a"
|
|
||||||
- name: "dup-provider"
|
|
||||||
base-url: "https://provider-b"
|
|
||||||
api-keys:
|
|
||||||
- "key-b"
|
|
||||||
`)
|
|
||||||
cfg, err := config.LoadConfig(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load duplicate config: %v", err)
|
|
||||||
}
|
|
||||||
if len(cfg.OpenAICompatibility) != 2 {
|
|
||||||
t.Fatalf("expected 2 providers, got %d", len(cfg.OpenAICompatibility))
|
|
||||||
}
|
|
||||||
for _, entry := range cfg.OpenAICompatibility {
|
|
||||||
if len(entry.APIKeyEntries) != 1 {
|
|
||||||
t.Fatalf("expected 1 key entry per provider: %+v", entry)
|
|
||||||
}
|
|
||||||
switch entry.BaseURL {
|
|
||||||
case "https://provider-a":
|
|
||||||
if entry.APIKeyEntries[0].APIKey != "key-a" {
|
|
||||||
t.Fatalf("provider-a key mismatch: %+v", entry.APIKeyEntries)
|
|
||||||
}
|
|
||||||
case "https://provider-b":
|
|
||||||
if entry.APIKeyEntries[0].APIKey != "key-b" {
|
|
||||||
t.Fatalf("provider-b key mismatch: %+v", entry.APIKeyEntries)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
t.Fatalf("unexpected provider base url: %s", entry.BaseURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeConfig(t *testing.T, content string) string {
|
|
||||||
t.Helper()
|
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "config.yaml")
|
|
||||||
if err := os.WriteFile(path, []byte(strings.TrimSpace(content)+"\n"), 0o644); err != nil {
|
|
||||||
t.Fatalf("write temp config: %v", err)
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
func readFile(t *testing.T, path string) string {
|
|
||||||
t.Helper()
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read temp config: %v", err)
|
|
||||||
}
|
|
||||||
return string(data)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user