From de47d455ea333df768503b0fa1bf2fa99a800c07 Mon Sep 17 00:00:00 2001 From: seakee Date: Thu, 14 May 2026 08:45:21 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20feat(usage-service):=20add=20AP?= =?UTF-8?q?I=20key=20alias=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create the api_key_aliases SQLite table for CPAM-owned API key display metadata. The table stores only SHA-256 API key hashes, aliases, and update timestamps, keeping CPA api-keys unchanged. Refs #21 --- usage-service/internal/store/store.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/usage-service/internal/store/store.go b/usage-service/internal/store/store.go index 1cea3275e..97d4b3592 100644 --- a/usage-service/internal/store/store.go +++ b/usage-service/internal/store/store.go @@ -166,6 +166,11 @@ func (s *Store) init() error { updated_at_ms integer not null, synced_at_ms integer )`, + `create table if not exists api_key_aliases ( + api_key_hash text primary key, + alias text not null, + updated_at_ms integer not null + )`, } for _, statement := range statements { if _, err := s.db.Exec(statement); err != nil { From 895fa7656f4887197109db259996a244875b70da Mon Sep 17 00:00:00 2001 From: seakee Date: Thu, 14 May 2026 08:46:02 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8=20feat(usage-service):=20expose?= =?UTF-8?q?=20API=20key=20aliases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add management endpoints to load, save, and delete API key alias mappings backed by SQLite. Validate hashes and enforce case-insensitive alias uniqueness so monitoring labels remain unambiguous. Also carries api_key_hash through usage payload details for historical request lookup. Refs #21 --- usage-service/internal/httpapi/server.go | 59 +++++++ usage-service/internal/httpapi/server_test.go | 66 ++++++++ usage-service/internal/store/store.go | 150 ++++++++++++++++++ usage-service/internal/store/store_test.go | 72 +++++++++ usage-service/internal/usage/event.go | 2 + 5 files changed, 349 insertions(+) diff --git a/usage-service/internal/httpapi/server.go b/usage-service/internal/httpapi/server.go index 6f022032b..a4d8e10a6 100644 --- a/usage-service/internal/httpapi/server.go +++ b/usage-service/internal/httpapi/server.go @@ -82,6 +82,10 @@ type modelPricesSyncRequest struct { Models []string `json:"models"` } +type apiKeyAliasesRequest struct { + Items []store.APIKeyAlias `json:"items"` +} + func New(cfg config.Config, store *store.Store, collector *collector.Manager) *Server { return &Server{ cfg: cfg, @@ -113,6 +117,10 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { s.withCORS(s.handleModelPrices)(w, r) return } + if strings.HasPrefix(r.URL.Path, "/v0/management/api-key-aliases") { + s.withCORS(s.handleAPIKeyAliases)(w, r) + return + } cleanUsagePath := strings.TrimRight(r.URL.Path, "/") if cleanUsagePath == "/v0/management/usage" || strings.HasPrefix(cleanUsagePath, "/v0/management/usage/") { s.withCORS(s.handleUsage)(w, r) @@ -465,6 +473,53 @@ func (s *Server) handleModelPrices(w http.ResponseWriter, r *http.Request) { } } +func (s *Server) handleAPIKeyAliases(w http.ResponseWriter, r *http.Request) { + if !s.authorizeIfConfigured(w, r) { + return + } + + path := strings.TrimRight(r.URL.Path, "/") + const basePath = "/v0/management/api-key-aliases" + switch { + case path == basePath && r.Method == http.MethodGet: + aliases, err := s.store.LoadAPIKeyAliases(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": aliases}) + case path == basePath && r.Method == http.MethodPut: + var req apiKeyAliasesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if req.Items == nil { + writeError(w, http.StatusBadRequest, errors.New("api key aliases are required")) + return + } + if err := s.store.UpsertAPIKeyAliases(r.Context(), req.Items); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + aliases, err := s.store.LoadAPIKeyAliases(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": aliases}) + case strings.HasPrefix(path, basePath+"/") && r.Method == http.MethodDelete: + apiKeyHash := strings.TrimPrefix(path, basePath+"/") + if err := s.store.DeleteAPIKeyAlias(r.Context(), apiKeyHash); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + default: + methodNotAllowed(w) + } +} + func fetchLiteLLMModelPrices(ctx context.Context) (map[string]store.ModelPrice, int, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelPriceSyncURL, nil) if err != nil { @@ -1221,6 +1276,10 @@ func usageServiceErrorCode(err error) string { return "enable_cpa_usage_statistics_failed" case strings.Contains(message, "prices are required"): return "prices_required" + case strings.Contains(message, "api key aliases are required"): + return "api_key_aliases_required" + case strings.Contains(message, "api key alias already exists"): + return "api_key_alias_duplicate" case strings.Contains(message, "model price sync failed"): return "model_price_sync_failed" case strings.Contains(message, "method not allowed"): diff --git a/usage-service/internal/httpapi/server_test.go b/usage-service/internal/httpapi/server_test.go index 517a52ecf..0f6cfac0c 100644 --- a/usage-service/internal/httpapi/server_test.go +++ b/usage-service/internal/httpapi/server_test.go @@ -507,6 +507,72 @@ func TestModelPricesSaveAndLoad(t *testing.T) { } } +func TestAPIKeyAliasesSaveLoadAndDelete(t *testing.T) { + handler := newTestHandler(t, "http://example.test", true) + const hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + body := bytes.NewBufferString(`{"items":[{"apiKeyHash":"` + hash + `","alias":"Team A"}]}`) + req := httptest.NewRequest(http.MethodPut, "/v0/management/api-key-aliases", body) + req.Header.Set("Authorization", "Bearer management-key") + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("save status = %d, body = %s", rr.Code, rr.Body.String()) + } + + req = httptest.NewRequest(http.MethodGet, "/v0/management/api-key-aliases", nil) + req.Header.Set("Authorization", "Bearer management-key") + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("load status = %d, body = %s", rr.Code, rr.Body.String()) + } + var response struct { + Items []struct { + APIKeyHash string `json:"apiKeyHash"` + Alias string `json:"alias"` + UpdatedAtMS int64 `json:"updatedAtMs"` + } `json:"items"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(response.Items) != 1 { + t.Fatalf("items = %#v", response.Items) + } + if response.Items[0].APIKeyHash != hash || response.Items[0].Alias != "Team A" || response.Items[0].UpdatedAtMS <= 0 { + t.Fatalf("alias = %#v", response.Items[0]) + } + + const otherHash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + req = httptest.NewRequest( + http.MethodPut, + "/v0/management/api-key-aliases", + bytes.NewBufferString(`{"items":[{"apiKeyHash":"`+otherHash+`","alias":" team a "}]}`), + ) + req.Header.Set("Authorization", "Bearer management-key") + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("duplicate status = %d, body = %s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), `"code":"api_key_alias_duplicate"`) { + t.Fatalf("duplicate body = %s", rr.Body.String()) + } + + req = httptest.NewRequest(http.MethodDelete, "/v0/management/api-key-aliases/"+hash, nil) + req.Header.Set("Authorization", "Bearer management-key") + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("delete status = %d, body = %s", rr.Code, rr.Body.String()) + } +} + func TestModelPricesSyncFromLiteLLMFormat(t *testing.T) { source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/usage-service/internal/store/store.go b/usage-service/internal/store/store.go index 97d4b3592..7de7e13ba 100644 --- a/usage-service/internal/store/store.go +++ b/usage-service/internal/store/store.go @@ -9,6 +9,7 @@ import ( "math" "os" "path/filepath" + "strings" "time" _ "modernc.org/sqlite" @@ -72,6 +73,12 @@ type ModelPriceSyncResult struct { Skipped int `json:"skipped"` } +type APIKeyAlias struct { + APIKeyHash string `json:"apiKeyHash"` + Alias string `json:"alias"` + UpdatedAtMS int64 `json:"updatedAtMs"` +} + type Store struct { db *sql.DB } @@ -457,6 +464,149 @@ func (s *Store) UpsertSyncedModelPrices(ctx context.Context, prices map[string]M return result, nil } +func (s *Store) LoadAPIKeyAliases(ctx context.Context) ([]APIKeyAlias, error) { + rows, err := s.db.QueryContext(ctx, `select api_key_hash, alias, updated_at_ms + from api_key_aliases + order by alias collate nocase, api_key_hash`) + if err != nil { + return nil, err + } + defer rows.Close() + + aliases := []APIKeyAlias{} + for rows.Next() { + var alias APIKeyAlias + if err := rows.Scan(&alias.APIKeyHash, &alias.Alias, &alias.UpdatedAtMS); err != nil { + return nil, err + } + aliases = append(aliases, alias) + } + return aliases, rows.Err() +} + +func (s *Store) UpsertAPIKeyAliases(ctx context.Context, aliases []APIKeyAlias) error { + if len(aliases) == 0 { + return nil + } + now := time.Now().UnixMilli() + normalizedAliases := make([]APIKeyAlias, 0, len(aliases)) + seenAliases := map[string]string{} + for _, alias := range aliases { + normalized, err := normalizeAPIKeyAlias(alias, now) + if err != nil { + return err + } + aliasKey := normalizeAPIKeyAliasUniqueKey(normalized.Alias) + if existingHash, ok := seenAliases[aliasKey]; ok && existingHash != normalized.APIKeyHash { + return errors.New("api key alias already exists") + } + seenAliases[aliasKey] = normalized.APIKeyHash + normalizedAliases = append(normalizedAliases, normalized) + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { + _ = tx.Rollback() + }() + + stmt, err := tx.PrepareContext(ctx, `insert into api_key_aliases ( + api_key_hash, alias, updated_at_ms + ) values (?, ?, ?) + on conflict(api_key_hash) do update set + alias = excluded.alias, + updated_at_ms = excluded.updated_at_ms`) + if err != nil { + return err + } + defer stmt.Close() + + existingRows, err := tx.QueryContext(ctx, `select api_key_hash, alias from api_key_aliases`) + if err != nil { + return err + } + existingAliases := map[string]string{} + for existingRows.Next() { + var apiKeyHash string + var alias string + if err := existingRows.Scan(&apiKeyHash, &alias); err != nil { + _ = existingRows.Close() + return err + } + existingAliases[normalizeAPIKeyAliasUniqueKey(alias)] = apiKeyHash + } + if err := existingRows.Close(); err != nil { + return err + } + if err := existingRows.Err(); err != nil { + return err + } + + for _, normalized := range normalizedAliases { + aliasKey := normalizeAPIKeyAliasUniqueKey(normalized.Alias) + if existingHash, ok := existingAliases[aliasKey]; ok && existingHash != normalized.APIKeyHash { + return errors.New("api key alias already exists") + } + if _, err := stmt.ExecContext( + ctx, + normalized.APIKeyHash, + normalized.Alias, + normalized.UpdatedAtMS, + ); err != nil { + return err + } + } + return tx.Commit() +} + +func (s *Store) DeleteAPIKeyAlias(ctx context.Context, apiKeyHash string) error { + hash := strings.ToLower(strings.TrimSpace(apiKeyHash)) + if !validAPIKeyHash(hash) { + return errors.New("valid apiKeyHash is required") + } + _, err := s.db.ExecContext(ctx, `delete from api_key_aliases where api_key_hash = ?`, hash) + return err +} + +func normalizeAPIKeyAlias(alias APIKeyAlias, now int64) (APIKeyAlias, error) { + hash := strings.ToLower(strings.TrimSpace(alias.APIKeyHash)) + if !validAPIKeyHash(hash) { + return APIKeyAlias{}, errors.New("valid apiKeyHash is required") + } + label := strings.TrimSpace(alias.Alias) + if label == "" { + return APIKeyAlias{}, errors.New("alias is required") + } + if len([]rune(label)) > 120 { + return APIKeyAlias{}, errors.New("alias must be 120 characters or less") + } + if alias.UpdatedAtMS <= 0 { + alias.UpdatedAtMS = now + } + alias.APIKeyHash = hash + alias.Alias = label + return alias, nil +} + +func normalizeAPIKeyAliasUniqueKey(alias string) string { + return strings.ToLower(strings.TrimSpace(alias)) +} + +func validAPIKeyHash(value string) bool { + if len(value) != 64 { + return false + } + for _, char := range value { + if (char >= '0' && char <= '9') || (char >= 'a' && char <= 'f') { + continue + } + return false + } + return true +} + func validateModelPrice(model string, price ModelPrice) error { if model == "" { return errors.New("model is required") diff --git a/usage-service/internal/store/store_test.go b/usage-service/internal/store/store_test.go index 233d082d7..a4dec060c 100644 --- a/usage-service/internal/store/store_test.go +++ b/usage-service/internal/store/store_test.go @@ -25,6 +25,7 @@ func TestStorePersistsAccountSnapshot(t *testing.T) { Model: "gpt-test", Endpoint: "POST /v1/chat/completions", AuthIndex: "auth-1", + APIKeyHash: "api-key-hash-1", AccountSnapshot: "alice@example.com", AuthLabelSnapshot: "Alice", AuthFileSnapshot: "alice.json", @@ -60,9 +61,15 @@ func TestStorePersistsAccountSnapshot(t *testing.T) { if event.AuthSnapshotAtMS != 1_778_000_000_100 { t.Fatalf("AuthSnapshotAtMS = %d", event.AuthSnapshotAtMS) } + if event.APIKeyHash != "api-key-hash-1" { + t.Fatalf("APIKeyHash = %q", event.APIKeyHash) + } payload := usage.BuildPayload(events) detail := payload.APIs["POST /v1/chat/completions"].Models["gpt-test"].Details[0] + if detail.APIKeyHash != "api-key-hash-1" { + t.Fatalf("payload APIKeyHash = %q", detail.APIKeyHash) + } if detail.AccountSnapshot != "alice@example.com" { t.Fatalf("payload AccountSnapshot = %q", detail.AccountSnapshot) } @@ -70,3 +77,68 @@ func TestStorePersistsAccountSnapshot(t *testing.T) { t.Fatalf("payload AuthProviderSnapshot = %q", detail.AuthProviderSnapshot) } } + +func TestStoreAPIKeyAliases(t *testing.T) { + db, err := Open(filepath.Join(t.TempDir(), "usage.sqlite")) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { + _ = db.Close() + }) + + const hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + if err := db.UpsertAPIKeyAliases(context.Background(), []APIKeyAlias{ + {APIKeyHash: hash, Alias: " Alice "}, + }); err != nil { + t.Fatalf("upsert alias: %v", err) + } + + aliases, err := db.LoadAPIKeyAliases(context.Background()) + if err != nil { + t.Fatalf("load aliases: %v", err) + } + if len(aliases) != 1 { + t.Fatalf("len(aliases) = %d, want 1", len(aliases)) + } + if aliases[0].APIKeyHash != hash || aliases[0].Alias != "Alice" || aliases[0].UpdatedAtMS <= 0 { + t.Fatalf("alias = %#v", aliases[0]) + } + + if err := db.UpsertAPIKeyAliases(context.Background(), []APIKeyAlias{ + {APIKeyHash: hash, Alias: "Team A"}, + }); err != nil { + t.Fatalf("update alias: %v", err) + } + aliases, err = db.LoadAPIKeyAliases(context.Background()) + if err != nil { + t.Fatalf("reload aliases: %v", err) + } + if len(aliases) != 1 || aliases[0].Alias != "Team A" { + t.Fatalf("updated aliases = %#v", aliases) + } + + const otherHash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789" + if err := db.UpsertAPIKeyAliases(context.Background(), []APIKeyAlias{ + {APIKeyHash: otherHash, Alias: " team a "}, + }); err == nil || err.Error() != "api key alias already exists" { + t.Fatalf("duplicate alias error = %v", err) + } + if err := db.UpsertAPIKeyAliases(context.Background(), []APIKeyAlias{ + {APIKeyHash: hash, Alias: "Alpha"}, + {APIKeyHash: otherHash, Alias: " alpha "}, + }); err == nil || err.Error() != "api key alias already exists" { + t.Fatalf("batch duplicate alias error = %v", err) + } + + if err := db.DeleteAPIKeyAlias(context.Background(), hash); err != nil { + t.Fatalf("delete alias: %v", err) + } + aliases, err = db.LoadAPIKeyAliases(context.Background()) + if err != nil { + t.Fatalf("load after delete: %v", err) + } + if len(aliases) != 0 { + t.Fatalf("aliases after delete = %#v", aliases) + } +} diff --git a/usage-service/internal/usage/event.go b/usage-service/internal/usage/event.go index c6b253caf..498afb98c 100644 --- a/usage-service/internal/usage/event.go +++ b/usage-service/internal/usage/event.go @@ -56,6 +56,7 @@ type Detail struct { Timestamp string `json:"timestamp"` Source string `json:"source"` AuthIndex string `json:"auth_index,omitempty"` + APIKeyHash string `json:"api_key_hash,omitempty"` AccountSnapshot string `json:"account_snapshot,omitempty"` AuthLabelSnapshot string `json:"auth_label_snapshot,omitempty"` AuthFileSnapshot string `json:"auth_file_snapshot,omitempty"` @@ -200,6 +201,7 @@ func BuildPayload(events []Event) Payload { Timestamp: event.Timestamp, Source: event.Source, AuthIndex: event.AuthIndex, + APIKeyHash: event.APIKeyHash, AccountSnapshot: event.AccountSnapshot, AuthLabelSnapshot: event.AuthLabelSnapshot, AuthFileSnapshot: event.AuthFileSnapshot, From 3ad2110ce71bb5ecdc66c6cb4d65187a7a58d940 Mon Sep 17 00:00:00 2001 From: seakee Date: Thu, 14 May 2026 08:46:42 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=A8=20feat(frontend):=20add=20API=20k?= =?UTF-8?q?ey=20alias=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add API key alias editing in the config editor, including SHA-256 hashing, duplicate validation, and Usage Service persistence. Expose API key filters and full-key search in request monitoring, with aliases preferred over masked key labels. Update localized copy, frontend tests, and generated key length to sk- plus 64 random characters. Closes #21 --- .../config/VisualConfigEditorBlocks.tsx | 474 ++++++++++++++++-- .../monitoring/accountOverviewState.test.ts | 3 + .../hooks/useMonitoringData.test.ts | 16 + .../monitoring/hooks/useMonitoringData.ts | 179 +++++-- src/features/monitoring/hooks/useUsageData.ts | 104 ++-- src/i18n/locales/en.json | 28 +- src/i18n/locales/ru.json | 28 +- src/i18n/locales/zh-CN.json | 28 +- src/i18n/locales/zh-TW.json | 28 +- src/pages/MonitoringCenterPage.tsx | 53 +- src/services/api/usageService.ts | 75 ++- src/utils/apiKeyHash.test.ts | 14 + src/utils/apiKeyHash.ts | 94 ++++ src/utils/usage.ts | 4 + 14 files changed, 973 insertions(+), 155 deletions(-) create mode 100644 src/utils/apiKeyHash.test.ts create mode 100644 src/utils/apiKeyHash.ts diff --git a/src/components/config/VisualConfigEditorBlocks.tsx b/src/components/config/VisualConfigEditorBlocks.tsx index 6f24a1522..01c2b4ad7 100644 --- a/src/components/config/VisualConfigEditorBlocks.tsx +++ b/src/components/config/VisualConfigEditorBlocks.tsx @@ -1,11 +1,27 @@ -import { memo, useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { + memo, + useCallback, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/Button'; import { Modal } from '@/components/ui/Modal'; import { Select } from '@/components/ui/Select'; -import { useNotificationStore } from '@/stores'; +import { + isUsageServiceId, + normalizeUsageServiceBase, + usageServiceApi, + type ApiKeyAlias, +} from '@/services/api/usageService'; +import { useAuthStore, useNotificationStore, useUsageServiceStore } from '@/stores'; import styles from './VisualConfigEditor.module.scss'; import { copyToClipboard } from '@/utils/clipboard'; +import { detectApiBaseFromLocation } from '@/utils/connection'; import type { PayloadFilterRule, PayloadModelEntry, @@ -21,6 +37,7 @@ import { VISUAL_CONFIG_PROTOCOL_OPTIONS, } from '@/hooks/useVisualConfig'; import { maskApiKey } from '@/utils/format'; +import { sha256Hex } from '@/utils/apiKeyHash'; import { isValidApiKeyCharset } from '@/utils/validation'; /** Minimum character count before the expand/collapse toggle appears. */ @@ -169,6 +186,11 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({ }) { const { t } = useTranslation(); const showNotification = useNotificationStore((state) => state.showNotification); + const showConfirmation = useNotificationStore((state) => state.showConfirmation); + const apiBase = useAuthStore((state) => state.apiBase); + const managementKey = useAuthStore((state) => state.managementKey); + const usageServiceEnabled = useUsageServiceStore((state) => state.enabled); + const usageServiceBase = useUsageServiceStore((state) => state.serviceBase); const apiKeys = useMemo( () => value @@ -190,29 +212,202 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({ const apiKeyInputId = useId(); const apiKeyHintId = `${apiKeyInputId}-hint`; const apiKeyErrorId = `${apiKeyInputId}-error`; + const keyAliasInputId = `${apiKeyInputId}-alias`; + const aliasModalInputId = useId(); + const aliasModalErrorId = `${aliasModalInputId}-error`; const [modalOpen, setModalOpen] = useState(false); const [editingApiKeyId, setEditingApiKeyId] = useState(null); const [inputValue, setInputValue] = useState(''); + const [inputAliasValue, setInputAliasValue] = useState(''); const [formError, setFormError] = useState(''); + const [apiKeyAliases, setApiKeyAliases] = useState([]); + const [aliasesLoading, setAliasesLoading] = useState(false); + const [aliasesAvailable, setAliasesAvailable] = useState(false); + const [aliasModalOpen, setAliasModalOpen] = useState(false); + const [aliasEditingApiKeyId, setAliasEditingApiKeyId] = useState(null); + const [aliasInputValue, setAliasInputValue] = useState(''); + const [aliasFormError, setAliasFormError] = useState(''); + const [aliasSaving, setAliasSaving] = useState(false); + + const aliasByHash = useMemo(() => { + const map = new Map(); + apiKeyAliases.forEach((item) => { + const hash = String(item.apiKeyHash || '') + .trim() + .toLowerCase(); + const alias = String(item.alias || '').trim(); + if (!hash || !alias) return; + map.set(hash, { ...item, apiKeyHash: hash, alias }); + }); + return map; + }, [apiKeyAliases]); + + const resolveAliasServiceBase = useCallback(async (): Promise => { + if (usageServiceEnabled && usageServiceBase) { + return usageServiceBase; + } + + const candidates = Array.from( + new Set( + [apiBase, detectApiBaseFromLocation()] + .map((candidate) => normalizeUsageServiceBase(candidate || '')) + .filter(Boolean) + ) + ); + + for (const candidate of candidates) { + try { + const info = await usageServiceApi.getInfo(candidate); + if (isUsageServiceId(info.service)) { + return candidate; + } + } catch { + // The regular CPA management API does not expose Usage Service metadata. + } + } + + return ''; + }, [apiBase, usageServiceBase, usageServiceEnabled]); + + useEffect(() => { + let cancelled = false; + + const loadAliases = async () => { + setAliasesLoading(true); + try { + const serviceBase = await resolveAliasServiceBase(); + if (cancelled) return; + if (!serviceBase) { + setAliasesAvailable(false); + setApiKeyAliases([]); + return; + } + const response = await usageServiceApi.getApiKeyAliases(serviceBase, managementKey); + if (cancelled) return; + setAliasesAvailable(true); + setApiKeyAliases(Array.isArray(response.items) ? response.items : []); + } catch { + if (cancelled) return; + setAliasesAvailable(false); + setApiKeyAliases([]); + } finally { + if (!cancelled) { + setAliasesLoading(false); + } + } + }; + + void loadAliases(); + + return () => { + cancelled = true; + }; + }, [managementKey, resolveAliasServiceBase]); function generateSecureApiKey(): string { const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - const array = new Uint8Array(17); + const array = new Uint8Array(64); crypto.getRandomValues(array); return 'sk-' + Array.from(array, (b) => charset[b % charset.length]).join(''); } + const getApiKeyHash = (apiKey: string) => sha256Hex(apiKey).toLowerCase(); + + const getAliasForApiKey = (apiKey: string) => { + const hash = getApiKeyHash(apiKey); + return hash ? (aliasByHash.get(hash)?.alias ?? '') : ''; + }; + + const normalizeAliasKey = (alias: string) => alias.trim().toLowerCase(); + + const isDuplicateAlias = (alias: string, currentApiKeyHash: string) => { + const aliasKey = normalizeAliasKey(alias); + const currentHash = currentApiKeyHash.trim().toLowerCase(); + if (!aliasKey) return false; + return apiKeyAliases.some((item) => { + const itemHash = String(item.apiKeyHash || '') + .trim() + .toLowerCase(); + return itemHash !== currentHash && normalizeAliasKey(String(item.alias || '')) === aliasKey; + }); + }; + + const validateAlias = (alias: string, currentApiKeyHash: string = '') => { + const trimmed = alias.trim(); + if (!trimmed) { + return t('config_management.visual.api_keys.alias_error_empty'); + } + if (Array.from(trimmed).length > 120) { + return t('config_management.visual.api_keys.alias_error_too_long'); + } + if (isDuplicateAlias(trimmed, currentApiKeyHash)) { + return t('config_management.visual.api_keys.alias_error_duplicate'); + } + return ''; + }; + + const saveAliasForKey = async (apiKey: string, alias: string) => { + const apiKeyHash = getApiKeyHash(apiKey); + const trimmedAlias = alias.trim(); + if (!apiKeyHash) { + throw new Error(t('config_management.visual.api_keys.error_empty')); + } + const validationError = validateAlias(trimmedAlias, apiKeyHash); + if (validationError) { + throw new Error(validationError); + } + + const serviceBase = await resolveAliasServiceBase(); + if (!serviceBase) { + throw new Error(t('config_management.visual.api_keys.alias_unavailable')); + } + + const response = await usageServiceApi.saveApiKeyAliases( + serviceBase, + [{ apiKeyHash, alias: trimmedAlias }], + managementKey + ); + setAliasesAvailable(true); + setApiKeyAliases(Array.isArray(response.items) ? response.items : []); + }; + + const deleteAliasForHash = async (apiKeyHash: string) => { + const serviceBase = await resolveAliasServiceBase(); + if (!serviceBase) { + throw new Error(t('config_management.visual.api_keys.alias_unavailable')); + } + + await usageServiceApi.deleteApiKeyAlias(serviceBase, apiKeyHash, managementKey); + setApiKeyAliases((previous) => + previous.filter((item) => item.apiKeyHash.toLowerCase() !== apiKeyHash.toLowerCase()) + ); + }; + + const getAliasErrorMessage = (error: unknown) => { + if ( + error && + typeof error === 'object' && + (error as { code?: unknown }).code === 'api_key_alias_duplicate' + ) { + return t('config_management.visual.api_keys.alias_error_duplicate'); + } + return error instanceof Error ? error.message : String(error); + }; + const openAddModal = () => { setEditingApiKeyId(null); setInputValue(''); + setInputAliasValue(''); setFormError(''); setModalOpen(true); }; const openEditModal = (apiKeyId: string) => { const editingIndex = renderApiKeyIds.findIndex((id) => id === apiKeyId); + const editingKey = apiKeys[editingIndex] ?? ''; setEditingApiKeyId(apiKeyId); - setInputValue(apiKeys[editingIndex] ?? ''); + setInputValue(editingKey); + setInputAliasValue(getAliasForApiKey(editingKey)); setFormError(''); setModalOpen(true); }; @@ -220,10 +415,27 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({ const closeModal = () => { setModalOpen(false); setInputValue(''); + setInputAliasValue(''); setEditingApiKeyId(null); setFormError(''); }; + const openAliasModal = (apiKeyId: string) => { + const editingIndex = renderApiKeyIds.findIndex((id) => id === apiKeyId); + const editingKey = apiKeys[editingIndex] ?? ''; + setAliasEditingApiKeyId(apiKeyId); + setAliasInputValue(getAliasForApiKey(editingKey)); + setAliasFormError(''); + setAliasModalOpen(true); + }; + + const closeAliasModal = () => { + setAliasModalOpen(false); + setAliasEditingApiKeyId(null); + setAliasInputValue(''); + setAliasFormError(''); + }; + const updateApiKeys = (nextKeys: string[]) => { onChange(nextKeys.join('\n')); }; @@ -235,8 +447,9 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({ updateApiKeys(apiKeys.filter((_, i) => i !== index)); }; - const handleSave = () => { + const handleSave = async () => { const trimmed = inputValue.trim(); + const trimmedAlias = inputAliasValue.trim(); if (!trimmed) { setFormError(t('config_management.visual.api_keys.error_empty')); return; @@ -245,6 +458,17 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({ setFormError(t('config_management.visual.api_keys.error_invalid')); return; } + if (trimmedAlias) { + const aliasError = validateAlias(trimmedAlias, getApiKeyHash(trimmed)); + if (aliasError) { + setFormError(aliasError); + return; + } + if (!aliasesAvailable) { + setFormError(t('config_management.visual.api_keys.alias_unavailable')); + return; + } + } const editingIndex = editingApiKeyId ? renderApiKeyIds.findIndex((id) => id === editingApiKeyId) @@ -253,6 +477,20 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({ editingApiKeyId === null ? [...apiKeys, trimmed] : apiKeys.map((key, idx) => (idx === editingIndex ? trimmed : key)); + + if (trimmedAlias) { + try { + setAliasSaving(true); + await saveAliasForKey(trimmed, trimmedAlias); + showNotification(t('config_management.visual.api_keys.alias_saved'), 'success'); + } catch (error) { + setFormError(getAliasErrorMessage(error)); + setAliasSaving(false); + return; + } + setAliasSaving(false); + } + if (editingApiKeyId === null) { setApiKeyIds([...renderApiKeyIds, makeClientId()]); } @@ -260,6 +498,57 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({ closeModal(); }; + const handleAliasSave = async () => { + const editingIndex = aliasEditingApiKeyId + ? renderApiKeyIds.findIndex((id) => id === aliasEditingApiKeyId) + : -1; + const editingKey = apiKeys[editingIndex] ?? ''; + const aliasError = validateAlias(aliasInputValue, getApiKeyHash(editingKey)); + if (aliasError) { + setAliasFormError(aliasError); + return; + } + + setAliasSaving(true); + try { + await saveAliasForKey(editingKey, aliasInputValue); + showNotification(t('config_management.visual.api_keys.alias_saved'), 'success'); + closeAliasModal(); + } catch (error) { + setAliasFormError(getAliasErrorMessage(error)); + } finally { + setAliasSaving(false); + } + }; + + const handleAliasDelete = () => { + const editingIndex = aliasEditingApiKeyId + ? renderApiKeyIds.findIndex((id) => id === aliasEditingApiKeyId) + : -1; + const editingKey = apiKeys[editingIndex] ?? ''; + const apiKeyHash = getApiKeyHash(editingKey); + if (!apiKeyHash || !aliasByHash.has(apiKeyHash)) return; + + showConfirmation({ + title: t('config_management.visual.api_keys.alias_delete_title'), + message: t('config_management.visual.api_keys.alias_delete_confirm'), + confirmText: t('config_management.visual.api_keys.alias_delete'), + variant: 'danger', + onConfirm: async () => { + setAliasSaving(true); + try { + await deleteAliasForHash(apiKeyHash); + showNotification(t('config_management.visual.api_keys.alias_deleted'), 'success'); + closeAliasModal(); + } catch (error) { + setAliasFormError(getAliasErrorMessage(error)); + } finally { + setAliasSaving(false); + } + }, + }); + }; + const handleCopy = async (apiKey: string) => { const copied = await copyToClipboard(apiKey); showNotification( @@ -286,47 +575,61 @@ export const ApiKeysCardEditor = memo(function ApiKeysCardEditor({
{t('config_management.visual.api_keys.empty')}
) : (
- {apiKeys.map((key, index) => ( -
-
-
#{index + 1}
-
- {t('config_management.visual.api_keys.input_label')} + {apiKeys.map((key, index) => { + const apiKeyHash = getApiKeyHash(key); + const alias = apiKeyHash ? (aliasByHash.get(apiKeyHash)?.alias ?? '') : ''; + return ( +
+
+
+ {alias || t('config_management.visual.api_keys.input_label')} +
+
{maskApiKey(String(key || ''))}
+
+
+ + + +
-
{maskApiKey(String(key || ''))}
-
-
- - -
-
- ))} + ); + })}
)}
{t('config_management.visual.api_keys.hint')}
+ {!aliasesAvailable && !aliasesLoading ? ( +
{t('config_management.visual.api_keys.alias_unavailable')}
+ ) : null} - - + ) : null} + + + + } + > +
+ + { + setAliasInputValue(e.target.value); + setAliasFormError(''); + }} + disabled={disabled || aliasSaving} + maxLength={120} + aria-describedby={aliasFormError ? aliasModalErrorId : undefined} + aria-invalid={Boolean(aliasFormError)} + /> +
{t('config_management.visual.api_keys.alias_hint')}
+ {aliasFormError && ( +
+ {aliasFormError} +
+ )} +
+
); }); @@ -668,7 +1046,9 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({ placeholder={t('config_management.visual.payload_rules.model_name')} ariaLabel={t('config_management.visual.payload_rules.model_name')} value={model.name} - onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })} + onChange={(nextValue) => + updateModel(ruleIndex, modelIndex, { name: nextValue }) + } disabled={disabled} /> @@ -678,7 +1058,9 @@ export const PayloadRulesEditor = memo(function PayloadRulesEditor({ placeholder={t('config_management.visual.payload_rules.model_name')} ariaLabel={t('config_management.visual.payload_rules.model_name')} value={model.name} - onChange={(nextValue) => updateModel(ruleIndex, modelIndex, { name: nextValue })} + onChange={(nextValue) => + updateModel(ruleIndex, modelIndex, { name: nextValue }) + } disabled={disabled} />