Skip to content

Commit 7d32e67

Browse files
committed
fix(management): preserve auth metadata and Codex priority
1 parent e0cb43f commit 7d32e67

6 files changed

Lines changed: 351 additions & 3 deletions

File tree

internal/api/handlers/management/auth_files.go

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1214,8 +1214,11 @@ func (h *Handler) PatchAuthFileStatus(c *gin.Context) {
12141214
}
12151215

12161216
var req struct {
1217-
Name string `json:"name"`
1218-
Disabled *bool `json:"disabled"`
1217+
Name string `json:"name"`
1218+
Disabled *bool `json:"disabled"`
1219+
Metadata map[string]any `json:"metadata"`
1220+
Type string `json:"type"`
1221+
Provider string `json:"provider"`
12191222
}
12201223
if err := c.ShouldBindJSON(&req); err != nil {
12211224
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
@@ -1253,8 +1256,17 @@ func (h *Handler) PatchAuthFileStatus(c *gin.Context) {
12531256
return
12541257
}
12551258

1259+
if err := h.mergeAuthFileStatusMetadata(targetAuth, req.Metadata, req.Type, req.Provider); err != nil {
1260+
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to merge auth metadata: %v", err)})
1261+
return
1262+
}
1263+
12561264
// Update disabled state
12571265
targetAuth.Disabled = *req.Disabled
1266+
if targetAuth.Metadata == nil {
1267+
targetAuth.Metadata = make(map[string]any)
1268+
}
1269+
targetAuth.Metadata["disabled"] = *req.Disabled
12581270
if *req.Disabled {
12591271
targetAuth.Status = coreauth.StatusDisabled
12601272
targetAuth.StatusMessage = "disabled via management API"
@@ -1272,6 +1284,84 @@ func (h *Handler) PatchAuthFileStatus(c *gin.Context) {
12721284
c.JSON(http.StatusOK, gin.H{"status": "ok", "disabled": *req.Disabled})
12731285
}
12741286

1287+
func (h *Handler) mergeAuthFileStatusMetadata(auth *coreauth.Auth, requestMetadata map[string]any, requestType, requestProvider string) error {
1288+
if auth == nil {
1289+
return nil
1290+
}
1291+
merged := make(map[string]any)
1292+
diskFound := false
1293+
if diskMetadata, errRead := h.readAuthMetadataForStatusPatch(auth); errRead == nil {
1294+
for key, value := range diskMetadata {
1295+
merged[key] = value
1296+
}
1297+
diskFound = true
1298+
} else if !errors.Is(errRead, os.ErrNotExist) {
1299+
return errRead
1300+
}
1301+
if !diskFound {
1302+
for key, value := range auth.Metadata {
1303+
merged[key] = value
1304+
}
1305+
}
1306+
for key, value := range requestMetadata {
1307+
merged[key] = value
1308+
}
1309+
1310+
provider := strings.TrimSpace(requestType)
1311+
if provider == "" {
1312+
provider = strings.TrimSpace(requestProvider)
1313+
}
1314+
if provider == "" {
1315+
provider = strings.TrimSpace(valueAsString(merged["type"]))
1316+
}
1317+
if provider == "" {
1318+
provider = strings.TrimSpace(auth.Provider)
1319+
}
1320+
if provider != "" && !strings.EqualFold(provider, "unknown") {
1321+
merged["type"] = provider
1322+
auth.Provider = provider
1323+
}
1324+
auth.Metadata = merged
1325+
return nil
1326+
}
1327+
1328+
func (h *Handler) readAuthMetadataForStatusPatch(auth *coreauth.Auth) (map[string]any, error) {
1329+
path := strings.TrimSpace(authAttribute(auth, "path"))
1330+
if path == "" {
1331+
path = strings.TrimSpace(authAttribute(auth, "source"))
1332+
}
1333+
if path == "" {
1334+
fileName := strings.TrimSpace(auth.FileName)
1335+
if fileName == "" {
1336+
fileName = strings.TrimSpace(auth.ID)
1337+
}
1338+
if fileName != "" && h != nil && h.cfg != nil {
1339+
authDir := strings.TrimSpace(h.cfg.AuthDir)
1340+
if resolvedAuthDir, errResolve := util.ResolveAuthDir(authDir); errResolve == nil && resolvedAuthDir != "" {
1341+
authDir = resolvedAuthDir
1342+
}
1343+
if authDir != "" {
1344+
path = filepath.Join(authDir, fileName)
1345+
}
1346+
}
1347+
}
1348+
if path == "" {
1349+
return nil, os.ErrNotExist
1350+
}
1351+
raw, errRead := os.ReadFile(path)
1352+
if errRead != nil {
1353+
return nil, errRead
1354+
}
1355+
if len(raw) == 0 {
1356+
return nil, os.ErrNotExist
1357+
}
1358+
metadata := make(map[string]any)
1359+
if errUnmarshal := json.Unmarshal(raw, &metadata); errUnmarshal != nil {
1360+
return nil, errUnmarshal
1361+
}
1362+
return metadata, nil
1363+
}
1364+
12751365
// PatchAuthFileFields updates arbitrary metadata fields of an auth file.
12761366
func (h *Handler) PatchAuthFileFields(c *gin.Context) {
12771367
if h.authManager == nil {
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package management
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
"testing"
12+
13+
"github.com/gin-gonic/gin"
14+
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
15+
fileauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
16+
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
17+
)
18+
19+
func TestPatchAuthFileStatus_MergesDiskMetadataBeforePersist(t *testing.T) {
20+
t.Setenv("MANAGEMENT_PASSWORD", "")
21+
gin.SetMode(gin.TestMode)
22+
23+
authDir := t.TempDir()
24+
fileName := "codex.json"
25+
filePath := filepath.Join(authDir, fileName)
26+
original := `{"type":"codex","access_token":"access","refresh_token":"refresh","email":"u@example.com","nested":{"keep":true}}`
27+
if errWrite := os.WriteFile(filePath, []byte(original), 0o600); errWrite != nil {
28+
t.Fatalf("failed to write auth file: %v", errWrite)
29+
}
30+
31+
store := fileauth.NewFileTokenStore()
32+
store.SetBaseDir(authDir)
33+
manager := coreauth.NewManager(store, nil, nil)
34+
record := &coreauth.Auth{
35+
ID: fileName,
36+
FileName: fileName,
37+
Provider: "codex",
38+
Attributes: map[string]string{
39+
"path": filePath,
40+
},
41+
Metadata: map[string]any{
42+
"name": fileName,
43+
},
44+
}
45+
if _, errRegister := manager.Register(context.Background(), record); errRegister != nil {
46+
t.Fatalf("failed to register auth record: %v", errRegister)
47+
}
48+
if errWrite := os.WriteFile(filePath, []byte(original), 0o600); errWrite != nil {
49+
t.Fatalf("failed to restore full auth file after registration: %v", errWrite)
50+
}
51+
52+
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
53+
rec := httptest.NewRecorder()
54+
ctx, _ := gin.CreateTestContext(rec)
55+
req := httptest.NewRequest(http.MethodPost, "/v0/management/auth-files?name="+fileName, strings.NewReader(`{"name":"codex.json","disabled":true}`))
56+
req.Header.Set("Content-Type", "application/json")
57+
ctx.Request = req
58+
59+
h.PatchAuthFileStatus(ctx)
60+
61+
if rec.Code != http.StatusOK {
62+
t.Fatalf("expected status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
63+
}
64+
raw, errRead := os.ReadFile(filePath)
65+
if errRead != nil {
66+
t.Fatalf("failed to read auth file: %v", errRead)
67+
}
68+
var data map[string]any
69+
if errUnmarshal := json.Unmarshal(raw, &data); errUnmarshal != nil {
70+
t.Fatalf("failed to unmarshal auth file: %v; raw=%s", errUnmarshal, string(raw))
71+
}
72+
if got := data["type"]; got != "codex" {
73+
t.Fatalf("type = %#v, want codex; raw=%s", got, string(raw))
74+
}
75+
if got := data["access_token"]; got != "access" {
76+
t.Fatalf("access_token = %#v, want preserved access; raw=%s", got, string(raw))
77+
}
78+
if got := data["refresh_token"]; got != "refresh" {
79+
t.Fatalf("refresh_token = %#v, want preserved refresh; raw=%s", got, string(raw))
80+
}
81+
if got := data["disabled"]; got != true {
82+
t.Fatalf("disabled = %#v, want true; raw=%s", got, string(raw))
83+
}
84+
}
85+
86+
func TestPatchAuthFileStatus_AcceptsMetadataAndTypeFromRequest(t *testing.T) {
87+
t.Setenv("MANAGEMENT_PASSWORD", "")
88+
gin.SetMode(gin.TestMode)
89+
90+
authDir := t.TempDir()
91+
fileName := "partial.json"
92+
filePath := filepath.Join(authDir, fileName)
93+
if errWrite := os.WriteFile(filePath, []byte(`{"name":"partial.json"}`), 0o600); errWrite != nil {
94+
t.Fatalf("failed to write auth file: %v", errWrite)
95+
}
96+
97+
store := fileauth.NewFileTokenStore()
98+
store.SetBaseDir(authDir)
99+
manager := coreauth.NewManager(store, nil, nil)
100+
record := &coreauth.Auth{
101+
ID: fileName,
102+
FileName: fileName,
103+
Provider: "unknown",
104+
Attributes: map[string]string{
105+
"path": filePath,
106+
},
107+
Metadata: map[string]any{
108+
"name": fileName,
109+
},
110+
}
111+
if _, errRegister := manager.Register(context.Background(), record); errRegister != nil {
112+
t.Fatalf("failed to register auth record: %v", errRegister)
113+
}
114+
115+
h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager)
116+
body := `{"name":"partial.json","disabled":false,"type":"codex","metadata":{"access_token":"access","refresh_token":"refresh","email":"u@example.com"}}`
117+
rec := httptest.NewRecorder()
118+
ctx, _ := gin.CreateTestContext(rec)
119+
req := httptest.NewRequest(http.MethodPost, "/v0/management/auth-files?name="+fileName, strings.NewReader(body))
120+
req.Header.Set("Content-Type", "application/json")
121+
ctx.Request = req
122+
123+
h.PatchAuthFileStatus(ctx)
124+
125+
if rec.Code != http.StatusOK {
126+
t.Fatalf("expected status %d, got %d with body %s", http.StatusOK, rec.Code, rec.Body.String())
127+
}
128+
raw, errRead := os.ReadFile(filePath)
129+
if errRead != nil {
130+
t.Fatalf("failed to read auth file: %v", errRead)
131+
}
132+
var data map[string]any
133+
if errUnmarshal := json.Unmarshal(raw, &data); errUnmarshal != nil {
134+
t.Fatalf("failed to unmarshal auth file: %v; raw=%s", errUnmarshal, string(raw))
135+
}
136+
if got := data["type"]; got != "codex" {
137+
t.Fatalf("type = %#v, want codex; raw=%s", got, string(raw))
138+
}
139+
if got := data["access_token"]; got != "access" {
140+
t.Fatalf("access_token = %#v, want request metadata access; raw=%s", got, string(raw))
141+
}
142+
if got := data["disabled"]; got != false {
143+
t.Fatalf("disabled = %#v, want false; raw=%s", got, string(raw))
144+
}
145+
146+
updated, ok := manager.GetByID(fileName)
147+
if !ok || updated == nil {
148+
t.Fatalf("expected updated auth")
149+
}
150+
if got := updated.Provider; got != "codex" {
151+
t.Fatalf("updated.Provider = %q, want codex", got)
152+
}
153+
}

internal/api/handlers/management/config_lists.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -962,6 +962,7 @@ func (h *Handler) PatchCodexKey(c *gin.Context) {
962962
Models *[]config.CodexModel `json:"models"`
963963
Headers *map[string]string `json:"headers"`
964964
ExcludedModels *[]string `json:"excluded-models"`
965+
Priority *int `json:"priority"`
965966
}
966967
var body struct {
967968
Index *int `json:"index"`
@@ -1022,6 +1023,9 @@ func (h *Handler) PatchCodexKey(c *gin.Context) {
10221023
if body.Value.ExcludedModels != nil {
10231024
entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels)
10241025
}
1026+
if body.Value.Priority != nil {
1027+
entry.Priority = *body.Value.Priority
1028+
}
10251029
normalizeCodexKey(&entry)
10261030
h.cfg.CodexKey[targetIndex] = entry
10271031
h.cfg.SanitizeCodexKeys()

internal/api/handlers/management/config_lists_delete_keys_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"net/http/httptest"
66
"os"
77
"path/filepath"
8+
"strings"
89
"testing"
910

1011
"github.com/gin-gonic/gin"
@@ -22,6 +23,35 @@ func writeTestConfigFile(t *testing.T) string {
2223
return path
2324
}
2425

26+
func TestPatchCodexKey_UpdatesPriority(t *testing.T) {
27+
t.Parallel()
28+
gin.SetMode(gin.TestMode)
29+
30+
h := &Handler{
31+
cfg: &config.Config{
32+
CodexKey: []config.CodexKey{
33+
{APIKey: "codex-key", BaseURL: "https://api.openai.com", Priority: 1},
34+
},
35+
},
36+
configFilePath: writeTestConfigFile(t),
37+
}
38+
39+
rec := httptest.NewRecorder()
40+
c, _ := gin.CreateTestContext(rec)
41+
body := `{"match":"codex-key","value":{"priority":42}}`
42+
c.Request = httptest.NewRequest(http.MethodPatch, "/v0/management/codex-api-key", strings.NewReader(body))
43+
c.Request.Header.Set("Content-Type", "application/json")
44+
45+
h.PatchCodexKey(c)
46+
47+
if rec.Code != http.StatusOK {
48+
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
49+
}
50+
if got := h.cfg.CodexKey[0].Priority; got != 42 {
51+
t.Fatalf("priority = %d, want 42", got)
52+
}
53+
}
54+
2555
func TestDeleteGeminiKey_RequiresBaseURLWhenAPIKeyDuplicated(t *testing.T) {
2656
t.Parallel()
2757
gin.SetMode(gin.TestMode)

sdk/auth/filestore.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ func (s *FileTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (str
113113
return "", err
114114
}
115115
case auth.Metadata != nil:
116-
auth.Metadata["disabled"] = auth.Disabled
116+
auth.Metadata = mergeAuthMetadataForSave(path, auth.Metadata, auth.Provider, auth.Disabled)
117117
raw, errMarshal := json.Marshal(auth.Metadata)
118118
if errMarshal != nil {
119119
return "", fmt.Errorf("auth filestore: marshal metadata failed: %w", errMarshal)
@@ -156,6 +156,28 @@ func (s *FileTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (str
156156
return path, nil
157157
}
158158

159+
func mergeAuthMetadataForSave(path string, metadata map[string]any, provider string, disabled bool) map[string]any {
160+
merged := make(map[string]any)
161+
if existing, errRead := os.ReadFile(path); errRead == nil && len(existing) > 0 {
162+
var diskMetadata map[string]any
163+
if errUnmarshal := json.Unmarshal(existing, &diskMetadata); errUnmarshal == nil {
164+
for key, value := range diskMetadata {
165+
merged[key] = value
166+
}
167+
}
168+
}
169+
for key, value := range metadata {
170+
merged[key] = value
171+
}
172+
if provider = strings.TrimSpace(provider); provider != "" && !strings.EqualFold(provider, "unknown") {
173+
if rawType, _ := merged["type"].(string); strings.TrimSpace(rawType) == "" || strings.EqualFold(strings.TrimSpace(rawType), "unknown") {
174+
merged["type"] = provider
175+
}
176+
}
177+
merged["disabled"] = disabled
178+
return merged
179+
}
180+
159181
// List enumerates all auth JSON files under the configured directory.
160182
func (s *FileTokenStore) List(ctx context.Context) ([]*cliproxyauth.Auth, error) {
161183
dir := s.baseDirSnapshot()

0 commit comments

Comments
 (0)