diff --git a/internal/control/health.go b/internal/control/health.go index dea0df21..5ef9b99b 100644 --- a/internal/control/health.go +++ b/internal/control/health.go @@ -44,7 +44,7 @@ type healthProblemCredentialResponse struct { CredentialID uint `json:"credential_id"` GroupID uint `json:"group_id"` GroupName string `json:"group_name"` - Mask string `json:"mask"` + Identity string `json:"identity"` LastFailureCategory string `json:"last_failure_category"` LastStatusCode *int `json:"last_status_code"` CooldownUntilMS *int64 `json:"cooldown_until_ms"` @@ -193,7 +193,10 @@ func optionalHealthStatusCode(value int) *int { return &cloned } -func (service *Service) healthProblemMask( +// healthProblemCredentialIdentity 返回健康问题列表里凭据的展示身份: +// API 密钥仍是掩码,订阅账号给完整邮箱,与凭据卡片、日志的既有约定一致; +// 拿不到邮箱时回退到 "Subscription #ID"。access/refresh token 绝不进入返回值。 +func (service *Service) healthProblemCredentialIdentity( ciphertexts map[uint]string, credentialID uint, channelID channel.ID, @@ -233,8 +236,8 @@ func (service *Service) healthProblemMask( if parseErr != nil { return "", fmt.Errorf("map runtime health subscription credential %d: %w", credentialID, app_errors.ErrInternalServer) } - if mask := maskEmail(credential.Account().Email); mask != "" { - return mask, nil + if email := strings.TrimSpace(credential.Account().Email); email != "" { + return email, nil } return fmt.Sprintf("Subscription #%d", credentialID), nil } @@ -331,7 +334,7 @@ func (service *Service) RuntimeHealth() (runtimeHealthResponse, error) { continue } groupView := observation.snapshot.Groups[key.GroupID] - mask, err := service.healthProblemMask( + identity, err := service.healthProblemCredentialIdentity( observation.problemCiphertexts, key.ID, groupView.ChannelID, @@ -351,7 +354,7 @@ func (service *Service) RuntimeHealth() (runtimeHealthResponse, error) { CredentialID: key.ID, GroupID: key.GroupID, GroupName: group.Name, - Mask: mask, + Identity: identity, LastFailureCategory: lastFailureCategory.String(), LastStatusCode: optionalHealthStatusCode(lastStatusCode), FailureCount: key.FailureCount, diff --git a/internal/control/health_test.go b/internal/control/health_test.go index 21351f6a..cc18a38a 100644 --- a/internal/control/health_test.go +++ b/internal/control/health_test.go @@ -147,7 +147,7 @@ func TestRuntimeHealthReturnsMutuallyExclusiveCurrentState(t *testing.T) { t.Fatalf("disabled/zero/empty group counts = %#v", got.Groups) } if len(got.CooldownCredentials) != 1 || got.CooldownCredentials[0].CredentialID != 12 || - got.CooldownCredentials[0].Mask != "rate****safe" || + got.CooldownCredentials[0].Identity != "rate****safe" || got.CooldownCredentials[0].LastFailureCategory != "rate_limited" || got.CooldownCredentials[0].LastStatusCode == nil || *got.CooldownCredentials[0].LastStatusCode != 429 || @@ -158,7 +158,7 @@ func TestRuntimeHealthReturnsMutuallyExclusiveCurrentState(t *testing.T) { t.Fatalf("cooldown details = %#v", got.CooldownCredentials) } if len(got.BlacklistedCredentials) != 1 || got.BlacklistedCredentials[0].CredentialID != 13 || - got.BlacklistedCredentials[0].Mask != "inva****lock" || + got.BlacklistedCredentials[0].Identity != "inva****lock" || got.BlacklistedCredentials[0].LastFailureCategory != "invalid_key" || got.BlacklistedCredentials[0].LastStatusCode == nil || *got.BlacklistedCredentials[0].LastStatusCode != 401 || @@ -435,7 +435,7 @@ func TestRuntimeHealthJSONOmitsScoresCredentialsAndZeroTimes(t *testing.T) { t.Fatalf("RuntimeHealth() error = %v", err) } if len(result.BlacklistedCredentials) != 1 || - result.BlacklistedCredentials[0].Mask != "prov****tail" || + result.BlacklistedCredentials[0].Identity != "prov****tail" || result.BlacklistedCredentials[0].LastFailureCategory != "ambiguous" || result.BlacklistedCredentials[0].LastStatusCode != nil { t.Fatalf("blacklisted details = %#v", result.BlacklistedCredentials) @@ -519,18 +519,18 @@ func TestRuntimeHealthFailsClosedWhenProblemKeyCannotBeDecrypted(t *testing.T) { } } -func TestHealthProblemMaskFailsClosedWhenCiphertextIsMissing(t *testing.T) { +func TestHealthProblemCredentialIdentityFailsClosedWhenCiphertextIsMissing(t *testing.T) { t.Parallel() fixture := newServiceFixture(t) - if _, err := fixture.service.healthProblemMask(nil, 1, "", "api_key"); !errors.Is( + if _, err := fixture.service.healthProblemCredentialIdentity(nil, 1, "", "api_key"); !errors.Is( err, app_errors.ErrInternalServer, ) { - t.Fatalf("healthProblemMask() error = %v, want INTERNAL_SERVER_ERROR", err) + t.Fatalf("healthProblemCredentialIdentity() error = %v, want INTERNAL_SERVER_ERROR", err) } } -func TestHealthProblemMaskExtractsTypedCredentialSecret(t *testing.T) { +func TestHealthProblemCredentialIdentityMasksAPIKeySecret(t *testing.T) { t.Parallel() fixture := newServiceFixture(t) ciphertext := encryptHealthKey( @@ -538,31 +538,33 @@ func TestHealthProblemMaskExtractsTypedCredentialSecret(t *testing.T) { fixture, `{"api_key":"provider-secret-credential-tail"}`, ) - mask, err := fixture.service.healthProblemMask( + identity, err := fixture.service.healthProblemCredentialIdentity( map[uint]string{1: ciphertext}, 1, channel.OpenAI, "api_key", ) if err != nil { - t.Fatalf("healthProblemMask() error = %v", err) + t.Fatalf("healthProblemCredentialIdentity() error = %v", err) } - if mask != "prov****tail" || strings.Contains(mask, "api_key") || strings.Contains(mask, "{") { - t.Fatalf("healthProblemMask() = %q, want api_key-only mask", mask) + if identity != "prov****tail" || strings.Contains(identity, "api_key") || strings.Contains(identity, "{") { + t.Fatalf("healthProblemCredentialIdentity() = %q, want api_key mask", identity) } } -func TestHealthProblemMaskUsesSafeSubscriptionAccountIdentity(t *testing.T) { +// 订阅账号在管理面其余位置(凭据卡片、日志)一律展示完整邮箱,这里不再是例外; +// 泄露边界仍然成立——展示的只是邮箱,refresh/access token 绝不出现在返回值里。 +func TestHealthProblemCredentialIdentityShowsFullSubscriptionEmail(t *testing.T) { t.Parallel() fixture := newServiceFixture(t) ciphertext := encryptHealthKey(t, fixture, `{"type":"codex","access_token":"access-secret","refresh_token":"refresh-secret","account_id":"account-one","email":"owner@example.com"}`) - mask, err := fixture.service.healthProblemMask(map[uint]string{1: ciphertext}, 1, channel.Codex, "subscription") + identity, err := fixture.service.healthProblemCredentialIdentity(map[uint]string{1: ciphertext}, 1, channel.Codex, "subscription") if err != nil { t.Fatal(err) } - if mask != "o***r@example.com" || strings.Contains(mask, "secret") { - t.Fatalf("mask = %q", mask) + if identity != "owner@example.com" || strings.Contains(identity, "secret") { + t.Fatalf("identity = %q", identity) } } diff --git a/internal/control/project_model_collection.go b/internal/control/project_model_collection.go index 1801764a..d331ddc2 100644 --- a/internal/control/project_model_collection.go +++ b/internal/control/project_model_collection.go @@ -300,10 +300,20 @@ func (s *Service) ListProjectModels(ctx context.Context, query ProjectModelListQ continue } sortProjectModelGroups(upstream.routeGroups) + // 分组信息只对管理员可见;访问密钥范围整条不发,而不是发一条 + // 身份被抹空的占位记录——id=0/channel_id="" 这类占位值会违反 + // 前端 projectSafeInteger/projectChannelID 的强校验,把整个 + // 模型页判定为无效响应,比不发这条数据本身还糟。 + routeGroups := []ProjectModelGroupDTO{} + affectedGroups := []ProjectModelGroupDTO{} + if query.AccessKeyID == nil { + routeGroups = append(routeGroups, upstream.routeGroups...) + affectedGroups = append(affectedGroups, upstream.affectedGroups...) + } upstreamDTO := ProjectUpstreamModelDTO{ ModelID: upstream.modelID, AliasApplied: upstream.aliasApplied, - Price: upstream.price, RouteGroups: append([]ProjectModelGroupDTO{}, upstream.routeGroups...), - AffectedGroups: append([]ProjectModelGroupDTO{}, upstream.affectedGroups...), + Price: upstream.price, RouteGroups: routeGroups, + AffectedGroups: affectedGroups, CatalogReference: upstream.catalogReference, } dto.UpstreamModels = append(dto.UpstreamModels, upstreamDTO) diff --git a/internal/control/project_model_collection_test.go b/internal/control/project_model_collection_test.go index 762c6583..5817e780 100644 --- a/internal/control/project_model_collection_test.go +++ b/internal/control/project_model_collection_test.go @@ -182,17 +182,15 @@ func TestProjectModelsHTTPScopesAccessKeyFiltersAndRelationships(t *testing.T) { t.Fatalf("AccessKey shared model = %#v", shared) } for _, upstream := range shared.UpstreamModels { - if len(upstream.RouteGroups) != 1 || len(upstream.AffectedGroups) != 1 { - t.Fatalf("channel-scoped shared upstream = %#v", upstream) + // 分组信息只对管理员可见。占位对象(id=0/channel_id="")会触发前端 + // projectSafeInteger/projectChannelID 的强校验直接判无效响应,把整个 + // 模型页读崩——必须整条不发,而不是发一条身份被抹空的记录。 + if len(upstream.RouteGroups) != 0 || len(upstream.AffectedGroups) != 0 { + t.Fatalf("AccessKey upstream must omit group identity entirely = %#v", upstream) } if upstream.Price.ChannelIcon == "" || upstream.Price.ChannelMark == "" { t.Fatalf("AccessKey upstream misses channel visual identity: %#v", upstream.Price) } - for _, group := range append(upstream.RouteGroups, upstream.AffectedGroups...) { - if got := string(group.Params); got != `{}` { - t.Fatalf("AccessKey route Group params = %s, want empty object", got) - } - } } for _, privateValue := range []string{ "base_url", "https://openai.example/v1", diff --git a/internal/control/request_logs.go b/internal/control/request_logs.go index 911e1247..aaadc737 100644 --- a/internal/control/request_logs.go +++ b/internal/control/request_logs.go @@ -11,6 +11,7 @@ import ( "net/url" "regexp" "strconv" + "strings" "github.com/gin-gonic/gin" @@ -67,6 +68,7 @@ type requestLogAttemptResponse struct { GroupName string `json:"group_name"` ChannelID *channel.ID `json:"channel_id"` CredentialID *uint `json:"credential_id"` + CredentialName string `json:"credential_name"` Operation *execution.Operation `json:"operation"` RouteMode *channel.RouteMode `json:"route_mode"` UpstreamModel *string `json:"upstream_model"` @@ -147,6 +149,7 @@ type requestLogItemResponse struct { GroupID *uint `json:"group_id"` ChannelID *channel.ID `json:"channel_id"` CredentialID *uint `json:"credential_id"` + CredentialName string `json:"credential_name"` RouteMode *channel.RouteMode `json:"route_mode"` UsageState usage.State `json:"usage_state"` CostState pricing.CostState `json:"cost_state"` @@ -249,7 +252,10 @@ func (s *Server) handleListRequestLogs(c *gin.Context) { page.Items[index] = sanitizeAccessKeyRequestLog(page.Items[index]) } } - result, err := mapRequestLogListResponse(page) + result, err := mapRequestLogListResponse( + page, + s.service.CredentialLabels(requestLogCredentialIDs(page.Items)), + ) if err != nil { writeServiceError(c, "list_request_logs", err) return @@ -278,7 +284,10 @@ func (s *Server) handleGetRequestLog(c *gin.Context) { writeServiceError(c, "get_request_log", err) return } - result, err := mapRequestLogDetailResponse(record) + result, err := mapRequestLogDetailResponse( + record, + s.service.CredentialLabels(requestLogCredentialIDs([]requestlog.Record{record})), + ) if err != nil { writeServiceError(c, "get_request_log", err) return @@ -805,7 +814,29 @@ func encodeRequestLogCursor(cursor requestlog.Cursor) (string, error) { return base64.RawURLEncoding.EncodeToString(raw), nil } -func mapRequestLogListResponse(page requestlog.Page) (requestLogListResponse, error) { +func credentialLabelFor(labels map[uint]string, credentialID *uint) string { + if labels == nil || credentialID == nil { + return "" + } + return labels[*credentialID] +} + +// 一页日志里的凭据数远小于条数:同一个号会被反复使用,去重后通常只剩几个。 +func requestLogCredentialIDs(records []requestlog.Record) []uint { + ids := make([]uint, 0, len(records)) + for _, record := range records { + ids = append(ids, record.CredentialID) + for _, attempt := range record.Attempts { + ids = append(ids, attempt.CredentialID) + } + } + return ids +} + +func mapRequestLogListResponse( + page requestlog.Page, + credentialLabels map[uint]string, +) (requestLogListResponse, error) { result := requestLogListResponse{ Items: make([]requestLogItemResponse, 0, len(page.Items)), } @@ -820,7 +851,7 @@ func mapRequestLogListResponse(page requestlog.Page) (requestLogListResponse, er if err != nil { return requestLogListResponse{}, err } - item, err := mapRequestLogItemResponse(record, usageCost) + item, err := mapRequestLogItemResponse(record, usageCost, credentialLabels) if err != nil { return requestLogListResponse{}, err } @@ -839,6 +870,7 @@ func mapRequestLogListResponse(page requestlog.Page) (requestLogListResponse, er func mapRequestLogItemResponse( record requestlog.Record, usageCost requestLogUsageCostResponse, + credentialLabels map[uint]string, ) (requestLogItemResponse, error) { inputTokens, ok := checkedRequestLogInputTokens(record) if !ok { @@ -893,6 +925,7 @@ func mapRequestLogItemResponse( GroupID: usageCost.groupID, ChannelID: usageCost.channelID, CredentialID: usageCost.credentialID, + CredentialName: credentialLabelFor(credentialLabels, usageCost.credentialID), RouteMode: routeMode, UsageState: record.UsageState, CostState: record.CostState, @@ -979,18 +1012,21 @@ func checkedRequestLogInputTokens(record requestlog.Record) (int64, bool) { return total, true } -func mapRequestLogDetailResponse(record requestlog.Record) (requestLogDetailResponse, error) { +func mapRequestLogDetailResponse( + record requestlog.Record, + credentialLabels map[uint]string, +) (requestLogDetailResponse, error) { usageCost, err := mapRequestLogUsageCost(record) if err != nil { return requestLogDetailResponse{}, err } - item, err := mapRequestLogItemResponse(record, usageCost) + item, err := mapRequestLogItemResponse(record, usageCost, credentialLabels) if err != nil { return requestLogDetailResponse{}, err } attempts := make([]requestLogAttemptResponse, 0, len(record.Attempts)) for _, attempt := range record.Attempts { - mapped, err := mapRequestLogAttempt(attempt) + mapped, err := mapRequestLogAttempt(attempt, credentialLabels) if err != nil { return requestLogDetailResponse{}, err } @@ -1002,7 +1038,10 @@ func mapRequestLogDetailResponse(record requestlog.Record) (requestLogDetailResp }, nil } -func mapRequestLogAttempt(attempt requestlog.Attempt) (requestLogAttemptResponse, error) { +func mapRequestLogAttempt( + attempt requestlog.Attempt, + credentialLabels map[uint]string, +) (requestLogAttemptResponse, error) { channelID, err := nullableRequestLogChannelID(attempt.ChannelID) if err != nil { return requestLogAttemptResponse{}, fmt.Errorf("map request log attempt: %w", err) @@ -1053,6 +1092,7 @@ func mapRequestLogAttempt(attempt requestlog.Attempt) (requestLogAttemptResponse GroupName: attempt.GroupName, ChannelID: channelID, CredentialID: credentialID, + CredentialName: credentialLabelFor(credentialLabels, credentialID), Operation: operation, RouteMode: routeMode, UpstreamModel: nullableRequestLogModel(attempt.UpstreamModel), @@ -1323,3 +1363,72 @@ func mapRequestLogUsageCost(record requestlog.Record) (requestLogUsageCostRespon } return result, nil } + +// CredentialLabels 把凭据 ID 翻译成可读标识:API 密钥给掩码,订阅账号给邮箱掩码。 +// 密文常驻凭据注册表,整个过程不读数据库;注册表里没有的凭据(已删除)不出现在 +// 结果里,由调用方决定如何呈现。入参允许重复,每个 ID 至多解密一次。 +func (s *Service) CredentialLabels(credentialIDs []uint) map[uint]string { + if s == nil || s.registry == nil || s.encryption == nil || len(credentialIDs) == 0 { + return nil + } + s.writeMu.RLock() + snapshot := s.manager.Current() + s.writeMu.RUnlock() + if snapshot == nil { + return nil + } + labels := make(map[uint]string, len(credentialIDs)) + seen := make(map[uint]struct{}, len(credentialIDs)) + for _, credentialID := range credentialIDs { + if credentialID == 0 { + continue + } + if _, done := seen[credentialID]; done { + continue + } + seen[credentialID] = struct{}{} + ref, known := s.registry.CredentialRef(credentialID) + if !known || ref.EncryptedValue == "" { + continue + } + group, exists := snapshot.Groups[ref.GroupID] + if !exists { + continue + } + label, err := s.credentialLabel(ref.EncryptedValue, group.ChannelID, group.ConnectionType) + if err != nil || label == "" { + continue + } + labels[credentialID] = label + } + return labels +} + +// 与凭据列表一致:订阅账号给完整邮箱(本就在管理面明示),API 密钥给掩码。 +func (s *Service) credentialLabel( + ciphertext string, + channelID channel.ID, + connectionType string, +) (string, error) { + plaintext, err := s.encryption.Decrypt(ciphertext) + if err != nil { + return "", err + } + expected, known := s.channelRegistry.ConnectionType(channelID) + if !known || expected != strings.TrimSpace(connectionType) { + return "", fmt.Errorf("channel connection mismatch") + } + if driver, bound := s.subscriptions.Driver(channelID); bound { + credential, parseErr := driver.Parse([]byte(plaintext)) + plaintext = "" + if parseErr != nil { + return "", parseErr + } + return strings.TrimSpace(credential.Account().Email), nil + } + validated, err := normalizeStoredCredential(s.channelRegistry, channelID, plaintext) + if err != nil { + return "", err + } + return maskCanonicalCredential(validated.CanonicalJSON()) +} diff --git a/internal/control/request_logs_test.go b/internal/control/request_logs_test.go index ef699372..07821fef 100644 --- a/internal/control/request_logs_test.go +++ b/internal/control/request_logs_test.go @@ -17,14 +17,17 @@ import ( "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" + "gorm.io/gorm" "gpt-load/internal/channel" "gpt-load/internal/execution" "gpt-load/internal/platform/config" + "gpt-load/internal/platform/utils" "gpt-load/internal/pricing" "gpt-load/internal/protocol" "gpt-load/internal/reasoning" "gpt-load/internal/requestlog" + "gpt-load/internal/storage/models" "gpt-load/internal/telemetry" "gpt-load/internal/usage" ) @@ -842,7 +845,7 @@ func TestRequestLogResponseUsesNullModelsForProtocolOnlyResponsesResources(t *te CostState: pricing.CostStateNotApplicable, PricingCompleteness: pricing.CompletenessNotApplicable, }}, - }) + }, nil) if err != nil { t.Fatalf("mapRequestLogListResponse() error = %v", err) } @@ -886,7 +889,7 @@ func TestRequestLogDetailProjectsEmptyAttemptReasoningAsNull(t *testing.T) { FailureCategory: telemetry.FailureCategoryClientError, Action: telemetry.ActionTerminate, }}, - }) + }, nil) if err != nil { t.Fatalf("mapRequestLogDetailResponse() error = %v", err) } @@ -957,7 +960,7 @@ func TestRequestLogUsageCostProjectionRejectsUnsafeValues(t *testing.T) { t.Run(test.name, func(t *testing.T) { record := base test.mutate(&record) - if _, err := mapRequestLogListResponse(requestlog.Page{Items: []requestlog.Record{record}}); err == nil { + if _, err := mapRequestLogListResponse(requestlog.Page{Items: []requestlog.Record{record}}, nil); err == nil { t.Fatal("mapRequestLogListResponse() error = nil, want rejection") } }) @@ -974,7 +977,7 @@ func TestRequestLogUsageCostProjectionAcceptsMaximumSafeFinalGroupID(t *testing. GroupID: uint(safeGroupID), UsageState: usage.StateComplete, CostState: pricing.CostStatePriced, PricingCompleteness: pricing.CompletenessComplete, } - result, err := mapRequestLogListResponse(requestlog.Page{Items: []requestlog.Record{record}}) + result, err := mapRequestLogListResponse(requestlog.Page{Items: []requestlog.Record{record}}, nil) if err != nil { t.Fatalf("mapRequestLogListResponse() error = %v", err) } @@ -1311,7 +1314,7 @@ func TestRequestLogAttemptActionUsesCredentialTerms(t *testing.T) { telemetry.Action("fail_credential"): "fail_credential", telemetry.ActionTerminate: string(telemetry.ActionTerminate), } { - mapped, err := mapRequestLogAttempt(requestlog.Attempt{Action: input}) + mapped, err := mapRequestLogAttempt(requestlog.Attempt{Action: input}, nil) if err != nil { t.Fatalf("mapRequestLogAttempt(%q) error = %v", input, err) } @@ -1334,3 +1337,109 @@ func Example_requestLogOpaqueCursor() { fmt.Println(encodeTestCursorPayload(payload)) // Output: eyJ2IjoyLCJjb21wbGV0ZWRfYXRfbXMiOjE3ODQ4OTQ0MDAwMDAsInJlcXVlc3RfaWQiOiIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwMDEifQ } + +// 日志里的凭据要显示成人话:密文常驻凭据注册表,按 ID 取出解密即可, +// 不额外读库;注册表里没有的(已删除)留空,交由前端显示“已删除 · #id”。 +func TestCredentialLabelsMasksFromRegistryWithoutDatabaseReads(t *testing.T) { + t.Parallel() + fixture := newServiceFixture(t) + created, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{ + Name: stringPointer("log labels"), ChannelID: channel.OpenAI, + ConnectionType: models.ConnectionTypeAPIKey, + Models: optionalGroupModels{Set: true}, + Credentials: "sk-log-label-abcdefghijklmnop", + }) + if err != nil { + t.Fatalf("CreateGroup() error = %v", err) + } + var credential models.Credential + if err := fixture.db.Where("group_id = ?", created.GroupID).Take(&credential).Error; err != nil { + t.Fatal(err) + } + + queries := 0 + const callbackName = "test:credential_labels_no_db" + if err := fixture.db.Callback().Query().Before("gorm:query").Register(callbackName, func(*gorm.DB) { + queries++ + }); err != nil { + t.Fatalf("register query observer: %v", err) + } + t.Cleanup(func() { _ = fixture.db.Callback().Query().Remove(callbackName) }) + + labels := fixture.service.CredentialLabels([]uint{credential.ID, credential.ID, 9_999}) + if queries != 0 { + t.Fatalf("database queries = %d, want 0", queries) + } + label, exists := labels[credential.ID] + if !exists || label == "" { + t.Fatalf("label for %d = %q, exists = %v", credential.ID, label, exists) + } + if strings.Contains(label, "log-label-abcdefghijklmnop") { + t.Fatalf("label %q leaks the credential", label) + } + if want := utils.MaskAPIKey("sk-log-label-abcdefghijklmnop"); label != want { + t.Fatalf("label = %q, want %q", label, want) + } + if _, exists := labels[9_999]; exists { + t.Fatalf("unknown credential should be absent, got %q", labels[9_999]) + } +} + +func TestRequestLogResponsesCarryCredentialLabels(t *testing.T) { + t.Parallel() + record := requestlog.Record{ + RequestID: "11111111-1111-4111-8111-111111111111", + CompletedAtMS: 1_700_000_000_000, + GroupID: 3, + CredentialID: 41, + UsageState: usage.StateComplete, + CostState: pricing.CostStatePriced, + PricingCompleteness: pricing.CompletenessComplete, + Attempts: []requestlog.Attempt{ + {Sequence: 1, GroupID: 3, CredentialID: 41}, + {Sequence: 2, GroupID: 3, CredentialID: 42}, + }, + } + labels := map[uint]string{41: "m***t@example.com"} + + detail, err := mapRequestLogDetailResponse(record, labels) + if err != nil { + t.Fatalf("mapRequestLogDetailResponse() error = %v", err) + } + if detail.CredentialName != "m***t@example.com" { + t.Fatalf("item credential_name = %q", detail.CredentialName) + } + if detail.Attempts[0].CredentialName != "m***t@example.com" { + t.Fatalf("attempt 1 credential_name = %q", detail.Attempts[0].CredentialName) + } + // 注册表里没有的凭据留空,前端据此显示“已删除”。 + if detail.Attempts[1].CredentialName != "" { + t.Fatalf("attempt 2 credential_name = %q, want empty", detail.Attempts[1].CredentialName) + } + + list, err := mapRequestLogListResponse(requestlog.Page{Items: []requestlog.Record{record}}, labels) + if err != nil { + t.Fatalf("mapRequestLogListResponse() error = %v", err) + } + if list.Items[0].CredentialName != "m***t@example.com" { + t.Fatalf("list credential_name = %q", list.Items[0].CredentialName) + } +} + +// 同一个凭据在一页里反复出现,收集时按出现顺序全量给出,去重交给 CredentialLabels。 +func TestRequestLogCredentialIDsCollectsItemAndAttempts(t *testing.T) { + t.Parallel() + ids := requestLogCredentialIDs([]requestlog.Record{ + {CredentialID: 41, Attempts: []requestlog.Attempt{{CredentialID: 41}, {CredentialID: 42}}}, + {CredentialID: 41}, + }) + want := []uint{41, 41, 42, 41} + if len(ids) != len(want) { + t.Fatalf("ids = %v, want %v", ids, want) + } + for index := range want { + if ids[index] != want[index] { + t.Fatalf("ids = %v, want %v", ids, want) + } + } +} diff --git a/web/src/api/control/types.ts b/web/src/api/control/types.ts index 6ab659ae..4479f9de 100644 --- a/web/src/api/control/types.ts +++ b/web/src/api/control/types.ts @@ -425,7 +425,8 @@ export interface HealthProblemCredentialDto { weight_manual: number | null weight_auto: number recovery: HealthRecoveryDto - mask: string + /** API 密钥仍是掩码,订阅账号给完整邮箱,与凭据卡片、日志的展示约定一致。 */ + identity: string last_failure_category: Exclude last_status_code: number | null } diff --git a/web/src/app/resources/health.ts b/web/src/app/resources/health.ts index 4f7d92c6..9c774bf3 100644 --- a/web/src/app/resources/health.ts +++ b/web/src/app/resources/health.ts @@ -90,7 +90,7 @@ const problemCredentialFields = [ 'weight_manual', 'weight_auto', 'recovery', - 'mask', + 'identity', 'last_failure_category', 'last_status_code', ] as const @@ -218,7 +218,7 @@ function projectProblemCredential(value: unknown): HealthProblemCredentialDto { : projectSafeInteger(record.weight_manual, { minimum: 0, maximum: 100 }), weight_auto: projectSafeInteger(record.weight_auto, { minimum: 0, maximum: 100 }), recovery, - mask: projectString(record.mask), + identity: projectString(record.identity), last_failure_category: projectEnum(record.last_failure_category, problemFailureCategories), last_status_code: record.last_status_code === null diff --git a/web/src/app/resources/models.ts b/web/src/app/resources/models.ts index e894a946..a3b89f9e 100644 --- a/web/src/app/resources/models.ts +++ b/web/src/app/resources/models.ts @@ -322,6 +322,7 @@ function projectCatalogReference( function projectUpstreamPrice( record: Record, upstreamModelID: string, + isAccessKey: boolean, ): Pick { const price = projectModelPrice(record.price) const routeGroups = projectArray(record.route_groups, projectRouteGroup) @@ -329,14 +330,16 @@ function projectUpstreamPrice( const affectedGroupIDs = new Set(affectedGroups.map(({ id }) => id)) if ( price.model_id !== upstreamModelID || - routeGroups.length === 0 || - affectedGroups.length === 0 || + (isAccessKey + ? routeGroups.length !== 0 || affectedGroups.length !== 0 + : routeGroups.length === 0 || + affectedGroups.length === 0 || + price.reference_group_count !== affectedGroups.length) || routeGroups.some(({ channel_id }) => channel_id !== price.channel_id) || affectedGroups.some(({ channel_id }) => channel_id !== price.channel_id) || new Set(routeGroups.map(({ id }) => id)).size !== routeGroups.length || affectedGroupIDs.size !== affectedGroups.length || - routeGroups.some(({ id }) => !affectedGroupIDs.has(id)) || - price.reference_group_count !== affectedGroups.length + routeGroups.some(({ id }) => !affectedGroupIDs.has(id)) ) { invalidResponse() } @@ -351,7 +354,11 @@ function projectUpstreamPrice( } } -function projectUpstreamModel(value: unknown, clientModel: string): ModelUpstreamDto { +function projectUpstreamModel( + value: unknown, + clientModel: string, + isAccessKey: boolean, +): ModelUpstreamDto { const record = projectRecord(value) assertNoSecretLikeFields(record, upstreamModelFields) const modelID = projectIdentityString(record.model_id) @@ -360,7 +367,7 @@ function projectUpstreamModel(value: unknown, clientModel: string): ModelUpstrea return { model_id: modelID, alias_applied: aliasApplied, - ...projectUpstreamPrice(record, modelID), + ...projectUpstreamPrice(record, modelID, isAccessKey), } } @@ -404,12 +411,12 @@ export function projectUpstreamModelDetail(value: unknown): UpstreamModelDetailD } } -function projectClientModel(value: unknown): ClientModelDto { +function projectClientModel(value: unknown, isAccessKey: boolean): ClientModelDto { const record = projectRecord(value) assertNoSecretLikeFields(record, clientModelFields) const clientModel = projectIdentityString(record.client_model) const upstreamModels = projectArray(record.upstream_models, (upstream) => - projectUpstreamModel(upstream, clientModel), + projectUpstreamModel(upstream, clientModel, isAccessKey), ) if ( upstreamModels.length === 0 || @@ -475,12 +482,12 @@ function expectedPageItems(pagination: ModelCollectionPaginationDto): number { return remainder === 0 ? pagination.page_size : remainder } -export function projectModelCollection(value: unknown): ModelCollectionDto { +export function projectModelCollection(value: unknown, isAccessKey = false): ModelCollectionDto { const record = projectRecord(value) assertNoSecretLikeFields(record, collectionFields) const summary = projectSummary(record.summary) const catalog = projectCatalog(record.catalog) - const items = projectArray(record.items, projectClientModel) + const items = projectArray(record.items, (item) => projectClientModel(item, isAccessKey)) const pagination = projectPagination(record.pagination) const expectedTotalPages = pagination.total_items === 0 ? 0 : Math.ceil(pagination.total_items / pagination.page_size) @@ -513,6 +520,7 @@ export async function listModels( client: ApiClient, filters: ModelCollectionFilters, signal?: AbortSignal, + isAccessKey = false, ): Promise { const normalized = normalizeModelCollectionFilters(filters) const params = new URLSearchParams({ @@ -524,6 +532,7 @@ export async function listModels( if (normalized.q !== undefined) params.set('q', normalized.q) const result = projectModelCollection( await client.request(`/api/models?${params.toString()}`, { method: 'GET', signal }), + isAccessKey, ) if ( result.pagination.page !== normalized.page || @@ -547,12 +556,14 @@ export async function getUpstreamModelDetail( export function modelCollectionQueryOptions( client: ApiClient, filters: MaybeRefOrGetter, + isAccessKey: MaybeRefOrGetter = false, ) { return queryOptions({ queryKey: computed(() => controlQueryKeys.models.collection(normalizeModelCollectionFilters(toValue(filters))), ), - queryFn: ({ queryKey, signal }) => listModels(client, queryKey[3], signal), + queryFn: ({ queryKey, signal }) => + listModels(client, queryKey[3], signal, toValue(isAccessKey)), placeholderData: keepPreviousData, }) } diff --git a/web/src/app/resources/request-logs.ts b/web/src/app/resources/request-logs.ts index dec51f89..2a347401 100644 --- a/web/src/app/resources/request-logs.ts +++ b/web/src/app/resources/request-logs.ts @@ -126,6 +126,8 @@ export interface RequestLogAttemptDto { group_name: string channel_id: string | null credential_id: number | null + /** 凭据的可读标识(掩码)。凭据已删除时为空。 */ + credential_name: string operation: RequestLogOperation | null route_mode: RequestLogRouteMode | null upstream_model: string | null @@ -180,6 +182,8 @@ export interface RequestLogItemDto { group_id: number | null channel_id: string | null credential_id: number | null + /** 凭据的可读标识(掩码)。凭据已删除时为空。 */ + credential_name: string route_mode: RequestLogRouteMode | null usage_state: RequestLogUsageState cost_state: RequestLogCostState @@ -281,6 +285,7 @@ const itemFields = [ 'group_id', 'channel_id', 'credential_id', + 'credential_name', 'route_mode', 'usage_state', 'cost_state', @@ -423,6 +428,7 @@ function projectAttempt(value: unknown): RequestLogAttemptDto { 'group_name', 'channel_id', 'credential_id', + 'credential_name', 'operation', 'route_mode', 'upstream_model', @@ -455,6 +461,7 @@ function projectAttempt(value: unknown): RequestLogAttemptDto { record.credential_id === null ? null : projectSafeInteger(record.credential_id, { minimum: 1 }), + credential_name: projectString(record.credential_name, { allowEmpty: true }), operation: record.operation === null ? null : projectEnum(record.operation, operations), route_mode: record.route_mode === null ? null : projectEnum(record.route_mode, routeModes), upstream_model: projectNullableModel(record.upstream_model), @@ -602,6 +609,7 @@ function projectItemRecord(record: Record): RequestLogItemDto { record.credential_id === null ? null : projectSafeInteger(record.credential_id, { minimum: 1 }), + credential_name: projectString(record.credential_name, { allowEmpty: true }), route_mode: record.route_mode === null ? null : projectEnum(record.route_mode, routeModes), pricing_mode: record.pricing_mode === null ? null : projectPricingMode(record.pricing_mode), context_threshold_tokens: diff --git a/web/src/components/config/ProxyConfigEditor.vue b/web/src/components/config/ProxyConfigEditor.vue index 9ce778fc..a96c3065 100644 --- a/web/src/components/config/ProxyConfigEditor.vue +++ b/web/src/components/config/ProxyConfigEditor.vue @@ -6,7 +6,7 @@ import { useI18n } from 'vue-i18n' import type { ProxyConfiguredMode, ProxyMutation, ProxyViewDto } from '@/api/control/types' import { proxyDraftState, proxyPlaceholderURL } from '@/app/resources/proxy' import AppButton from '@/components/ui/AppButton.vue' -import AppSelect from '@/components/ui/AppSelect.vue' +import SegmentedControl from '@/components/ui/SegmentedControl.vue' import AppTextInput from '@/components/ui/AppTextInput.vue' import CompactFieldError from '@/components/ui/CompactFieldError.vue' import IconButton from '@/components/ui/IconButton.vue' @@ -35,6 +35,10 @@ const modeOptions = computed(() => [ { value: 'direct', label: t('common.proxy.mode.direct') }, { value: 'custom', label: t('common.proxy.mode.custom') }, ]) +// 分段控件没有整体 disabled,逐项禁用。 +const segmentedModeOptions = computed(() => + modeOptions.value.map((option) => ({ ...option, disabled: props.disabled || pending.value })), +) const draft = computed(() => proxyDraftState(props.view, mode.value, endpoint.value)) const endpointError = computed(() => mode.value === 'custom' && touched.value && draft.value.invalid @@ -78,6 +82,9 @@ function updateEndpoint(value: string): void { saveFailed.value = false } +// 供卡片徽章行的指示器一次完成“展开 + 进入编辑”。 +defineExpose({ beginEdit }) + async function save(): Promise { touched.value = true saveFailed.value = false @@ -126,13 +133,12 @@ async function save(): Promise {
- { font-size: var(--text-label-xs); } -/* 下拉固定宽度,不随所选模式伸缩。 */ -.proxy-config-editor__mode :deep(.app-select__trigger) { - width: 92px; - min-height: 26px; +.proxy-config-editor__mode { flex: none; - padding-inline: 7px; - font-size: var(--text-label-xs); } .proxy-config-editor__endpoint { @@ -223,7 +224,8 @@ async function save(): Promise { --compact-field-error-indicator-size: 22px; --compact-field-error-indicator-right: 2px; --compact-field-error-input-gap: 4px; - flex: 1 1 120px; + /* basis 归零,地址栏独吞剩余宽度,控件行不折行。 */ + flex: 1 1 0; min-width: 0; } diff --git a/web/src/components/config/ProxyScopeIndicator.vue b/web/src/components/config/ProxyScopeIndicator.vue index 7f0a94e7..134a18f2 100644 --- a/web/src/components/config/ProxyScopeIndicator.vue +++ b/web/src/components/config/ProxyScopeIndicator.vue @@ -6,22 +6,35 @@ import { useI18n } from 'vue-i18n' import type { ProxyViewDto } from '@/api/control/types' import AppTooltip from '@/components/ui/AppTooltip.vue' -const props = defineProps<{ view: ProxyViewDto }>() +const props = withDefaults(defineProps<{ view: ProxyViewDto; clickable?: boolean }>(), { + clickable: false, +}) +const emit = defineEmits<{ activate: [] }>() const { t } = useI18n() // 只在凭据自己配置了代理(未沿用上一级)时提示,继承态不加视觉噪音。 const own = computed(() => props.view.configured_mode !== 'inherit') // 只报类型,具体地址留给展开后的代理面板,避免在列表层面泄露/堆叠细节。 -const tooltip = computed(() => - t('common.proxy.ownTooltip', { - type: t(`common.proxy.mode.${props.view.configured_mode}`), - }), -) +const tooltip = computed(() => { + const type = t(`common.proxy.mode.${props.view.configured_mode}`) + return props.clickable + ? t('common.proxy.ownTooltipClickable', { type }) + : t('common.proxy.ownTooltip', { type }) +})