mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-04-20 22:51:45 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f55754621f | ||
|
|
ac26e7db43 | ||
|
|
10b824fcac | ||
|
|
7dccc7ba2f | ||
|
|
70c90687fd | ||
|
|
8144ffd5c8 | ||
|
|
6b45d311ec | ||
|
|
1821bf7051 | ||
|
|
d42b5d4e78 | ||
|
|
1e6bc81cfd | ||
|
|
1a149475e0 | ||
|
|
e5166841db | ||
|
|
bb9b2d1758 | ||
|
|
d312422ab4 | ||
|
|
09c92aa0b5 | ||
|
|
8c67b3ae64 | ||
|
|
000e4ceb4e | ||
|
|
7333619f15 | ||
|
|
2db8df8e38 |
@@ -8,6 +8,7 @@ builds:
|
|||||||
- linux
|
- linux
|
||||||
- windows
|
- windows
|
||||||
- darwin
|
- darwin
|
||||||
|
- freebsd
|
||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ GLM CODING PLANを10%割引で取得:https://z.ai/subscribe?ic=8JVLJQFSKB
|
|||||||
<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向けの公式高安定性リレーサービスを提供しており、エンタープライズグレードの同時接続、迅速な請求書発行、24時間365日の専任技術サポートを備えています。Claude Code / Codex / Geminiの公式チャネルが元の価格の38% / 2% / 9%で利用でき、チャージ時にはさらに割引があります!CLIProxyAPIユーザー向けの特別特典:<a href="https://www.aicodemirror.com/register?invitecode=TJNAIF">こちらのリンク</a>から登録すると、初回チャージが20%割引になり、エンタープライズのお客様は最大25%割引を受けられます!</td>
|
<td>AICodeMirrorのスポンサーシップに感謝します!AICodeMirrorはClaude Code / Codex / Gemini CLI向けの公式高安定性リレーサービスを提供しており、エンタープライズグレードの同時接続、迅速な請求書発行、24時間365日の専任技術サポートを備えています。Claude Code / Codex / Geminiの公式チャネルが元の価格の38% / 2% / 9%で利用でき、チャージ時にはさらに割引があります!CLIProxyAPIユーザー向けの特別特典:<a href="https://www.aicodemirror.com/register?invitecode=TJNAIF">こちらのリンク</a>から登録すると、初回チャージが20%割引になり、エンタープライズのお客様は最大25%割引を受けられます!</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="./assets/bmoplus.png" alt="BmoPlus" width="150"></a></td>
|
||||||
|
<td>本プロジェクトにご支援いただいた BmoPlus に感謝いたします!BmoPlusは、AIサブスクリプションのヘビーユーザー向けに特化した信頼性の高いAIアカウントサービスプロバイダーであり、安定した ChatGPT Plus / ChatGPT Pro (完全保証) / Claude Pro / Super Grok / Gemini Pro の公式代行チャージおよび即納アカウントを提供しています。こちらの<a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus AIアカウント専門店/代行チャージ</a>経由でご登録・ご注文いただいたユーザー様は、GPTを <b>公式サイト価格の約1割(90% OFF)</b> という驚異的な価格でご利用いただけます!</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
BIN
assets/bmoplus.png
Normal file
BIN
assets/bmoplus.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -25,6 +25,10 @@ remote-management:
|
|||||||
# Disable the bundled management control panel asset download and HTTP route when true.
|
# Disable the bundled management control panel asset download and HTTP route when true.
|
||||||
disable-control-panel: false
|
disable-control-panel: false
|
||||||
|
|
||||||
|
# Disable automatic periodic background updates of the management panel from GitHub (default: false).
|
||||||
|
# When enabled, the panel is only downloaded on first access if missing, and never auto-updated afterward.
|
||||||
|
# disable-auto-update-panel: false
|
||||||
|
|
||||||
# GitHub repository for the management control panel. Accepts a repository URL or releases API URL.
|
# GitHub repository for the management control panel. Accepts a repository URL or releases API URL.
|
||||||
panel-github-repository: 'https://github.com/router-for-me/Cli-Proxy-API-Management-Center'
|
panel-github-repository: 'https://github.com/router-for-me/Cli-Proxy-API-Management-Center'
|
||||||
|
|
||||||
|
|||||||
@@ -550,10 +550,23 @@ func isRuntimeOnlyAuth(auth *coreauth.Auth) bool {
|
|||||||
return strings.EqualFold(strings.TrimSpace(auth.Attributes["runtime_only"]), "true")
|
return strings.EqualFold(strings.TrimSpace(auth.Attributes["runtime_only"]), "true")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isUnsafeAuthFileName(name string) bool {
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(name, "/\\") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if filepath.VolumeName(name) != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Download single auth file by name
|
// Download single auth file by name
|
||||||
func (h *Handler) DownloadAuthFile(c *gin.Context) {
|
func (h *Handler) DownloadAuthFile(c *gin.Context) {
|
||||||
name := c.Query("name")
|
name := strings.TrimSpace(c.Query("name"))
|
||||||
if name == "" || strings.Contains(name, string(os.PathSeparator)) {
|
if isUnsafeAuthFileName(name) {
|
||||||
c.JSON(400, gin.H{"error": "invalid name"})
|
c.JSON(400, gin.H{"error": "invalid name"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -635,8 +648,8 @@ func (h *Handler) UploadAuthFile(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no files uploaded"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no files uploaded"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
name := c.Query("name")
|
name := strings.TrimSpace(c.Query("name"))
|
||||||
if name == "" || strings.Contains(name, string(os.PathSeparator)) {
|
if isUnsafeAuthFileName(name) {
|
||||||
c.JSON(400, gin.H{"error": "invalid name"})
|
c.JSON(400, gin.H{"error": "invalid name"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -869,7 +882,7 @@ func uniqueAuthFileNames(names []string) []string {
|
|||||||
|
|
||||||
func (h *Handler) deleteAuthFileByName(ctx context.Context, name string) (string, int, error) {
|
func (h *Handler) deleteAuthFileByName(ctx context.Context, name string) (string, int, error) {
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
if name == "" || strings.Contains(name, string(os.PathSeparator)) {
|
if isUnsafeAuthFileName(name) {
|
||||||
return "", http.StatusBadRequest, fmt.Errorf("invalid name")
|
return "", http.StatusBadRequest, fmt.Errorf("invalid name")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
62
internal/api/handlers/management/auth_files_download_test.go
Normal file
62
internal/api/handlers/management/auth_files_download_test.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package management
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDownloadAuthFile_ReturnsFile(t *testing.T) {
|
||||||
|
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
authDir := t.TempDir()
|
||||||
|
fileName := "download-user.json"
|
||||||
|
expected := []byte(`{"type":"codex"}`)
|
||||||
|
if err := os.WriteFile(filepath.Join(authDir, fileName), expected, 0o600); err != nil {
|
||||||
|
t.Fatalf("failed to write auth file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, nil)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(rec)
|
||||||
|
ctx.Request = httptest.NewRequest(http.MethodGet, "/v0/management/auth-files/download?name="+url.QueryEscape(fileName), nil)
|
||||||
|
h.DownloadAuthFile(ctx)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected download status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := rec.Body.Bytes(); string(got) != string(expected) {
|
||||||
|
t.Fatalf("unexpected download content: %q", string(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadAuthFile_RejectsPathSeparators(t *testing.T) {
|
||||||
|
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: t.TempDir()}, nil)
|
||||||
|
|
||||||
|
for _, name := range []string{
|
||||||
|
"../external/secret.json",
|
||||||
|
`..\\external\\secret.json`,
|
||||||
|
"nested/secret.json",
|
||||||
|
`nested\\secret.json`,
|
||||||
|
} {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(rec)
|
||||||
|
ctx.Request = httptest.NewRequest(http.MethodGet, "/v0/management/auth-files/download?name="+url.QueryEscape(name), nil)
|
||||||
|
h.DownloadAuthFile(ctx)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected %d for name %q, got %d with body %s", http.StatusBadRequest, name, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package management
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDownloadAuthFile_PreventsWindowsSlashTraversal(t *testing.T) {
|
||||||
|
t.Setenv("MANAGEMENT_PASSWORD", "")
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
authDir := filepath.Join(tempDir, "auth")
|
||||||
|
externalDir := filepath.Join(tempDir, "external")
|
||||||
|
if err := os.MkdirAll(authDir, 0o700); err != nil {
|
||||||
|
t.Fatalf("failed to create auth dir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(externalDir, 0o700); err != nil {
|
||||||
|
t.Fatalf("failed to create external dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secretName := "secret.json"
|
||||||
|
secretPath := filepath.Join(externalDir, secretName)
|
||||||
|
if err := os.WriteFile(secretPath, []byte(`{"secret":true}`), 0o600); err != nil {
|
||||||
|
t.Fatalf("failed to write external file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, nil)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(rec)
|
||||||
|
ctx.Request = httptest.NewRequest(
|
||||||
|
http.MethodGet,
|
||||||
|
"/v0/management/auth-files/download?name="+url.QueryEscape("../external/"+secretName),
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
h.DownloadAuthFile(ctx)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected status %d, got %d with body %s", http.StatusBadRequest, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -195,6 +195,9 @@ type RemoteManagement struct {
|
|||||||
SecretKey string `yaml:"secret-key"`
|
SecretKey string `yaml:"secret-key"`
|
||||||
// DisableControlPanel skips serving and syncing the bundled management UI when true.
|
// DisableControlPanel skips serving and syncing the bundled management UI when true.
|
||||||
DisableControlPanel bool `yaml:"disable-control-panel"`
|
DisableControlPanel bool `yaml:"disable-control-panel"`
|
||||||
|
// DisableAutoUpdatePanel disables automatic periodic background updates of the management panel asset from GitHub.
|
||||||
|
// When false (the default), the background updater remains enabled; when true, the panel is only downloaded on first access if missing.
|
||||||
|
DisableAutoUpdatePanel bool `yaml:"disable-auto-update-panel"`
|
||||||
// PanelGitHubRepository overrides the GitHub repository used to fetch the management panel asset.
|
// PanelGitHubRepository overrides the GitHub repository used to fetch the management panel asset.
|
||||||
// Accepts either a repository URL (https://github.com/org/repo) or an API releases endpoint.
|
// Accepts either a repository URL (https://github.com/org/repo) or an API releases endpoint.
|
||||||
PanelGitHubRepository string `yaml:"panel-github-repository"`
|
PanelGitHubRepository string `yaml:"panel-github-repository"`
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const (
|
|||||||
httpUserAgent = "CLIProxyAPI-management-updater"
|
httpUserAgent = "CLIProxyAPI-management-updater"
|
||||||
managementSyncMinInterval = 30 * time.Second
|
managementSyncMinInterval = 30 * time.Second
|
||||||
updateCheckInterval = 3 * time.Hour
|
updateCheckInterval = 3 * time.Hour
|
||||||
|
maxAssetDownloadSize = 50 << 20 // 10 MB safety limit for management asset downloads
|
||||||
)
|
)
|
||||||
|
|
||||||
// ManagementFileName exposes the control panel asset filename.
|
// ManagementFileName exposes the control panel asset filename.
|
||||||
@@ -88,6 +89,10 @@ func runAutoUpdater(ctx context.Context) {
|
|||||||
log.Debug("management asset auto-updater skipped: control panel disabled")
|
log.Debug("management asset auto-updater skipped: control panel disabled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if cfg.RemoteManagement.DisableAutoUpdatePanel {
|
||||||
|
log.Debug("management asset auto-updater skipped: disable-auto-update-panel is enabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
configPath, _ := schedulerConfigPath.Load().(string)
|
configPath, _ := schedulerConfigPath.Load().(string)
|
||||||
staticDir := StaticDir(configPath)
|
staticDir := StaticDir(configPath)
|
||||||
@@ -259,7 +264,8 @@ func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL
|
|||||||
}
|
}
|
||||||
|
|
||||||
if remoteHash != "" && !strings.EqualFold(remoteHash, downloadedHash) {
|
if remoteHash != "" && !strings.EqualFold(remoteHash, downloadedHash) {
|
||||||
log.Warnf("remote digest mismatch for management asset: expected %s got %s", remoteHash, downloadedHash)
|
log.Errorf("management asset digest mismatch: expected %s got %s — aborting update for safety", remoteHash, downloadedHash)
|
||||||
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = atomicWriteFile(localPath, data); err != nil {
|
if err = atomicWriteFile(localPath, data); err != nil {
|
||||||
@@ -282,6 +288,9 @@ func ensureFallbackManagementHTML(ctx context.Context, client *http.Client, loca
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Warnf("management asset downloaded from fallback URL without digest verification (hash=%s) — "+
|
||||||
|
"enable verified GitHub updates by keeping disable-auto-update-panel set to false", downloadedHash)
|
||||||
|
|
||||||
if err = atomicWriteFile(localPath, data); err != nil {
|
if err = atomicWriteFile(localPath, data); err != nil {
|
||||||
log.WithError(err).Warn("failed to persist fallback management control panel page")
|
log.WithError(err).Warn("failed to persist fallback management control panel page")
|
||||||
return false
|
return false
|
||||||
@@ -392,10 +401,13 @@ func downloadAsset(ctx context.Context, client *http.Client, downloadURL string)
|
|||||||
return nil, "", fmt.Errorf("unexpected download status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
return nil, "", fmt.Errorf("unexpected download status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
data, err := io.ReadAll(io.LimitReader(resp.Body, maxAssetDownloadSize+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("read download body: %w", err)
|
return nil, "", fmt.Errorf("read download body: %w", err)
|
||||||
}
|
}
|
||||||
|
if int64(len(data)) > maxAssetDownloadSize {
|
||||||
|
return nil, "", fmt.Errorf("download exceeds maximum allowed size of %d bytes", maxAssetDownloadSize)
|
||||||
|
}
|
||||||
|
|
||||||
sum := sha256.Sum256(data)
|
sum := sha256.Sum256(data)
|
||||||
return data, hex.EncodeToString(sum[:]), nil
|
return data, hex.EncodeToString(sum[:]), nil
|
||||||
|
|||||||
@@ -165,29 +165,22 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
|
|||||||
// Process messages and transform them to Claude Code format
|
// Process messages and transform them to Claude Code format
|
||||||
if messages := root.Get("messages"); messages.Exists() && messages.IsArray() {
|
if messages := root.Get("messages"); messages.Exists() && messages.IsArray() {
|
||||||
messageIndex := 0
|
messageIndex := 0
|
||||||
systemMessageIndex := -1
|
|
||||||
messages.ForEach(func(_, message gjson.Result) bool {
|
messages.ForEach(func(_, message gjson.Result) bool {
|
||||||
role := message.Get("role").String()
|
role := message.Get("role").String()
|
||||||
contentResult := message.Get("content")
|
contentResult := message.Get("content")
|
||||||
|
|
||||||
switch role {
|
switch role {
|
||||||
case "system":
|
case "system":
|
||||||
if systemMessageIndex == -1 {
|
|
||||||
systemMsg := []byte(`{"role":"user","content":[]}`)
|
|
||||||
out, _ = sjson.SetRawBytes(out, "messages.-1", systemMsg)
|
|
||||||
systemMessageIndex = messageIndex
|
|
||||||
messageIndex++
|
|
||||||
}
|
|
||||||
if contentResult.Exists() && contentResult.Type == gjson.String && contentResult.String() != "" {
|
if contentResult.Exists() && contentResult.Type == gjson.String && contentResult.String() != "" {
|
||||||
textPart := []byte(`{"type":"text","text":""}`)
|
textPart := []byte(`{"type":"text","text":""}`)
|
||||||
textPart, _ = sjson.SetBytes(textPart, "text", contentResult.String())
|
textPart, _ = sjson.SetBytes(textPart, "text", contentResult.String())
|
||||||
out, _ = sjson.SetRawBytes(out, fmt.Sprintf("messages.%d.content.-1", systemMessageIndex), textPart)
|
out, _ = sjson.SetRawBytes(out, "system.-1", textPart)
|
||||||
} else if contentResult.Exists() && contentResult.IsArray() {
|
} else if contentResult.Exists() && contentResult.IsArray() {
|
||||||
contentResult.ForEach(func(_, part gjson.Result) bool {
|
contentResult.ForEach(func(_, part gjson.Result) bool {
|
||||||
if part.Get("type").String() == "text" {
|
if part.Get("type").String() == "text" {
|
||||||
textPart := []byte(`{"type":"text","text":""}`)
|
textPart := []byte(`{"type":"text","text":""}`)
|
||||||
textPart, _ = sjson.SetBytes(textPart, "text", part.Get("text").String())
|
textPart, _ = sjson.SetBytes(textPart, "text", part.Get("text").String())
|
||||||
out, _ = sjson.SetRawBytes(out, fmt.Sprintf("messages.%d.content.-1", systemMessageIndex), textPart)
|
out, _ = sjson.SetRawBytes(out, "system.-1", textPart)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
@@ -269,6 +262,16 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Preserve a minimal conversational turn for system-only inputs.
|
||||||
|
// Claude payloads with top-level system instructions but no messages are risky for downstream validation.
|
||||||
|
if messageIndex == 0 {
|
||||||
|
system := gjson.GetBytes(out, "system")
|
||||||
|
if system.Exists() && system.IsArray() && len(system.Array()) > 0 {
|
||||||
|
fallbackMsg := []byte(`{"role":"user","content":[{"type":"text","text":""}]}`)
|
||||||
|
out, _ = sjson.SetRawBytes(out, "messages.-1", fallbackMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tools mapping: OpenAI tools -> Claude Code tools
|
// Tools mapping: OpenAI tools -> Claude Code tools
|
||||||
|
|||||||
@@ -135,3 +135,111 @@ func TestConvertOpenAIRequestToClaude_ToolResultURLImageOnly(t *testing.T) {
|
|||||||
t.Fatalf("Unexpected image URL: %q", got)
|
t.Fatalf("Unexpected image URL: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConvertOpenAIRequestToClaude_SystemRoleBecomesTopLevelSystem(t *testing.T) {
|
||||||
|
inputJSON := `{
|
||||||
|
"model": "gpt-4.1",
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "You are a helpful assistant."},
|
||||||
|
{"role": "user", "content": "Hello"}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
|
||||||
|
resultJSON := gjson.ParseBytes(result)
|
||||||
|
|
||||||
|
system := resultJSON.Get("system")
|
||||||
|
if !system.IsArray() {
|
||||||
|
t.Fatalf("Expected top-level system array, got %s", system.Raw)
|
||||||
|
}
|
||||||
|
if len(system.Array()) != 1 {
|
||||||
|
t.Fatalf("Expected 1 system block, got %d. System: %s", len(system.Array()), system.Raw)
|
||||||
|
}
|
||||||
|
if got := system.Get("0.type").String(); got != "text" {
|
||||||
|
t.Fatalf("Expected system block type %q, got %q", "text", got)
|
||||||
|
}
|
||||||
|
if got := system.Get("0.text").String(); got != "You are a helpful assistant." {
|
||||||
|
t.Fatalf("Expected system text %q, got %q", "You are a helpful assistant.", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := resultJSON.Get("messages").Array()
|
||||||
|
if len(messages) != 1 {
|
||||||
|
t.Fatalf("Expected 1 non-system message, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
|
||||||
|
}
|
||||||
|
if got := messages[0].Get("role").String(); got != "user" {
|
||||||
|
t.Fatalf("Expected remaining message role %q, got %q", "user", got)
|
||||||
|
}
|
||||||
|
if got := messages[0].Get("content.0.text").String(); got != "Hello" {
|
||||||
|
t.Fatalf("Expected user text %q, got %q", "Hello", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertOpenAIRequestToClaude_MultipleSystemMessagesMergedIntoTopLevelSystem(t *testing.T) {
|
||||||
|
inputJSON := `{
|
||||||
|
"model": "gpt-4.1",
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "Rule 1"},
|
||||||
|
{"role": "system", "content": [{"type": "text", "text": "Rule 2"}]},
|
||||||
|
{"role": "user", "content": "Hello"}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
|
||||||
|
resultJSON := gjson.ParseBytes(result)
|
||||||
|
|
||||||
|
system := resultJSON.Get("system").Array()
|
||||||
|
if len(system) != 2 {
|
||||||
|
t.Fatalf("Expected 2 system blocks, got %d. System: %s", len(system), resultJSON.Get("system").Raw)
|
||||||
|
}
|
||||||
|
if got := system[0].Get("text").String(); got != "Rule 1" {
|
||||||
|
t.Fatalf("Expected first system text %q, got %q", "Rule 1", got)
|
||||||
|
}
|
||||||
|
if got := system[1].Get("text").String(); got != "Rule 2" {
|
||||||
|
t.Fatalf("Expected second system text %q, got %q", "Rule 2", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := resultJSON.Get("messages").Array()
|
||||||
|
if len(messages) != 1 {
|
||||||
|
t.Fatalf("Expected 1 non-system message, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
|
||||||
|
}
|
||||||
|
if got := messages[0].Get("role").String(); got != "user" {
|
||||||
|
t.Fatalf("Expected remaining message role %q, got %q", "user", got)
|
||||||
|
}
|
||||||
|
if got := messages[0].Get("content.0.text").String(); got != "Hello" {
|
||||||
|
t.Fatalf("Expected user text %q, got %q", "Hello", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertOpenAIRequestToClaude_SystemOnlyInputKeepsFallbackUserMessage(t *testing.T) {
|
||||||
|
inputJSON := `{
|
||||||
|
"model": "gpt-4.1",
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "You are a helpful assistant."}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
result := ConvertOpenAIRequestToClaude("claude-sonnet-4-5", []byte(inputJSON), false)
|
||||||
|
resultJSON := gjson.ParseBytes(result)
|
||||||
|
|
||||||
|
system := resultJSON.Get("system").Array()
|
||||||
|
if len(system) != 1 {
|
||||||
|
t.Fatalf("Expected 1 system block, got %d. System: %s", len(system), resultJSON.Get("system").Raw)
|
||||||
|
}
|
||||||
|
if got := system[0].Get("text").String(); got != "You are a helpful assistant." {
|
||||||
|
t.Fatalf("Expected system text %q, got %q", "You are a helpful assistant.", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := resultJSON.Get("messages").Array()
|
||||||
|
if len(messages) != 1 {
|
||||||
|
t.Fatalf("Expected 1 fallback message, got %d. Messages: %s", len(messages), resultJSON.Get("messages").Raw)
|
||||||
|
}
|
||||||
|
if got := messages[0].Get("role").String(); got != "user" {
|
||||||
|
t.Fatalf("Expected fallback message role %q, got %q", "user", got)
|
||||||
|
}
|
||||||
|
if got := messages[0].Get("content.0.type").String(); got != "text" {
|
||||||
|
t.Fatalf("Expected fallback content type %q, got %q", "text", got)
|
||||||
|
}
|
||||||
|
if got := messages[0].Get("content.0.text").String(); got != "" {
|
||||||
|
t.Fatalf("Expected fallback text %q, got %q", "", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -256,6 +256,9 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
|
|||||||
if oldCfg.RemoteManagement.DisableControlPanel != newCfg.RemoteManagement.DisableControlPanel {
|
if oldCfg.RemoteManagement.DisableControlPanel != newCfg.RemoteManagement.DisableControlPanel {
|
||||||
changes = append(changes, fmt.Sprintf("remote-management.disable-control-panel: %t -> %t", oldCfg.RemoteManagement.DisableControlPanel, newCfg.RemoteManagement.DisableControlPanel))
|
changes = append(changes, fmt.Sprintf("remote-management.disable-control-panel: %t -> %t", oldCfg.RemoteManagement.DisableControlPanel, newCfg.RemoteManagement.DisableControlPanel))
|
||||||
}
|
}
|
||||||
|
if oldCfg.RemoteManagement.DisableAutoUpdatePanel != newCfg.RemoteManagement.DisableAutoUpdatePanel {
|
||||||
|
changes = append(changes, fmt.Sprintf("remote-management.disable-auto-update-panel: %t -> %t", oldCfg.RemoteManagement.DisableAutoUpdatePanel, newCfg.RemoteManagement.DisableAutoUpdatePanel))
|
||||||
|
}
|
||||||
oldPanelRepo := strings.TrimSpace(oldCfg.RemoteManagement.PanelGitHubRepository)
|
oldPanelRepo := strings.TrimSpace(oldCfg.RemoteManagement.PanelGitHubRepository)
|
||||||
newPanelRepo := strings.TrimSpace(newCfg.RemoteManagement.PanelGitHubRepository)
|
newPanelRepo := strings.TrimSpace(newCfg.RemoteManagement.PanelGitHubRepository)
|
||||||
if oldPanelRepo != newPanelRepo {
|
if oldPanelRepo != newPanelRepo {
|
||||||
|
|||||||
@@ -20,10 +20,11 @@ func TestBuildConfigChangeDetails(t *testing.T) {
|
|||||||
RestrictManagementToLocalhost: false,
|
RestrictManagementToLocalhost: false,
|
||||||
},
|
},
|
||||||
RemoteManagement: config.RemoteManagement{
|
RemoteManagement: config.RemoteManagement{
|
||||||
AllowRemote: false,
|
AllowRemote: false,
|
||||||
SecretKey: "old",
|
SecretKey: "old",
|
||||||
DisableControlPanel: false,
|
DisableControlPanel: false,
|
||||||
PanelGitHubRepository: "repo-old",
|
DisableAutoUpdatePanel: false,
|
||||||
|
PanelGitHubRepository: "repo-old",
|
||||||
},
|
},
|
||||||
OAuthExcludedModels: map[string][]string{
|
OAuthExcludedModels: map[string][]string{
|
||||||
"providerA": {"m1"},
|
"providerA": {"m1"},
|
||||||
@@ -54,10 +55,11 @@ func TestBuildConfigChangeDetails(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
RemoteManagement: config.RemoteManagement{
|
RemoteManagement: config.RemoteManagement{
|
||||||
AllowRemote: true,
|
AllowRemote: true,
|
||||||
SecretKey: "new",
|
SecretKey: "new",
|
||||||
DisableControlPanel: true,
|
DisableControlPanel: true,
|
||||||
PanelGitHubRepository: "repo-new",
|
DisableAutoUpdatePanel: true,
|
||||||
|
PanelGitHubRepository: "repo-new",
|
||||||
},
|
},
|
||||||
OAuthExcludedModels: map[string][]string{
|
OAuthExcludedModels: map[string][]string{
|
||||||
"providerA": {"m1", "m2"},
|
"providerA": {"m1", "m2"},
|
||||||
@@ -88,6 +90,7 @@ func TestBuildConfigChangeDetails(t *testing.T) {
|
|||||||
expectContains(t, details, "ampcode.upstream-url: http://old-upstream -> http://new-upstream")
|
expectContains(t, details, "ampcode.upstream-url: http://old-upstream -> http://new-upstream")
|
||||||
expectContains(t, details, "ampcode.model-mappings: updated (1 -> 2 entries)")
|
expectContains(t, details, "ampcode.model-mappings: updated (1 -> 2 entries)")
|
||||||
expectContains(t, details, "remote-management.allow-remote: false -> true")
|
expectContains(t, details, "remote-management.allow-remote: false -> true")
|
||||||
|
expectContains(t, details, "remote-management.disable-auto-update-panel: false -> true")
|
||||||
expectContains(t, details, "remote-management.secret-key: updated")
|
expectContains(t, details, "remote-management.secret-key: updated")
|
||||||
expectContains(t, details, "oauth-excluded-models[providera]: updated (1 -> 2 entries)")
|
expectContains(t, details, "oauth-excluded-models[providera]: updated (1 -> 2 entries)")
|
||||||
expectContains(t, details, "oauth-excluded-models[providerb]: added (1 entries)")
|
expectContains(t, details, "oauth-excluded-models[providerb]: added (1 entries)")
|
||||||
@@ -265,9 +268,10 @@ func TestBuildConfigChangeDetails_FlagsAndKeys(t *testing.T) {
|
|||||||
ModelMappings: []config.AmpModelMapping{{From: "a", To: "b"}},
|
ModelMappings: []config.AmpModelMapping{{From: "a", To: "b"}},
|
||||||
},
|
},
|
||||||
RemoteManagement: config.RemoteManagement{
|
RemoteManagement: config.RemoteManagement{
|
||||||
DisableControlPanel: true,
|
DisableControlPanel: true,
|
||||||
PanelGitHubRepository: "new/repo",
|
DisableAutoUpdatePanel: true,
|
||||||
SecretKey: "",
|
PanelGitHubRepository: "new/repo",
|
||||||
|
SecretKey: "",
|
||||||
},
|
},
|
||||||
SDKConfig: sdkconfig.SDKConfig{
|
SDKConfig: sdkconfig.SDKConfig{
|
||||||
RequestLog: true,
|
RequestLog: true,
|
||||||
@@ -299,6 +303,7 @@ func TestBuildConfigChangeDetails_FlagsAndKeys(t *testing.T) {
|
|||||||
expectContains(t, details, "ampcode.restrict-management-to-localhost: false -> true")
|
expectContains(t, details, "ampcode.restrict-management-to-localhost: false -> true")
|
||||||
expectContains(t, details, "ampcode.upstream-api-key: removed")
|
expectContains(t, details, "ampcode.upstream-api-key: removed")
|
||||||
expectContains(t, details, "remote-management.disable-control-panel: false -> true")
|
expectContains(t, details, "remote-management.disable-control-panel: false -> true")
|
||||||
|
expectContains(t, details, "remote-management.disable-auto-update-panel: false -> true")
|
||||||
expectContains(t, details, "remote-management.panel-github-repository: old/repo -> new/repo")
|
expectContains(t, details, "remote-management.panel-github-repository: old/repo -> new/repo")
|
||||||
expectContains(t, details, "remote-management.secret-key: deleted")
|
expectContains(t, details, "remote-management.secret-key: deleted")
|
||||||
}
|
}
|
||||||
@@ -336,10 +341,11 @@ func TestBuildConfigChangeDetails_AllBranches(t *testing.T) {
|
|||||||
ForceModelMappings: false,
|
ForceModelMappings: false,
|
||||||
},
|
},
|
||||||
RemoteManagement: config.RemoteManagement{
|
RemoteManagement: config.RemoteManagement{
|
||||||
AllowRemote: false,
|
AllowRemote: false,
|
||||||
DisableControlPanel: false,
|
DisableControlPanel: false,
|
||||||
PanelGitHubRepository: "old/repo",
|
DisableAutoUpdatePanel: false,
|
||||||
SecretKey: "old",
|
PanelGitHubRepository: "old/repo",
|
||||||
|
SecretKey: "old",
|
||||||
},
|
},
|
||||||
SDKConfig: sdkconfig.SDKConfig{
|
SDKConfig: sdkconfig.SDKConfig{
|
||||||
RequestLog: false,
|
RequestLog: false,
|
||||||
@@ -389,10 +395,11 @@ func TestBuildConfigChangeDetails_AllBranches(t *testing.T) {
|
|||||||
ForceModelMappings: true,
|
ForceModelMappings: true,
|
||||||
},
|
},
|
||||||
RemoteManagement: config.RemoteManagement{
|
RemoteManagement: config.RemoteManagement{
|
||||||
AllowRemote: true,
|
AllowRemote: true,
|
||||||
DisableControlPanel: true,
|
DisableControlPanel: true,
|
||||||
PanelGitHubRepository: "new/repo",
|
DisableAutoUpdatePanel: true,
|
||||||
SecretKey: "",
|
PanelGitHubRepository: "new/repo",
|
||||||
|
SecretKey: "",
|
||||||
},
|
},
|
||||||
SDKConfig: sdkconfig.SDKConfig{
|
SDKConfig: sdkconfig.SDKConfig{
|
||||||
RequestLog: true,
|
RequestLog: true,
|
||||||
@@ -460,6 +467,7 @@ func TestBuildConfigChangeDetails_AllBranches(t *testing.T) {
|
|||||||
expectContains(t, changes, "oauth-excluded-models[p2]: added (1 entries)")
|
expectContains(t, changes, "oauth-excluded-models[p2]: added (1 entries)")
|
||||||
expectContains(t, changes, "remote-management.allow-remote: false -> true")
|
expectContains(t, changes, "remote-management.allow-remote: false -> true")
|
||||||
expectContains(t, changes, "remote-management.disable-control-panel: false -> true")
|
expectContains(t, changes, "remote-management.disable-control-panel: false -> true")
|
||||||
|
expectContains(t, changes, "remote-management.disable-auto-update-panel: false -> true")
|
||||||
expectContains(t, changes, "remote-management.panel-github-repository: old/repo -> new/repo")
|
expectContains(t, changes, "remote-management.panel-github-repository: old/repo -> new/repo")
|
||||||
expectContains(t, changes, "remote-management.secret-key: deleted")
|
expectContains(t, changes, "remote-management.secret-key: deleted")
|
||||||
expectContains(t, changes, "openai-compatibility:")
|
expectContains(t, changes, "openai-compatibility:")
|
||||||
|
|||||||
Reference in New Issue
Block a user