diff --git a/API.en.md b/API.en.md index 1d6fe6c..9e6b539 100644 --- a/API.en.md +++ b/API.en.md @@ -130,7 +130,8 @@ Gemini-compatible clients can also send `x-goog-api-key`, `?key=`, or `?api_key= | POST | `/admin/settings/password` | Admin | Update admin password and invalidate old JWTs | | POST | `/admin/config/import` | Admin | Import config (merge/replace) | | GET | `/admin/config/export` | Admin | Export full config (`config`/`json`/`base64`) | -| POST | `/admin/keys` | Admin | Add API key | +| POST | `/admin/keys` | Admin | Add API key (optional `name`/`remark`) | +| PUT | `/admin/keys/{key}` | Admin | Update API key metadata | | DELETE | `/admin/keys/{key}` | Admin | Delete API key | | GET | `/admin/proxies` | Admin | List proxies | | POST | `/admin/proxies` | Admin | Add proxy | @@ -643,11 +644,15 @@ Returns Vercel preconfiguration status. ### `GET /admin/config` -Returns sanitized config. +Returns sanitized config, including both `keys` and `api_keys`. ```json { "keys": ["k1", "k2"], + "api_keys": [ + {"key": "k1", "name": "Primary", "remark": "Production"}, + {"key": "k2", "name": "Backup", "remark": "Load test"} + ], "env_backed": false, "env_source_present": true, "env_writeback_enabled": true, @@ -671,13 +676,18 @@ Returns sanitized config. ### `POST /admin/config` -Only updates `keys`, `accounts`, and `claude_mapping`. +Only updates `keys`, `api_keys`, `accounts`, and `claude_mapping`. +If both `api_keys` and `keys` are sent, the structured `api_keys` entries win so `name` / `remark` metadata is preserved; `keys` remains a legacy fallback. **Request**: ```json { "keys": ["k1", "k2"], + "api_keys": [ + {"key": "k1", "name": "Primary", "remark": "Production"}, + {"key": "k2", "name": "Backup", "remark": "Load test"} + ], "accounts": [ {"email": "user@example.com", "password": "pwd", "token": ""} ], @@ -737,7 +747,7 @@ Imports full config with: The request can send config directly, or wrapped as `{"config": {...}, "mode":"merge"}`. Query params `?mode=merge` / `?mode=replace` are also supported. -Import accepts `keys`, `accounts`, `claude_mapping` / `claude_model_mapping`, `model_aliases`, `admin`, `runtime`, `responses`, `embeddings`, and `auto_delete`; legacy `toolcall` fields are ignored. +Import accepts `keys`, `api_keys`, `accounts`, `claude_mapping` / `claude_model_mapping`, `model_aliases`, `admin`, `runtime`, `responses`, `embeddings`, and `auto_delete`; legacy `toolcall` fields are ignored. > `compat` fields are managed via `/admin/settings` or the config file; this import endpoint does not update `compat`. @@ -748,7 +758,17 @@ Exports full config in three forms: `config`, `json`, and `base64`. ### `POST /admin/keys` ```json -{"key": "new-api-key"} +{"key": "new-api-key", "name": "Primary", "remark": "Production"} +``` + +**Response**: `{"success": true, "total_keys": 3}` + +### `PUT /admin/keys/{key}` + +Updates the `name` / `remark` of the specified API key. The path `key` is read-only and cannot be changed. + +```json +{"name": "Backup", "remark": "Load test"} ``` **Response**: `{"success": true, "total_keys": 3}` diff --git a/API.md b/API.md index 1f9bcf5..af61e70 100644 --- a/API.md +++ b/API.md @@ -130,7 +130,8 @@ Gemini 兼容客户端还可以使用 `x-goog-api-key`、`?key=` 或 `?api_key=` | POST | `/admin/settings/password` | Admin | 更新 Admin 密码并使旧 JWT 失效 | | POST | `/admin/config/import` | Admin | 导入配置(merge/replace) | | GET | `/admin/config/export` | Admin | 导出完整配置(含 `config`/`json`/`base64`) | -| POST | `/admin/keys` | Admin | 添加 API key | +| POST | `/admin/keys` | Admin | 添加 API key(可附 name/remark) | +| PUT | `/admin/keys/{key}` | Admin | 更新 API key 备注信息 | | DELETE | `/admin/keys/{key}` | Admin | 删除 API key | | GET | `/admin/proxies` | Admin | 代理列表 | | POST | `/admin/proxies` | Admin | 添加代理 | @@ -644,11 +645,15 @@ data: {"type":"message_stop"} ### `GET /admin/config` -返回脱敏后的配置。 +返回脱敏后的配置,包含 `keys` 与 `api_keys`。 ```json { "keys": ["k1", "k2"], + "api_keys": [ + {"key": "k1", "name": "主 Key", "remark": "生产流量"}, + {"key": "k2", "name": "备用 Key", "remark": "压测"} + ], "env_backed": false, "env_source_present": true, "env_writeback_enabled": true, @@ -672,13 +677,18 @@ data: {"type":"message_stop"} ### `POST /admin/config` -只更新 `keys`、`accounts`、`claude_mapping`。 +只更新 `keys`、`api_keys`、`accounts`、`claude_mapping`。 +如果同时发送 `api_keys` 与 `keys`,优先保留 `api_keys` 中的结构化 `name` / `remark`;`keys` 仅作为旧格式兼容回退。 **请求**: ```json { "keys": ["k1", "k2"], + "api_keys": [ + {"key": "k1", "name": "主 Key", "remark": "生产流量"}, + {"key": "k2", "name": "备用 Key", "remark": "压测"} + ], "accounts": [ {"email": "user@example.com", "password": "pwd", "token": ""} ], @@ -738,7 +748,7 @@ data: {"type":"message_stop"} 请求可直接传配置对象,或使用 `{"config": {...}, "mode":"merge"}` 包裹格式。 也支持在查询参数里传 `?mode=merge` / `?mode=replace`。 -导入时会接受 `keys`、`accounts`、`claude_mapping` / `claude_model_mapping`、`model_aliases`、`admin`、`runtime`、`responses`、`embeddings`、`auto_delete` 等字段;`toolcall` 相关字段会被忽略。 +导入时会接受 `keys`、`api_keys`、`accounts`、`claude_mapping` / `claude_model_mapping`、`model_aliases`、`admin`、`runtime`、`responses`、`embeddings`、`auto_delete` 等字段;`toolcall` 相关字段会被忽略。 > `compat` 相关字段请通过 `/admin/settings` 或配置文件管理;该导入接口不会更新 `compat`。 @@ -749,7 +759,17 @@ data: {"type":"message_stop"} ### `POST /admin/keys` ```json -{"key": "new-api-key"} +{"key": "new-api-key", "name": "主 Key", "remark": "生产流量"} +``` + +**响应**:`{"success": true, "total_keys": 3}` + +### `PUT /admin/keys/{key}` + +更新指定 API key 的 `name` / `remark`,路径参数中的 `key` 为只读标识,不可修改。 + +```json +{"name": "备用 Key", "remark": "压测"} ``` **响应**:`{"success": true, "total_keys": 3}` diff --git a/VERSION b/VERSION index 87ce492..40c341b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.5.2 +3.6.0 diff --git a/internal/admin/handler.go b/internal/admin/handler.go index bed3894..02bd69e 100644 --- a/internal/admin/handler.go +++ b/internal/admin/handler.go @@ -25,6 +25,7 @@ func RegisterRoutes(r chi.Router, h *Handler) { pr.Post("/config/import", h.configImport) pr.Get("/config/export", h.configExport) pr.Post("/keys", h.addKey) + pr.Put("/keys/{key}", h.updateKey) pr.Delete("/keys/{key}", h.deleteKey) pr.Get("/proxies", h.listProxies) pr.Post("/proxies", h.addProxy) diff --git a/internal/admin/handler_config_import.go b/internal/admin/handler_config_import.go index a28b4e5..c238428 100644 --- a/internal/admin/handler_config_import.go +++ b/internal/admin/handler_config_import.go @@ -53,15 +53,19 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) { next.Accounts = normalizeAndDedupeAccounts(next.Accounts) next.VercelSyncHash = c.VercelSyncHash next.VercelSyncTime = c.VercelSyncTime - importedKeys = len(next.Keys) + importedKeys = len(next.APIKeys) importedAccounts = len(next.Accounts) } else { existingKeys := map[string]struct{}{} - for _, k := range next.Keys { - existingKeys[k] = struct{}{} + for _, item := range next.APIKeys { + key := strings.TrimSpace(item.Key) + if key == "" { + continue + } + existingKeys[key] = struct{}{} } - for _, k := range incoming.Keys { - key := strings.TrimSpace(k) + for _, item := range incoming.APIKeys { + key := strings.TrimSpace(item.Key) if key == "" { continue } @@ -69,7 +73,7 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) { continue } existingKeys[key] = struct{}{} - next.Keys = append(next.Keys, key) + next.APIKeys = append(next.APIKeys, item) importedKeys++ } diff --git a/internal/admin/handler_config_write.go b/internal/admin/handler_config_write.go index adddfad..ae696bc 100644 --- a/internal/admin/handler_config_write.go +++ b/internal/admin/handler_config_write.go @@ -21,8 +21,7 @@ func (h *Handler) updateConfig(w http.ResponseWriter, r *http.Request) { err := h.Store.Update(func(c *config.Config) error { if apiKeys, ok := toAPIKeys(req["api_keys"]); ok { c.APIKeys = apiKeys - } - if keys, ok := toStringSlice(req["keys"]); ok { + } else if keys, ok := toStringSlice(req["keys"]); ok { legacy := make([]config.APIKey, 0, len(keys)) for _, key := range keys { if key == "" { @@ -110,6 +109,47 @@ func (h *Handler) addKey(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_keys": len(h.Store.Snapshot().Keys)}) } +func (h *Handler) updateKey(w http.ResponseWriter, r *http.Request) { + key := strings.TrimSpace(chi.URLParam(r, "key")) + if key == "" { + writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "key 不能为空"}) + return + } + + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "invalid json"}) + return + } + name, nameOK := fieldStringOptional(req, "name") + remark, remarkOK := fieldStringOptional(req, "remark") + + err := h.Store.Update(func(c *config.Config) error { + idx := -1 + for i, item := range c.APIKeys { + if item.Key == key { + idx = i + break + } + } + if idx < 0 { + return fmt.Errorf("key 不存在") + } + if nameOK { + c.APIKeys[idx].Name = name + } + if remarkOK { + c.APIKeys[idx].Remark = remark + } + return nil + }) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]any{"detail": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"success": true, "total_keys": len(h.Store.Snapshot().Keys)}) +} + func (h *Handler) deleteKey(w http.ResponseWriter, r *http.Request) { key := chi.URLParam(r, "key") err := h.Store.Update(func(c *config.Config) error { diff --git a/internal/admin/handler_keys_test.go b/internal/admin/handler_keys_test.go new file mode 100644 index 0000000..82ff5e2 --- /dev/null +++ b/internal/admin/handler_keys_test.go @@ -0,0 +1,76 @@ +package admin + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" +) + +func TestKeyEndpointsPreserveStructuredMetadata(t *testing.T) { + h := newAdminTestHandler(t, `{ + "api_keys":[{"key":"k1","name":"primary","remark":"prod"}] + }`) + + r := chi.NewRouter() + r.Post("/admin/keys", h.addKey) + r.Put("/admin/keys/{key}", h.updateKey) + r.Delete("/admin/keys/{key}", h.deleteKey) + + addBody := []byte(`{"key":"k2","name":"secondary","remark":"staging"}`) + addReq := httptest.NewRequest(http.MethodPost, "/admin/keys", bytes.NewReader(addBody)) + addRec := httptest.NewRecorder() + r.ServeHTTP(addRec, addReq) + if addRec.Code != http.StatusOK { + t.Fatalf("add status=%d body=%s", addRec.Code, addRec.Body.String()) + } + + snap := h.Store.Snapshot() + if len(snap.APIKeys) != 2 { + t.Fatalf("unexpected api keys after add: %#v", snap.APIKeys) + } + if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" { + t.Fatalf("existing metadata was lost after add: %#v", snap.APIKeys[0]) + } + if snap.APIKeys[1].Name != "secondary" || snap.APIKeys[1].Remark != "staging" { + t.Fatalf("new metadata was lost after add: %#v", snap.APIKeys[1]) + } + + updateBody := map[string]any{ + "name": "primary-updated", + "remark": "prod-updated", + } + updateBytes, _ := json.Marshal(updateBody) + updateReq := httptest.NewRequest(http.MethodPut, "/admin/keys/k1", bytes.NewReader(updateBytes)) + updateRec := httptest.NewRecorder() + r.ServeHTTP(updateRec, updateReq) + if updateRec.Code != http.StatusOK { + t.Fatalf("update status=%d body=%s", updateRec.Code, updateRec.Body.String()) + } + + snap = h.Store.Snapshot() + if len(snap.APIKeys) != 2 { + t.Fatalf("unexpected api keys after update: %#v", snap.APIKeys) + } + if snap.APIKeys[0].Key != "k1" || snap.APIKeys[0].Name != "primary-updated" || snap.APIKeys[0].Remark != "prod-updated" { + t.Fatalf("metadata update did not persist: %#v", snap.APIKeys[0]) + } + + deleteReq := httptest.NewRequest(http.MethodDelete, "/admin/keys/k1", nil) + deleteRec := httptest.NewRecorder() + r.ServeHTTP(deleteRec, deleteReq) + if deleteRec.Code != http.StatusOK { + t.Fatalf("delete status=%d body=%s", deleteRec.Code, deleteRec.Body.String()) + } + + snap = h.Store.Snapshot() + if len(snap.APIKeys) != 1 || snap.APIKeys[0].Key != "k2" { + t.Fatalf("unexpected api keys after delete: %#v", snap.APIKeys) + } + if len(snap.Keys) != 1 || snap.Keys[0] != "k2" { + t.Fatalf("unexpected legacy keys after delete: %#v", snap.Keys) + } +} diff --git a/internal/admin/handler_settings_test.go b/internal/admin/handler_settings_test.go index d698b67..9aeba35 100644 --- a/internal/admin/handler_settings_test.go +++ b/internal/admin/handler_settings_test.go @@ -234,6 +234,43 @@ func TestUpdateSettingsHotReloadTokenRefreshInterval(t *testing.T) { } } +func TestUpdateConfigPreservesStructuredAPIKeysWhenBothFieldsPresent(t *testing.T) { + h := newAdminTestHandler(t, `{ + "keys":["legacy"], + "api_keys":[{"key":"legacy","name":"primary","remark":"prod"}], + "accounts":[] + }`) + + payload := map[string]any{ + "keys": []any{"legacy", "new-key"}, + "api_keys": []any{ + map[string]any{"key": "legacy", "name": "primary-updated", "remark": "prod-updated"}, + map[string]any{"key": "new-key", "name": "secondary", "remark": "staging"}, + }, + } + b, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/admin/config", bytes.NewReader(b)) + rec := httptest.NewRecorder() + h.updateConfig(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + + snap := h.Store.Snapshot() + if len(snap.Keys) != 2 || snap.Keys[0] != "legacy" || snap.Keys[1] != "new-key" { + t.Fatalf("unexpected keys after config update: %#v", snap.Keys) + } + if len(snap.APIKeys) != 2 { + t.Fatalf("unexpected api keys after config update: %#v", snap.APIKeys) + } + if snap.APIKeys[0].Name != "primary-updated" || snap.APIKeys[0].Remark != "prod-updated" { + t.Fatalf("structured metadata for existing key was not preserved: %#v", snap.APIKeys[0]) + } + if snap.APIKeys[1].Name != "secondary" || snap.APIKeys[1].Remark != "staging" { + t.Fatalf("structured metadata for new key was not preserved: %#v", snap.APIKeys[1]) + } +} + func TestUpdateSettingsPasswordInvalidatesOldJWT(t *testing.T) { hash := authn.HashAdminPassword("old-password") h := newAdminTestHandler(t, `{"admin":{"password_hash":"`+hash+`"}}`) @@ -315,6 +352,40 @@ func TestConfigImportMergeAndReplace(t *testing.T) { } } +func TestConfigImportMergePreservesStructuredAPIKeys(t *testing.T) { + h := newAdminTestHandler(t, `{ + "api_keys":[{"key":"k1","name":"primary","remark":"prod"}] + }`) + + merge := map[string]any{ + "mode": "merge", + "config": map[string]any{ + "api_keys": []any{ + map[string]any{"key": "k1", "name": "should-not-overwrite", "remark": "ignored"}, + map[string]any{"key": "k2", "name": "secondary", "remark": "staging"}, + }, + }, + } + mergeBytes, _ := json.Marshal(merge) + mergeReq := httptest.NewRequest(http.MethodPost, "/admin/config/import?mode=merge", bytes.NewReader(mergeBytes)) + mergeRec := httptest.NewRecorder() + h.configImport(mergeRec, mergeReq) + if mergeRec.Code != http.StatusOK { + t.Fatalf("merge status=%d body=%s", mergeRec.Code, mergeRec.Body.String()) + } + + snap := h.Store.Snapshot() + if len(snap.APIKeys) != 2 { + t.Fatalf("unexpected api keys after structured merge: %#v", snap.APIKeys) + } + if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" { + t.Fatalf("existing structured metadata was overwritten: %#v", snap.APIKeys[0]) + } + if snap.APIKeys[1].Name != "secondary" || snap.APIKeys[1].Remark != "staging" { + t.Fatalf("new structured metadata was lost: %#v", snap.APIKeys[1]) + } +} + func TestConfigImportAppliesTokenRefreshInterval(t *testing.T) { h := newAdminTestHandler(t, `{"keys":["k1"]}`) diff --git a/internal/admin/helpers.go b/internal/admin/helpers.go index 5af8388..8411b3b 100644 --- a/internal/admin/helpers.go +++ b/internal/admin/helpers.go @@ -117,6 +117,14 @@ func fieldString(m map[string]any, key string) string { return strings.TrimSpace(fmt.Sprintf("%v", v)) } +func fieldStringOptional(m map[string]any, key string) (string, bool) { + v, ok := m[key] + if !ok || v == nil { + return "", false + } + return strings.TrimSpace(fmt.Sprintf("%v", v)), true +} + func statusOr(v int, d int) int { if v == 0 { return d diff --git a/internal/config/config.go b/internal/config/config.go index 3566f2e..05879c2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -86,65 +86,14 @@ func (c *Config) NormalizeCredentials() { if c == nil { return } - normalizedAPIKeys := make([]APIKey, 0, len(c.APIKeys)) - metaByKey := make(map[string]APIKey, len(c.APIKeys)) - for _, item := range c.APIKeys { - key := strings.TrimSpace(item.Key) - if key == "" { - continue - } - if _, ok := metaByKey[key]; ok { - continue - } - metaByKey[key] = APIKey{ - Key: key, - Name: strings.TrimSpace(item.Name), - Remark: strings.TrimSpace(item.Remark), - } - } - if len(c.Keys) > 0 { - seen := make(map[string]struct{}, len(c.Keys)) - for _, key := range c.Keys { - key = strings.TrimSpace(key) - if key == "" { - continue - } - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - if item, ok := metaByKey[key]; ok { - normalizedAPIKeys = append(normalizedAPIKeys, item) - } else { - normalizedAPIKeys = append(normalizedAPIKeys, APIKey{Key: key}) - } - } + normalizedAPIKeys := normalizeAPIKeys(c.APIKeys) + if len(normalizedAPIKeys) > 0 { + c.APIKeys = normalizedAPIKeys + c.Keys = apiKeysToStrings(c.APIKeys) } else { - normalizedAPIKeys = make([]APIKey, 0, len(c.APIKeys)) - seen := make(map[string]struct{}, len(c.APIKeys)) - for _, item := range c.APIKeys { - key := strings.TrimSpace(item.Key) - if key == "" { - continue - } - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - normalizedAPIKeys = append(normalizedAPIKeys, APIKey{ - Key: key, - Name: strings.TrimSpace(item.Name), - Remark: strings.TrimSpace(item.Remark), - }) - } + c.Keys = normalizeKeys(c.Keys) + c.APIKeys = apiKeysFromStrings(c.Keys, nil) } - c.APIKeys = normalizedAPIKeys - - keys := make([]string, 0, len(c.APIKeys)) - for _, item := range c.APIKeys { - keys = append(keys, item.Key) - } - c.Keys = keys for i := range c.Accounts { c.Accounts[i].Name = strings.TrimSpace(c.Accounts[i].Name) diff --git a/internal/config/config_edge_test.go b/internal/config/config_edge_test.go index 0de0815..b70cf11 100644 --- a/internal/config/config_edge_test.go +++ b/internal/config/config_edge_test.go @@ -529,6 +529,101 @@ func TestStoreUpdate(t *testing.T) { } } +func TestStoreUpdateReconcilesAPIKeyMutations(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{ + "keys":["k1"], + "api_keys":[{"key":"k1","name":"primary","remark":"prod"}], + "accounts":[] + }`) + store := LoadStore() + + if err := store.Update(func(cfg *Config) error { + cfg.APIKeys = append(cfg.APIKeys, APIKey{Key: "k2", Name: "secondary", Remark: "staging"}) + return nil + }); err != nil { + t.Fatalf("add api key failed: %v", err) + } + + snap := store.Snapshot() + if len(snap.Keys) != 2 || snap.Keys[0] != "k1" || snap.Keys[1] != "k2" { + t.Fatalf("unexpected keys after api key add: %#v", snap.Keys) + } + if len(snap.APIKeys) != 2 { + t.Fatalf("unexpected api keys length after add: %#v", snap.APIKeys) + } + if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" { + t.Fatalf("metadata for existing key was lost: %#v", snap.APIKeys[0]) + } + if snap.APIKeys[1].Name != "secondary" || snap.APIKeys[1].Remark != "staging" { + t.Fatalf("metadata for new key was lost: %#v", snap.APIKeys[1]) + } + + if err := store.Update(func(cfg *Config) error { + cfg.APIKeys = append([]APIKey(nil), cfg.APIKeys[1:]...) + return nil + }); err != nil { + t.Fatalf("delete api key failed: %v", err) + } + + snap = store.Snapshot() + if len(snap.Keys) != 1 || snap.Keys[0] != "k2" { + t.Fatalf("unexpected keys after api key delete: %#v", snap.Keys) + } + if len(snap.APIKeys) != 1 || snap.APIKeys[0].Key != "k2" { + t.Fatalf("unexpected api keys after delete: %#v", snap.APIKeys) + } +} + +func TestStoreUpdateReconcilesLegacyKeyMutations(t *testing.T) { + t.Setenv("DS2API_CONFIG_JSON", `{ + "keys":["k1"], + "api_keys":[{"key":"k1","name":"primary","remark":"prod"}], + "accounts":[] + }`) + store := LoadStore() + + if err := store.Update(func(cfg *Config) error { + cfg.Keys = append(cfg.Keys, "k2") + return nil + }); err != nil { + t.Fatalf("legacy key update failed: %v", err) + } + + snap := store.Snapshot() + if len(snap.Keys) != 2 || snap.Keys[0] != "k1" || snap.Keys[1] != "k2" { + t.Fatalf("unexpected keys after legacy update: %#v", snap.Keys) + } + if len(snap.APIKeys) != 2 { + t.Fatalf("unexpected api keys after legacy update: %#v", snap.APIKeys) + } + if snap.APIKeys[0].Name != "primary" || snap.APIKeys[0].Remark != "prod" { + t.Fatalf("metadata for preserved key was lost: %#v", snap.APIKeys[0]) + } + if snap.APIKeys[1].Key != "k2" || snap.APIKeys[1].Name != "" || snap.APIKeys[1].Remark != "" { + t.Fatalf("new legacy key should stay metadata-free: %#v", snap.APIKeys[1]) + } +} + +func TestNormalizeCredentialsPrefersStructuredAPIKeys(t *testing.T) { + cfg := Config{ + Keys: []string{"legacy-key"}, + APIKeys: []APIKey{ + {Key: "structured-key", Name: "primary", Remark: "prod"}, + }, + } + cfg.NormalizeCredentials() + + if len(cfg.Keys) != 1 || cfg.Keys[0] != "structured-key" { + t.Fatalf("unexpected normalized keys: %#v", cfg.Keys) + } + if len(cfg.APIKeys) != 1 { + t.Fatalf("unexpected normalized api keys: %#v", cfg.APIKeys) + } + if cfg.APIKeys[0].Key != "structured-key" || cfg.APIKeys[0].Name != "primary" || cfg.APIKeys[0].Remark != "prod" { + t.Fatalf("unexpected structured api key metadata: %#v", cfg.APIKeys[0]) + } +} + func TestStoreClaudeMapping(t *testing.T) { t.Setenv("DS2API_CONFIG_JSON", `{"keys":[],"accounts":[],"claude_mapping":{"fast":"deepseek-chat","slow":"deepseek-reasoner"}}`) store := LoadStore() diff --git a/internal/config/credentials.go b/internal/config/credentials.go new file mode 100644 index 0000000..a29f314 --- /dev/null +++ b/internal/config/credentials.go @@ -0,0 +1,158 @@ +package config + +import ( + "slices" + "strings" +) + +func (c *Config) ReconcileCredentials(base Config) { + if c == nil { + return + } + currKeys := normalizeKeys(c.Keys) + currAPIKeys := normalizeAPIKeys(c.APIKeys) + baseKeys := normalizeKeys(base.Keys) + baseAPIKeys := normalizeAPIKeys(base.APIKeys) + + keysChanged := !slices.Equal(currKeys, baseKeys) + apiKeysChanged := !equalAPIKeys(currAPIKeys, baseAPIKeys) + + if keysChanged && !apiKeysChanged { + c.APIKeys = apiKeysFromStrings(currKeys, apiKeyMap(baseAPIKeys)) + } else { + c.APIKeys = currAPIKeys + } + c.Keys = apiKeysToStrings(c.APIKeys) +} + +func normalizeKeys(keys []string) []string { + if len(keys) == 0 { + return nil + } + out := make([]string, 0, len(keys)) + seen := make(map[string]struct{}, len(keys)) + for _, key := range keys { + key = strings.TrimSpace(key) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, key) + } + if len(out) == 0 { + return nil + } + return out +} + +func normalizeAPIKeys(items []APIKey) []APIKey { + if len(items) == 0 { + return nil + } + out := make([]APIKey, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + key := strings.TrimSpace(item.Key) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, APIKey{ + Key: key, + Name: strings.TrimSpace(item.Name), + Remark: strings.TrimSpace(item.Remark), + }) + } + if len(out) == 0 { + return nil + } + return out +} + +func apiKeysFromStrings(keys []string, meta map[string]APIKey) []APIKey { + if len(keys) == 0 { + return nil + } + out := make([]APIKey, 0, len(keys)) + seen := make(map[string]struct{}, len(keys)) + for _, key := range keys { + key = strings.TrimSpace(key) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + if item, ok := meta[key]; ok { + out = append(out, APIKey{ + Key: key, + Name: strings.TrimSpace(item.Name), + Remark: strings.TrimSpace(item.Remark), + }) + continue + } + out = append(out, APIKey{Key: key}) + } + if len(out) == 0 { + return nil + } + return out +} + +func apiKeysToStrings(items []APIKey) []string { + if len(items) == 0 { + return nil + } + keys := make([]string, 0, len(items)) + for _, item := range items { + key := strings.TrimSpace(item.Key) + if key == "" { + continue + } + keys = append(keys, key) + } + if len(keys) == 0 { + return nil + } + return keys +} + +func apiKeyMap(items []APIKey) map[string]APIKey { + if len(items) == 0 { + return nil + } + out := make(map[string]APIKey, len(items)) + for _, item := range items { + key := strings.TrimSpace(item.Key) + if key == "" { + continue + } + if _, ok := out[key]; ok { + continue + } + out[key] = APIKey{ + Key: key, + Name: strings.TrimSpace(item.Name), + Remark: strings.TrimSpace(item.Remark), + } + } + return out +} + +func equalAPIKeys(a, b []APIKey) bool { + if len(a) != len(b) { + return false + } + return slices.EqualFunc(a, b, func(x, y APIKey) bool { + return strings.TrimSpace(x.Key) == strings.TrimSpace(y.Key) && + strings.TrimSpace(x.Name) == strings.TrimSpace(y.Name) && + strings.TrimSpace(x.Remark) == strings.TrimSpace(y.Remark) + }) +} diff --git a/internal/config/store.go b/internal/config/store.go index 3e397ef..a54e012 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -218,10 +218,12 @@ func (s *Store) Replace(cfg Config) error { func (s *Store) Update(mutator func(*Config) error) error { s.mu.Lock() defer s.mu.Unlock() - cfg := s.cfg.Clone() + base := s.cfg.Clone() + cfg := base.Clone() if err := mutator(&cfg); err != nil { return err } + cfg.ReconcileCredentials(base) cfg.NormalizeCredentials() s.cfg = cfg s.rebuildIndexes() diff --git a/webui/src/features/account/AccountManagerContainer.jsx b/webui/src/features/account/AccountManagerContainer.jsx index 3e00241..9b88ca1 100644 --- a/webui/src/features/account/AccountManagerContainer.jsx +++ b/webui/src/features/account/AccountManagerContainer.jsx @@ -30,7 +30,10 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage, const { showAddKey, - setShowAddKey, + openAddKey, + openEditKey, + closeKeyModal, + editingKey, showAddAccount, setShowAddAccount, newKey, @@ -94,7 +97,8 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage, config={config} keysExpanded={keysExpanded} setKeysExpanded={setKeysExpanded} - setShowAddKey={setShowAddKey} + onAddKey={openAddKey} + onEditKey={openEditKey} copiedKey={copiedKey} setCopiedKey={setCopiedKey} onDeleteKey={deleteKey} @@ -133,10 +137,11 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage, setShowAddKey(false)} + onClose={closeKeyModal} onAdd={addKey} /> diff --git a/webui/src/features/account/AddKeyModal.jsx b/webui/src/features/account/AddKeyModal.jsx index 1311b78..875101a 100644 --- a/webui/src/features/account/AddKeyModal.jsx +++ b/webui/src/features/account/AddKeyModal.jsx @@ -1,40 +1,47 @@ import { X } from 'lucide-react' -export default function AddKeyModal({ show, t, newKey, setNewKey, loading, onClose, onAdd }) { +export default function AddKeyModal({ show, t, editingKey, newKey, setNewKey, loading, onClose, onAdd }) { if (!show) { return null } + const isEditing = Boolean(editingKey?.key) + return (
-

{t('accountManager.modalAddKeyTitle')}

+

{isEditing ? t('accountManager.modalEditKeyTitle') : t('accountManager.modalAddKeyTitle')}

- +
setNewKey({ ...newKey, key: e.target.value })} - autoFocus + autoFocus={!isEditing} + readOnly={isEditing} /> - + {!isEditing && ( + + )}
-

{t('accountManager.generateHint')}

+

+ {isEditing ? t('accountManager.keyReadonlyHint') : t('accountManager.generateHint')} +

@@ -44,6 +51,7 @@ export default function AddKeyModal({ show, t, newKey, setNewKey, loading, onClo placeholder={t('accountManager.namePlaceholder')} value={newKey.name} onChange={e => setNewKey({ ...newKey, name: e.target.value })} + autoFocus={isEditing} />
@@ -59,7 +67,9 @@ export default function AddKeyModal({ show, t, newKey, setNewKey, loading, onClo
diff --git a/webui/src/features/account/ApiKeysPanel.jsx b/webui/src/features/account/ApiKeysPanel.jsx index 17f8c71..7030d8b 100644 --- a/webui/src/features/account/ApiKeysPanel.jsx +++ b/webui/src/features/account/ApiKeysPanel.jsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { Check, ChevronDown, Copy, Plus, Trash2 } from 'lucide-react' +import { Check, ChevronDown, Copy, Pencil, Plus, Trash2 } from 'lucide-react' import clsx from 'clsx' function fallbackCopyText(text) { @@ -31,7 +31,8 @@ export default function ApiKeysPanel({ config, keysExpanded, setKeysExpanded, - setShowAddKey, + onAddKey, + onEditKey, copiedKey, setCopiedKey, onDeleteKey, @@ -81,7 +82,7 @@ export default function ApiKeysPanel({
+