diff --git a/admin/api_key_account_usage_test.go b/admin/api_key_account_usage_test.go index 1440123a..e3e03d8d 100644 --- a/admin/api_key_account_usage_test.go +++ b/admin/api_key_account_usage_test.go @@ -17,10 +17,13 @@ func TestAggregateAPIKeyAccountGroups(t *testing.T) { AccountID: 2, Requests: 3, TotalTokens: 200, AccountBilled: 0.4, UserBilled: 0.6, Groups: []database.APIKeyAccountGroup{{ID: 10, Name: "primary"}, {ID: 20, Name: "shared"}}, }, + { + AccountID: 3, Requests: 1, TotalTokens: 50, AccountBilled: 0.1, UserBilled: 0.15, + }, } - groups, summary := aggregateAPIKeyAccountGroups(items) - if summary.Accounts != 2 || summary.Requests != 5 || summary.TotalTokens != 300 || math.Abs(summary.AccountBilled-0.6) > 1e-9 { + groups, summary, reconciliation := aggregateAPIKeyAccountGroups(items) + if summary.Accounts != 3 || summary.Requests != 6 || summary.TotalTokens != 350 || math.Abs(summary.AccountBilled-0.7) > 1e-9 || math.Abs(summary.UserBilled-1.05) > 1e-9 { t.Fatalf("summary = %+v", summary) } if len(groups) != 2 { @@ -32,4 +35,20 @@ func TestAggregateAPIKeyAccountGroups(t *testing.T) { if groups[1].ID != 20 || groups[1].Accounts != 1 || groups[1].TotalTokens != 200 { t.Fatalf("shared group = %+v", groups[1]) } + if reconciliation.UniqueGroupedAccounts != 2 || reconciliation.MultiGroupAccounts != 1 { + t.Fatalf("reconciliation account counts = %+v", reconciliation) + } + if reconciliation.GroupedTotal.Accounts != 3 || reconciliation.GroupedTotal.Requests != 8 || reconciliation.GroupedTotal.TotalTokens != 500 || math.Abs(reconciliation.GroupedTotal.UserBilled-1.5) > 1e-9 { + t.Fatalf("grouped total = %+v", reconciliation.GroupedTotal) + } + if reconciliation.Ungrouped.Accounts != 1 || reconciliation.Ungrouped.TotalTokens != 50 || math.Abs(reconciliation.Ungrouped.UserBilled-0.15) > 1e-9 { + t.Fatalf("ungrouped = %+v", reconciliation.Ungrouped) + } + if reconciliation.Duplicate.Accounts != 1 || reconciliation.Duplicate.Requests != 3 || reconciliation.Duplicate.TotalTokens != 200 || math.Abs(reconciliation.Duplicate.UserBilled-0.6) > 1e-9 { + t.Fatalf("duplicate = %+v", reconciliation.Duplicate) + } + reconciled := reconciliation.GroupedTotal.UserBilled + reconciliation.Ungrouped.UserBilled - reconciliation.Duplicate.UserBilled + if math.Abs(reconciled-summary.UserBilled) > 1e-9 { + t.Fatalf("reconciled billed = %f, summary = %f", reconciled, summary.UserBilled) + } } diff --git a/admin/handler.go b/admin/handler.go index 49b2cf05..1e5c27b2 100644 --- a/admin/handler.go +++ b/admin/handler.go @@ -713,6 +713,8 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) { api.DELETE("/prompt-policy/risk-profiles/:subject_type/:subject_key/trust", h.RevokePromptRiskTrustPolicy) api.POST("/prompt-policy/conversation-locks/:lock_key/unlock", h.UnlockPromptConversation) api.POST("/prompt-filter/test", h.TestPromptFilter) + api.GET("/prompt-filter/review/keys", h.ListPromptReviewAPIKeys) + api.DELETE("/prompt-filter/review/keys/:key_id", h.DeletePromptReviewAPIKey) api.POST("/prompt-filter/review/test", h.TestPromptReviewConnection) api.POST("/prompt-filter/rules/test", h.TestPromptFilterRulePattern) api.GET("/prompt-filter/rules", h.GetPromptFilterRules) @@ -6047,10 +6049,11 @@ func (h *Handler) GetAPIKeyAccountStats(c *gin.Context) { cacheKey := fmt.Sprintf("%d:%d:%d", id, rangeStart.Unix()/30, rangeEnd.Unix()/30) type cachedResponse struct { - Items []database.APIKeyAccountStat `json:"items"` - Groups []apiKeyAccountGroupUsage `json:"groups"` - Summary apiKeyAccountUsageSummary `json:"summary"` - MembershipBasis string `json:"membership_basis"` + Items []database.APIKeyAccountStat `json:"items"` + Groups []apiKeyAccountGroupUsage `json:"groups"` + Summary apiKeyAccountUsageSummary `json:"summary"` + Reconciliation apiKeyAccountUsageReconciliation `json:"reconciliation"` + MembershipBasis string `json:"membership_basis"` } var response cachedResponse if h.getRuntimeJSON(ctx, adminAPIKeyAccountsNamespace, cacheKey, &response) { @@ -6067,7 +6070,7 @@ func (h *Handler) GetAPIKeyAccountStats(c *gin.Context) { items = []database.APIKeyAccountStat{} } response.Items = items - response.Groups, response.Summary = aggregateAPIKeyAccountGroups(items) + response.Groups, response.Summary, response.Reconciliation = aggregateAPIKeyAccountGroups(items) response.MembershipBasis = "current_and_deleted_last_membership" h.setRuntimeJSON(ctx, adminAPIKeyAccountsNamespace, cacheKey, response, adminUsageRangeCacheTTL) c.JSON(http.StatusOK, response) @@ -6092,18 +6095,46 @@ type apiKeyAccountGroupUsage struct { UserBilled float64 `json:"user_billed"` } +type apiKeyAccountUsageReconciliation struct { + GroupedTotal apiKeyAccountUsageSummary `json:"grouped_total"` + Ungrouped apiKeyAccountUsageSummary `json:"ungrouped"` + Duplicate apiKeyAccountUsageSummary `json:"duplicate"` + UniqueGroupedAccounts int `json:"unique_grouped_accounts"` + MultiGroupAccounts int `json:"multi_group_accounts"` +} + // aggregateAPIKeyAccountGroups uses current memberships for active accounts and // the retained last membership for recycle-bin accounts. If an account belongs // to multiple groups, its usage is intentionally included in each group; the // overall summary remains de-duplicated. -func aggregateAPIKeyAccountGroups(items []database.APIKeyAccountStat) ([]apiKeyAccountGroupUsage, apiKeyAccountUsageSummary) { +func aggregateAPIKeyAccountGroups(items []database.APIKeyAccountStat) ([]apiKeyAccountGroupUsage, apiKeyAccountUsageSummary, apiKeyAccountUsageReconciliation) { groupMap := make(map[int64]*apiKeyAccountGroupUsage) summary := apiKeyAccountUsageSummary{Accounts: len(items)} + reconciliation := apiKeyAccountUsageReconciliation{} for _, item := range items { summary.Requests += item.Requests summary.TotalTokens += item.TotalTokens summary.AccountBilled += item.AccountBilled summary.UserBilled += item.UserBilled + groupCount := len(item.Groups) + if groupCount == 0 { + reconciliation.Ungrouped.Accounts++ + reconciliation.Ungrouped.Requests += item.Requests + reconciliation.Ungrouped.TotalTokens += item.TotalTokens + reconciliation.Ungrouped.AccountBilled += item.AccountBilled + reconciliation.Ungrouped.UserBilled += item.UserBilled + } else { + reconciliation.UniqueGroupedAccounts++ + } + if groupCount > 1 { + reconciliation.MultiGroupAccounts++ + extraAssignments := int64(groupCount - 1) + reconciliation.Duplicate.Accounts += groupCount - 1 + reconciliation.Duplicate.Requests += item.Requests * extraAssignments + reconciliation.Duplicate.TotalTokens += item.TotalTokens * extraAssignments + reconciliation.Duplicate.AccountBilled += item.AccountBilled * float64(extraAssignments) + reconciliation.Duplicate.UserBilled += item.UserBilled * float64(extraAssignments) + } for _, group := range item.Groups { total := groupMap[group.ID] if total == nil { @@ -6120,6 +6151,11 @@ func aggregateAPIKeyAccountGroups(items []database.APIKeyAccountStat) ([]apiKeyA groups := make([]apiKeyAccountGroupUsage, 0, len(groupMap)) for _, group := range groupMap { groups = append(groups, *group) + reconciliation.GroupedTotal.Accounts += group.Accounts + reconciliation.GroupedTotal.Requests += group.Requests + reconciliation.GroupedTotal.TotalTokens += group.TotalTokens + reconciliation.GroupedTotal.AccountBilled += group.AccountBilled + reconciliation.GroupedTotal.UserBilled += group.UserBilled } sort.Slice(groups, func(i, j int) bool { if groups[i].UserBilled == groups[j].UserBilled { @@ -6127,7 +6163,7 @@ func aggregateAPIKeyAccountGroups(items []database.APIKeyAccountStat) ([]apiKeyA } return groups[i].UserBilled > groups[j].UserBilled }) - return groups, summary + return groups, summary, reconciliation } // GetChartData 返回图表聚合数据(服务端分桶 + 内存缓存) diff --git a/admin/prompt_filter.go b/admin/prompt_filter.go index 99fea7d9..0f2a00c9 100644 --- a/admin/prompt_filter.go +++ b/admin/prompt_filter.go @@ -2,7 +2,9 @@ package admin import ( "context" + "crypto/sha256" "database/sql" + "encoding/hex" "encoding/json" "errors" "net/http" @@ -74,6 +76,8 @@ type promptReviewTestRequest struct { type promptReviewKeyTestResult struct { KeyIndex int `json:"key_index"` + KeyID string `json:"key_id,omitempty"` + KeyMasked string `json:"key_masked,omitempty"` OK bool `json:"ok"` Endpoint string `json:"endpoint,omitempty"` Model string `json:"model,omitempty"` @@ -90,6 +94,44 @@ type promptReviewKeyTestResult struct { Error string `json:"error,omitempty"` } +type promptReviewAPIKeyDescriptor struct { + ID string `json:"id"` + Index int `json:"index"` + Masked string `json:"masked"` +} + +type promptReviewAPIKeysResponse struct { + Items []promptReviewAPIKeyDescriptor `json:"items"` + Count int `json:"count"` +} + +func promptReviewAPIKeyID(key string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(key))) + return hex.EncodeToString(sum[:]) +} + +func maskPromptReviewAPIKey(key string) string { + key = strings.TrimSpace(key) + if len(key) <= 4 { + return "••••" + } + prefix := "" + if len(key) >= 3 { + prefix = key[:3] + } + return prefix + "••••" + key[len(key)-4:] +} + +func promptReviewAPIKeyDescriptors(keys []string) []promptReviewAPIKeyDescriptor { + items := make([]promptReviewAPIKeyDescriptor, 0, len(keys)) + for index, key := range keys { + items = append(items, promptReviewAPIKeyDescriptor{ + ID: promptReviewAPIKeyID(key), Index: index + 1, Masked: maskPromptReviewAPIKey(key), + }) + } + return items +} + type promptReviewTestResponse struct { OK bool `json:"ok"` Endpoint string `json:"endpoint"` @@ -535,6 +577,7 @@ func (h *Handler) TestPromptReviewConnection(c *gin.Context) { }(index, key) } results := make([]promptReviewKeyTestResult, len(keys)) + descriptors := promptReviewAPIKeyDescriptors(keys) allOK := true var first promptfilter.ReviewOutcome for range keys { @@ -543,7 +586,8 @@ func (h *Handler) TestPromptReviewConnection(c *gin.Context) { first = item.outcome } result := promptReviewKeyTestResult{ - KeyIndex: item.index + 1, OK: item.err == nil, Flagged: item.outcome.Flagged, + KeyIndex: item.index + 1, KeyID: descriptors[item.index].ID, KeyMasked: descriptors[item.index].Masked, + OK: item.err == nil, Flagged: item.outcome.Flagged, Endpoint: item.outcome.Endpoint, Model: item.outcome.Model, Confidence: item.outcome.Confidence, Reason: item.outcome.Reason, HighestCategory: item.outcome.HighestCategory, DecisionCategory: item.outcome.DecisionCategory, DecisionScore: item.outcome.DecisionScore, @@ -593,6 +637,85 @@ func (h *Handler) TestPromptReviewConnection(c *gin.Context) { }) } +func (h *Handler) ListPromptReviewAPIKeys(c *gin.Context) { + if h == nil || h.store == nil { + writeError(c, http.StatusServiceUnavailable, "Prompt 审核配置不可用") + return + } + items := promptReviewAPIKeyDescriptors(h.store.GetPromptFilterConfig().Review.APIKeyList()) + c.JSON(http.StatusOK, promptReviewAPIKeysResponse{Items: items, Count: len(items)}) +} + +func (h *Handler) DeletePromptReviewAPIKey(c *gin.Context) { + if h == nil || h.store == nil || h.db == nil { + writeError(c, http.StatusServiceUnavailable, "Prompt 审核配置不可用") + return + } + keyID := strings.ToLower(strings.TrimSpace(c.Param("key_id"))) + if len(keyID) != sha256.Size*2 { + writeError(c, http.StatusBadRequest, "审查 Key 标识无效") + return + } + if _, err := hex.DecodeString(keyID); err != nil { + writeError(c, http.StatusBadRequest, "审查 Key 标识无效") + return + } + + h.settingsUpdateMu.Lock() + defer h.settingsUpdateMu.Unlock() + settings, err := h.db.GetSystemSettings(c.Request.Context()) + if err != nil { + writeInternalError(c, err) + return + } + if settings == nil { + writeError(c, http.StatusNotFound, "审查 Key 不存在") + return + } + currentRaw := strings.TrimSpace(settings.PromptFilterReviewAPIKey) + keys := (promptfilter.ReviewConfig{APIKey: currentRaw}).APIKeyList() + remaining := make([]string, 0, len(keys)) + found := false + for _, key := range keys { + if promptReviewAPIKeyID(key) == keyID { + found = true + continue + } + remaining = append(remaining, key) + } + if !found { + writeError(c, http.StatusNotFound, "审查 Key 不存在或已被删除") + return + } + if settings.PromptFilterReviewEnabled && len(remaining) == 0 { + writeError(c, http.StatusConflict, "模型复核启用时不能删除最后一个审查 Key,请先关闭模型复核或添加替代 Key") + return + } + replacement := strings.Join(remaining, "\n") + runtimeCfg := h.store.GetPromptFilterConfig() + runtimeCfg.Review.APIKey = replacement + runtimeCfg = promptfilter.NormalizeConfig(runtimeCfg) + if err := promptfilter.ValidateReviewConfig(runtimeCfg.Review); err != nil { + writeError(c, http.StatusConflict, "删除后审查配置无效: "+err.Error()) + return + } + swapped, err := h.db.CompareAndSwapPromptFilterReviewAPIKeys(c.Request.Context(), currentRaw, replacement) + if err != nil { + writeInternalError(c, err) + return + } + if !swapped { + writeError(c, http.StatusConflict, "审查 Key 列表已被其他操作修改,请刷新后重试") + return + } + if err := h.store.SetPromptFilterConfigWithAdvancedRaw(runtimeCfg, h.store.GetPromptFilterAdvancedConfig()); err != nil { + writeError(c, http.StatusInternalServerError, "审查 Key 已保存,但运行时配置更新失败") + return + } + items := promptReviewAPIKeyDescriptors(remaining) + c.JSON(http.StatusOK, promptReviewAPIKeysResponse{Items: items, Count: len(items)}) +} + func (h *Handler) TestPromptFilterRulePattern(c *gin.Context) { var req promptFilterRulePatternTestRequest if err := c.ShouldBindJSON(&req); err != nil { diff --git a/admin/prompt_filter_test.go b/admin/prompt_filter_test.go index f60aa1de..53bd572f 100644 --- a/admin/prompt_filter_test.go +++ b/admin/prompt_filter_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "path/filepath" "strings" "sync" "sync/atomic" @@ -131,6 +132,71 @@ func TestPromptReviewConnectionTestsAllKeysConcurrentlyWithoutReturningSecrets(t t.Fatalf("response leaked key: %s", recorder.Body.String()) } } + for index, result := range response.Results { + if result.KeyID == "" || result.KeyMasked == "" || result.KeyIndex != index+1 { + t.Fatalf("result %d has no stable redacted identity: %+v", index, result) + } + } +} + +func TestDeletePromptReviewAPIKeyRemovesOnlySelectedKey(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := database.New("sqlite", filepath.Join(t.TempDir(), "review-keys.db")) + if err != nil { + t.Fatalf("New(sqlite): %v", err) + } + defer db.Close() + settings := &database.SystemSettings{ + PromptFilterReviewEnabled: true, PromptFilterReviewAPIKey: "key-one\nkey-two", + PromptFilterReviewBaseURL: "https://review.example.com", PromptFilterReviewModel: "review-model", + } + if err := db.UpdateSystemSettings(context.Background(), settings); err != nil { + t.Fatalf("UpdateSystemSettings: %v", err) + } + store := auth.NewStore(nil, nil, settings) + t.Cleanup(store.Stop) + handler := &Handler{db: db, store: store} + + deleteKey := func(key string) *httptest.ResponseRecorder { + t.Helper() + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Params = gin.Params{{Key: "key_id", Value: promptReviewAPIKeyID(key)}} + c.Request = httptest.NewRequest(http.MethodDelete, "/api/admin/prompt-filter/review/keys/selected", nil) + handler.DeletePromptReviewAPIKey(c) + return recorder + } + + if recorder := deleteKey("key-one"); recorder.Code != http.StatusOK { + t.Fatalf("delete selected status=%d body=%s", recorder.Code, recorder.Body.String()) + } + persisted, err := db.GetSystemSettings(context.Background()) + if err != nil || persisted.PromptFilterReviewAPIKey != "key-two" { + t.Fatalf("persisted keys=%q err=%v, want key-two", persisted.PromptFilterReviewAPIKey, err) + } + if got := store.GetPromptFilterConfig().Review.APIKeyList(); len(got) != 1 || got[0] != "key-two" { + t.Fatalf("runtime keys=%v, want [key-two]", got) + } + if recorder := deleteKey("key-two"); recorder.Code != http.StatusConflict { + t.Fatalf("delete last enabled key status=%d body=%s", recorder.Code, recorder.Body.String()) + } +} + +func TestPromptReviewAPIKeyDescriptorsNeverExposeSecrets(t *testing.T) { + keys := []string{"sk-secret-alpha-1234", "opaque-secret-beta-9876"} + items := promptReviewAPIKeyDescriptors(keys) + if len(items) != len(keys) || items[0].ID == items[1].ID { + t.Fatalf("descriptors=%+v", items) + } + encoded, err := json.Marshal(items) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + for _, key := range keys { + if strings.Contains(string(encoded), key) { + t.Fatalf("descriptor response leaked key %q: %s", key, encoded) + } + } } func TestPromptFilterTestEndpointUsesRealGuardPipelineMetadata(t *testing.T) { diff --git a/database/prompt_filter_review_keys.go b/database/prompt_filter_review_keys.go new file mode 100644 index 00000000..0ce7bfaa --- /dev/null +++ b/database/prompt_filter_review_keys.go @@ -0,0 +1,31 @@ +package database + +import ( + "context" + "strings" +) + +// CompareAndSwapPromptFilterReviewAPIKeys replaces only the review key field +// when it still matches the value reviewed by the caller. This prevents an +// individual deletion from overwriting a concurrent settings save. +func (db *DB) CompareAndSwapPromptFilterReviewAPIKeys(ctx context.Context, expected, replacement string) (bool, error) { + expected = strings.TrimSpace(expected) + replacement = strings.TrimSpace(replacement) + var swapped bool + err := db.withSQLiteWriteLock(ctx, func() error { + result, err := db.conn.ExecContext(ctx, ` + UPDATE system_settings SET prompt_filter_review_api_key=$1 + WHERE id=1 AND TRIM(COALESCE(prompt_filter_review_api_key, ''))=$2 + `, replacement, expected) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + swapped = affected == 1 + return nil + }) + return swapped, err +} diff --git a/database/prompt_filter_review_keys_test.go b/database/prompt_filter_review_keys_test.go new file mode 100644 index 00000000..06bcb675 --- /dev/null +++ b/database/prompt_filter_review_keys_test.go @@ -0,0 +1,31 @@ +package database + +import ( + "context" + "path/filepath" + "testing" +) + +func TestCompareAndSwapPromptFilterReviewAPIKeysRejectsStaleSnapshot(t *testing.T) { + db, err := New("sqlite", filepath.Join(t.TempDir(), "review-keys.db")) + if err != nil { + t.Fatalf("New(sqlite): %v", err) + } + defer db.Close() + ctx := context.Background() + if err := db.UpdateSystemSettings(ctx, &SystemSettings{PromptFilterReviewAPIKey: "key-one\nkey-two"}); err != nil { + t.Fatalf("UpdateSystemSettings: %v", err) + } + swapped, err := db.CompareAndSwapPromptFilterReviewAPIKeys(ctx, "key-one\nkey-two", "key-two") + if err != nil || !swapped { + t.Fatalf("first swap = %t, %v; want true, nil", swapped, err) + } + swapped, err = db.CompareAndSwapPromptFilterReviewAPIKeys(ctx, "key-one\nkey-two", "key-one") + if err != nil || swapped { + t.Fatalf("stale swap = %t, %v; want false, nil", swapped, err) + } + settings, err := db.GetSystemSettings(ctx) + if err != nil || settings.PromptFilterReviewAPIKey != "key-two" { + t.Fatalf("stored keys = %q, %v; want key-two", settings.PromptFilterReviewAPIKey, err) + } +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index dca3d5b6..2228ff2e 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -70,6 +70,7 @@ import type { PromptFilterTestResponse, PromptReviewTestRequest, PromptReviewTestResponse, + PromptReviewAPIKeysResponse, PublicAPIKeyUsageResponse, RecycleBinAccountsResponse, ResetCreditsDetailResponse, @@ -1077,6 +1078,10 @@ export const api = { request('/prompt-filter/test', { method: 'POST', body: JSON.stringify(data) }), testPromptReview: (data: PromptReviewTestRequest) => request('/prompt-filter/review/test', { method: 'POST', body: JSON.stringify(data) }), + getPromptReviewAPIKeys: () => + request('/prompt-filter/review/keys'), + deletePromptReviewAPIKey: (keyID: string) => + request(`/prompt-filter/review/keys/${encodeURIComponent(keyID)}`, { method: 'DELETE' }), testPromptFilterRulePattern: (data: { pattern: string; text: string }) => request('/prompt-filter/rules/test', { method: 'POST', body: JSON.stringify(data) }), getPromptFilterRules: () => diff --git a/frontend/src/components/APIKeyTokenUsagePanel.tsx b/frontend/src/components/APIKeyTokenUsagePanel.tsx index d8222af9..eb98a21d 100644 --- a/frontend/src/components/APIKeyTokenUsagePanel.tsx +++ b/frontend/src/components/APIKeyTokenUsagePanel.tsx @@ -5,6 +5,7 @@ import { useToast } from "../hooks/useToast"; import type { APIKeyAccountGroup, APIKeyAccountGroupUsage, + APIKeyAccountUsageReconciliation, APIKeyAccountStat, APIKeyTokenStat, } from "../types"; @@ -151,6 +152,9 @@ export default function APIKeyTokenUsagePanel({ const [expandedIds, setExpandedIds] = useState>(() => new Set()); const [accountData, setAccountData] = useState>({}); const [groupData, setGroupData] = useState>({}); + const [reconciliationData, setReconciliationData] = useState< + Record + >({}); const [accountLoadingIds, setAccountLoadingIds] = useState>(() => new Set()); const [accountError, setAccountError] = useState>({}); const reloadAbort = useRef(null); @@ -199,6 +203,7 @@ export default function APIKeyTokenUsagePanel({ setAccountLoadingIds(new Set()); setAccountData({}); setGroupData({}); + setReconciliationData({}); setAccountError({}); try { const [data] = await Promise.all([ @@ -251,6 +256,10 @@ export default function APIKeyTokenUsagePanel({ }); setAccountData((prev) => ({ ...prev, [id]: data.items ?? [] })); setGroupData((prev) => ({ ...prev, [id]: data.groups ?? [] })); + const reconciliation = data.reconciliation; + if (reconciliation) { + setReconciliationData((prev) => ({ ...prev, [id]: reconciliation })); + } } catch (err) { if (!controller.signal.aborted) { setAccountError((prev) => ({ ...prev, [id]: getErrorMessage(err) })); @@ -639,6 +648,7 @@ export default function APIKeyTokenUsagePanel({ error={accountError[item.api_key_id]} rows={accountData[item.api_key_id]} groups={groupData[item.api_key_id]} + reconciliation={reconciliationData[item.api_key_id]} showFull={showFullUsageNumbers} locale={locale} /> @@ -729,6 +739,7 @@ function KeyAccountBreakdown({ error, rows, groups, + reconciliation, showFull, locale, }: { @@ -736,6 +747,7 @@ function KeyAccountBreakdown({ error?: string; rows?: APIKeyAccountStat[]; groups?: APIKeyAccountGroupUsage[]; + reconciliation?: APIKeyAccountUsageReconciliation; showFull: boolean; locale: string; }) { @@ -889,13 +901,13 @@ function KeyAccountBreakdown({
- {groups && groups.length > 0 ? ( + {(groups && groups.length > 0) || reconciliation?.ungrouped.accounts ? (
{t("apiKeys.keyGroupsTitle")}
- {groups.map((group) => ( + {(groups ?? []).map((group) => (
))} + {reconciliation && reconciliation.ungrouped.accounts > 0 ? ( +
+
+ + {t("apiKeys.keyGroupsUngrouped")} + + + {t("apiKeys.keyGroupsAccounts", { count: reconciliation.ungrouped.accounts })} + +
+
+ + {formatUsageNumber(reconciliation.ungrouped.total_tokens, showFull, locale)}{" "} + {t("apiKeys.keyAccountsTokUnit")} + + + {t("apiKeys.keyGroupsAccountCost")}: {" "} + {formatUSD(reconciliation.ungrouped.account_billed)} + + + {t("apiKeys.keyGroupsBilled")}: {" "} + + {formatUSD(reconciliation.ungrouped.user_billed)} + + +
+
+ ) : null}
+ {reconciliation ? ( +
+
+ {t("apiKeys.keyGroupsReconcileCost")} + {formatUSD(reconciliation.grouped_total.user_billed)} + + + {formatUSD(reconciliation.ungrouped.user_billed)} + - + {formatUSD(reconciliation.duplicate.user_billed)} + = + {formatUSD(totalBilled)} +
+
+ {t("apiKeys.keyGroupsReconcileAccounts")} + {reconciliation.grouped_total.accounts} + + + {reconciliation.ungrouped.accounts} + - + {reconciliation.duplicate.accounts} + = + {rows.length} +
+

+ {t("apiKeys.keyGroupsReconcileHint", { + ungrouped: reconciliation.ungrouped.accounts, + multi: reconciliation.multi_group_accounts, + })} +

+
+ ) : null}

{t("apiKeys.keyGroupsCurrentHint")}

@@ -963,6 +1033,11 @@ function KeyAccountBreakdown({ ) : null} + {!a.groups || a.groups.length === 0 ? ( + + {t(a.account_deleted ? "apiKeys.keyAccountHistoricalGroupMissing" : "apiKeys.keyAccountUngrouped")} + + ) : null}
{formatPercent(share)} diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index e6217959..2b2a96df 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -2401,6 +2401,12 @@ "reviewApiKeyPlaceholder": "One key per line (sk-...), multiple allowed", "reviewApiKeyConfigured": "{{n}} key(s) configured; leave blank to keep", "reviewApiKeyHint": "One key per line. Multiple keys are round-robin balanced and failed over on rate limits, invalid keys, or server errors.", + "reviewKeyListTitle": "Saved review keys", + "reviewKeyListHint": "Only masked suffixes are shown. Connection test results map to each key so a failing key can be removed individually.", + "reviewKeyDeleteTitle": "Delete this review key?", + "reviewKeyDeleteConfirm": "This removes {{key}} without changing the other review keys. The last key cannot be deleted while model review is enabled.", + "reviewKeyDeleteAria": "Delete review key {{key}}", + "reviewKeyDeleted": "Deleted review key {{key}}", "reviewRequestMode": "Request Adapter", "reviewModeChat": "Chat Completions compatible", "reviewModeModerations": "Moderations compatible", @@ -2415,6 +2421,21 @@ "moderationThresholdsHint": "A Moderations category is matched when its score is greater than or equal to the configured threshold. The provider's flagged boolean is not used for enforcement.", "moderationThresholdsReset": "Restore default thresholds", "moderationThresholdDefault": "Default {{percent}}%", + "moderationCategories": { + "harassment": "Harassment", + "harassment/threatening": "Threatening harassment", + "hate": "Hate", + "hate/threatening": "Threatening hate", + "illicit": "Illicit activity", + "illicit/violent": "Violent illicit activity", + "self-harm": "Self-harm", + "self-harm/intent": "Self-harm intent", + "self-harm/instructions": "Self-harm instructions", + "sexual": "Sexual content", + "sexual/minors": "Sexual content involving minors", + "violence": "Violence", + "violence/graphic": "Graphic violence" + }, "reviewMaxConcurrent": "Maximum Concurrent Reviews", "reviewMaxTextLength": "Maximum Review Characters", "reviewCircuitBreakerFailures": "Circuit Breaker Failure Threshold", @@ -3860,8 +3881,14 @@ "keyGroupsAccountCost": "Upstream cost", "keyGroupsBilled": "User billed", "keyGroupsCurrentHint": "Active accounts use current groups; recycle-bin accounts use their last retained groups. Accounts in multiple groups count toward each group.", - "keyAccountDeleted": "Deleted", - "keyAccountDeletedHint": "This upstream account is in the recycle bin; its historical usage is still included." + "keyAccountDeleted": "Deleted", + "keyAccountDeletedHint": "This upstream account is in the recycle bin; its historical usage is still included.", + "keyAccountUngrouped": "Ungrouped", + "keyAccountHistoricalGroupMissing": "Historical group missing", + "keyGroupsUngrouped": "Ungrouped / historical group missing", + "keyGroupsReconcileCost": "Billing check:", + "keyGroupsReconcileAccounts": "Account check:", + "keyGroupsReconcileHint": "Group totals include repeated usage for multi-group accounts. {{ungrouped}} ungrouped account(s) are added and repeated assignments from {{multi}} multi-group account(s) are subtracted to match the deduplicated key total." }, "keyUsage": { "title": "API Key Usage", diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 8a37f74f..61695df6 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -2401,6 +2401,12 @@ "reviewApiKeyPlaceholder": "每行一个 key(sk-...),可填多个", "reviewApiKeyConfigured": "已配置 {{n}} 个 key,留空保持不变", "reviewApiKeyHint": "每行一个 key。配置多个可轮询分摊额度,遇到限流、无效 key 或服务端错误时自动切换。", + "reviewKeyListTitle": "已保存的审查 Key", + "reviewKeyListHint": "仅显示脱敏尾号;连接测试结果会对应到具体 Key,可单独删除异常项。", + "reviewKeyDeleteTitle": "删除这个审查 Key?", + "reviewKeyDeleteConfirm": "将删除 {{key}}。其他审查 Key 不受影响;启用模型复核时不能删除最后一个 Key。", + "reviewKeyDeleteAria": "删除审查 Key {{key}}", + "reviewKeyDeleted": "已删除审查 Key {{key}}", "reviewRequestMode": "请求适配模式", "reviewModeChat": "Chat Completions 兼容接口", "reviewModeModerations": "Moderations 兼容接口", @@ -2412,9 +2418,24 @@ }, "reviewConfidenceThreshold": "违规置信度阈值", "moderationThresholds": "风险阈值", - "moderationThresholdsHint": "按 OpenAI Moderations 分类调整命中阈值,分数达到或超过阈值即视为命中;不再使用上游 flagged 布尔值决定拦截。", + "moderationThresholdsHint": "按内容风险类型调整命中阈值,分数达到或超过阈值即视为命中。括号内保留上游 Moderations 英文分类标识,便于接口排查。", "moderationThresholdsReset": "恢复默认阈值", "moderationThresholdDefault": "默认 {{percent}}%", + "moderationCategories": { + "harassment": "骚扰内容", + "harassment/threatening": "带威胁的骚扰内容", + "hate": "仇恨内容", + "hate/threatening": "带威胁的仇恨内容", + "illicit": "违法或违规行为", + "illicit/violent": "涉及暴力的违法行为", + "self-harm": "自伤内容", + "self-harm/intent": "自伤意图", + "self-harm/instructions": "自伤方法或指导", + "sexual": "成人性内容", + "sexual/minors": "涉及未成年人的性内容", + "violence": "暴力内容", + "violence/graphic": "血腥或重度暴力内容" + }, "reviewMaxConcurrent": "最大并发审核数", "reviewMaxTextLength": "单次审核最大字符数", "reviewCircuitBreakerFailures": "连续失败熔断阈值", @@ -3860,8 +3881,14 @@ "keyGroupsAccountCost": "上游成本", "keyGroupsBilled": "下游计费", "keyGroupsCurrentHint": "活跃账号按当前分组、回收站账号按删除前保留的分组汇总;同一账号属于多个分组时会计入每个分组。", - "keyAccountDeleted": "已删除", - "keyAccountDeletedHint": "该上游账号已进入回收站,但历史用量仍计入统计。" + "keyAccountDeleted": "已删除", + "keyAccountDeletedHint": "该上游账号已进入回收站,但历史用量仍计入统计。", + "keyAccountUngrouped": "未分组", + "keyAccountHistoricalGroupMissing": "历史分组缺失", + "keyGroupsUngrouped": "未分组 / 历史分组缺失", + "keyGroupsReconcileCost": "计费核对:", + "keyGroupsReconcileAccounts": "账号核对:", + "keyGroupsReconcileHint": "分组卡片会重复计入多分组账号;加上 {{ungrouped}} 个未分组账号,再减去 {{multi}} 个多分组账号的重复归属后,即为 Key 去重总计。" }, "keyUsage": { "title": "API Key 使用统计", diff --git a/frontend/src/pages/PromptFilter.tsx b/frontend/src/pages/PromptFilter.tsx index 80db2718..af77d652 100644 --- a/frontend/src/pages/PromptFilter.tsx +++ b/frontend/src/pages/PromptFilter.tsx @@ -11,11 +11,12 @@ import StateShell from '../components/StateShell' import { DEFAULT_PAGE_SIZE_OPTIONS, usePersistedPageSize } from '../hooks/usePersistedPageSize' import { useDataLoader } from '../hooks/useDataLoader' import { useToast } from '../hooks/useToast' +import { useConfirmDialog } from '../hooks/useConfirmDialog' import { formatBeijingTime, formatRelativeTime } from '../utils/time' import { getErrorMessage } from '../utils/error' import { getPromptFilterScoreBand, normalizePromptFilterScore } from '../lib/promptFilterScore' import { parseAdvancedConfigDocument, patchAdvancedConfigDocument, readAdvancedConfigPath } from '../types' -import type { AdvancedConfigObject, AdvancedConfigPatch, PromptFilterLog, PromptFilterMatch, PromptFilterRule, PromptFilterRulesResponse, PromptFilterTestResponse, PromptGuardConfig, PromptGuardLayer, PromptGuardMode, PromptGuardProfile, PromptGuardProvider, PromptIdentityUpdateMode, PromptIntelligenceAIAnalysisResponse, PromptIntelligenceAIProvider, PromptIntelligenceCandidate, PromptIntelligenceEvidenceResponse, PromptIntelligenceGatewayKey, PromptIntelligenceRun, PromptPolicyIncident, PromptPolicyIncidentDetailResponse, PromptReviewTestResponse, PromptRiskProfile, PromptRiskProfileDetailResponse, SystemSettings } from '../types' +import type { AdvancedConfigObject, AdvancedConfigPatch, PromptFilterLog, PromptFilterMatch, PromptFilterRule, PromptFilterRulesResponse, PromptFilterTestResponse, PromptGuardConfig, PromptGuardLayer, PromptGuardMode, PromptGuardProfile, PromptGuardProvider, PromptIdentityUpdateMode, PromptIntelligenceAIAnalysisResponse, PromptIntelligenceAIProvider, PromptIntelligenceCandidate, PromptIntelligenceEvidenceResponse, PromptIntelligenceGatewayKey, PromptIntelligenceRun, PromptPolicyIncident, PromptPolicyIncidentDetailResponse, PromptReviewAPIKeyDescriptor, PromptReviewTestResponse, PromptRiskProfile, PromptRiskProfileDetailResponse, SystemSettings } from '../types' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' @@ -2749,6 +2750,10 @@ function OverviewView({ const [reviewTestText, setReviewTestText] = useState('请帮我整理今天的会议纪要。') const [reviewTesting, setReviewTesting] = useState(false) const [reviewTestResult, setReviewTestResult] = useState(null) + const [configuredReviewKeys, setConfiguredReviewKeys] = useState([]) + const [reviewKeysLoading, setReviewKeysLoading] = useState(false) + const [deletingReviewKeyID, setDeletingReviewKeyID] = useState(null) + const { confirm, confirmDialog } = useConfirmDialog() const [advancedOpen, setAdvancedOpen] = useState(false) const [reviewSettingsOpen, setReviewSettingsOpen] = useState(false) const [newAPISettingsOpen, setNewAPISettingsOpen] = useState(false) @@ -2863,6 +2868,52 @@ function OverviewView({ setReviewTesting(false) } } + useEffect(() => { + if (!reviewSettingsOpen) return + let cancelled = false + setReviewKeysLoading(true) + void api.getPromptReviewAPIKeys() + .then((result) => { + if (!cancelled) setConfiguredReviewKeys(result.items) + }) + .catch((err) => { + if (!cancelled) showToast(getErrorMessage(err), 'error') + }) + .finally(() => { + if (!cancelled) setReviewKeysLoading(false) + }) + return () => { cancelled = true } + }, [reviewSettingsOpen, showToast]) + const deleteReviewKey = async (keyID: string, masked: string) => { + const approved = await confirm({ + title: t('promptFilter.reviewKeyDeleteTitle'), + description: t('promptFilter.reviewKeyDeleteConfirm', { key: masked }), + confirmText: t('common.delete'), + tone: 'destructive', + confirmVariant: 'destructive', + }) + if (!approved) return + setDeletingReviewKeyID(keyID) + try { + const result = await api.deletePromptReviewAPIKey(keyID) + setConfiguredReviewKeys(result.items) + setForm((current) => ({ + ...current, + prompt_filter_review_api_key_configured: result.count > 0, + prompt_filter_review_api_key_count: result.count, + })) + setReviewTestResult((current) => current ? { + ...current, + key_count: result.count, + results: current.results?.filter((item) => item.key_id !== keyID), + } : null) + showToast(t('promptFilter.reviewKeyDeleted', { key: masked })) + } catch (err) { + showToast(getErrorMessage(err), 'error') + } finally { + setDeletingReviewKeyID(null) + } + } const applyRecommendedProtection = () => { setForm((current) => { const patched = patchAdvancedConfigDocument(current.prompt_filter_advanced_config, [ @@ -3077,6 +3128,37 @@ function OverviewView({ /> {t('promptFilter.reviewApiKeyHint')} + {configuredReviewKeys.length > 0 ? ( +
+
+
+
{t('promptFilter.reviewKeyListTitle')}
+

{t('promptFilter.reviewKeyListHint')}

+
+ {reviewKeysLoading ? '…' : configuredReviewKeys.length} +
+
+ {configuredReviewKeys.map((key) => { + const tested = reviewTestResult?.results?.find((item) => item.key_id ? item.key_id === key.id : item.key_index === key.index) + return ( +
+
+
+ #{key.index} + {key.masked} + {tested ? {tested.ok ? t('common.success') : t('common.failed')} : null} +
+ {tested ?
{tested.latency_ms} ms · {tested.ok ? tested.confidence.toFixed(2) : tested.error || '-'}
: null} +
+ +
+ ) + })} +
+
+ ) : null}
updateReviewAdapter('scope', value as ReviewAdapterFormConfig['scope'])} options={(['all_requests', 'local_candidates', 'local_blocks'] as ReviewAdapterFormConfig['scope'][]).map((scope) => ({ label: t(`promptFilter.reviewScopeOptions.${scope}`), value: scope }))} /> @@ -3093,7 +3175,11 @@ function OverviewView({
{moderationThresholdCategories.map((category) => ( - +
updateModerationThreshold(category, value)} /> % @@ -3158,7 +3244,7 @@ function OverviewView({
{t('promptFilter.reviewModel')}: {reviewTestResult.model}
{reviewTestResult.highest_category ?
{t('promptFilter.reviewTestHighestCategory')}: {reviewTestResult.highest_category}
: null} {reviewTestResult.reason ?
{t('promptFilter.reviewTestReason')}: {reviewTestResult.reason}
: null} - {reviewTestResult.results?.length ?
{reviewTestResult.results.map((item) =>
Key #{item.key_index}{item.ok ? t('common.success') : t('common.failed')}
{item.latency_ms} ms · {item.ok ? item.confidence.toFixed(2) : item.error || '-'}
)}
: null} + {reviewTestResult.results?.length ?
{reviewTestResult.results.map((item) =>
{item.key_masked || `Key #${item.key_index}`}{item.ok ? t('common.success') : t('common.failed')}
{item.latency_ms} ms · {item.ok ? item.confidence.toFixed(2) : item.error || '-'}
)}
: null}
) : null}
@@ -3246,6 +3332,7 @@ function OverviewView({ + {confirmDialog} ) } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 7fdaa416..a1033c83 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1666,6 +1666,8 @@ export interface PromptReviewTestRequest { export interface PromptReviewKeyTestResult { key_index: number + key_id?: string + key_masked?: string ok: boolean endpoint?: string model?: string @@ -1682,6 +1684,17 @@ export interface PromptReviewKeyTestResult { error?: string } +export interface PromptReviewAPIKeyDescriptor { + id: string + index: number + masked: string +} + +export interface PromptReviewAPIKeysResponse { + items: PromptReviewAPIKeyDescriptor[] + count: number +} + export interface PromptReviewTestResponse { ok: boolean endpoint: string @@ -2215,10 +2228,19 @@ export interface APIKeyAccountUsageSummary { user_billed: number } +export interface APIKeyAccountUsageReconciliation { + grouped_total: APIKeyAccountUsageSummary + ungrouped: APIKeyAccountUsageSummary + duplicate: APIKeyAccountUsageSummary + unique_grouped_accounts: number + multi_group_accounts: number +} + export interface APIKeyAccountStatsResponse { items: APIKeyAccountStat[] groups: APIKeyAccountGroupUsage[] summary: APIKeyAccountUsageSummary + reconciliation?: APIKeyAccountUsageReconciliation /** Active accounts use current memberships; deleted accounts use their last retained membership. */ membership_basis: 'current_and_deleted_last_membership' }