From d7a7e8a55905c8f3ddb54ce6ca0a6ac43f9dc4a4 Mon Sep 17 00:00:00 2001 From: coso Date: Thu, 16 Jul 2026 13:44:52 +0800 Subject: [PATCH 1/3] fix: normalize empty collection API contracts --- .../internal/controlplane/alert_repository.go | 2 +- .../controlplane/department_repository.go | 2 +- .../governance_policy_repository.go | 2 +- .../controlplane/identity_repository.go | 8 +- .../controlplane/postgres_repository_test.go | 51 +++++ backend/internal/controlplane/repository.go | 28 ++- .../controlplane/risk_block_repository.go | 2 +- backend/internal/plugins/artifact_sink_s3.go | 3 + backend/internal/plugins/catalog_mapper.go | 2 +- backend/internal/plugins/license_service.go | 2 +- .../internal/plugins/package_compatibility.go | 2 +- .../internal/plugins/postgres_repository.go | 8 +- .../internal/plugins/repository_helpers.go | 3 + backend/internal/system/s3_backup.go | 2 +- frontend/src/api/account.ts | 15 +- frontend/src/api/control.test.ts | 64 ++++++ frontend/src/api/control.ts | 214 ++++++++++++------ frontend/src/api/customer.ts | 35 ++- frontend/src/api/modules.test.ts | 37 +++ frontend/src/api/normalizers.ts | 179 ++++++++++++++- frontend/src/api/operator.ts | 15 +- frontend/src/api/platform.ts | 36 ++- frontend/src/api/plugins.ts | 54 +++-- frontend/src/api/settings.ts | 39 +++- frontend/src/api/system.ts | 7 +- .../views/admin/AdminEffectivePricingView.vue | 2 +- 26 files changed, 656 insertions(+), 158 deletions(-) diff --git a/backend/internal/controlplane/alert_repository.go b/backend/internal/controlplane/alert_repository.go index 611e312..c4ccea3 100644 --- a/backend/internal/controlplane/alert_repository.go +++ b/backend/internal/controlplane/alert_repository.go @@ -105,7 +105,7 @@ FROM alert_events` return nil, err } defer rows.Close() - var out []AlertEvent + out := make([]AlertEvent, 0) for rows.Next() { event, err := scanAlertEvent(rows) if err != nil { diff --git a/backend/internal/controlplane/department_repository.go b/backend/internal/controlplane/department_repository.go index 7992c8f..b9ff86b 100644 --- a/backend/internal/controlplane/department_repository.go +++ b/backend/internal/controlplane/department_repository.go @@ -38,7 +38,7 @@ ORDER BY status ASC, code ASC return nil, err } defer rows.Close() - var out []Department + out := make([]Department, 0) for rows.Next() { var department Department if err := rows.Scan(&department.ID, &department.Name, &department.Code, &department.ParentID, &department.CostCenter, &department.MonthlyBudgetCents, &department.Status, &department.CreatedAt, &department.UpdatedAt); err != nil { diff --git a/backend/internal/controlplane/governance_policy_repository.go b/backend/internal/controlplane/governance_policy_repository.go index 1fc9d4e..6bc894d 100644 --- a/backend/internal/controlplane/governance_policy_repository.go +++ b/backend/internal/controlplane/governance_policy_repository.go @@ -40,7 +40,7 @@ ORDER BY status ASC, name ASC return nil, err } defer rows.Close() - var out []GovernancePolicy + out := make([]GovernancePolicy, 0) for rows.Next() { var policy GovernancePolicy var allowlist, denylist string diff --git a/backend/internal/controlplane/identity_repository.go b/backend/internal/controlplane/identity_repository.go index 94a9cce..fa9879a 100644 --- a/backend/internal/controlplane/identity_repository.go +++ b/backend/internal/controlplane/identity_repository.go @@ -33,7 +33,7 @@ func (r *MemoryRepository) SaveWorkspaceUser(_ context.Context, user WorkspaceUs func (r *MemoryRepository) ListAuthIdentities(_ context.Context, userID string) ([]AuthIdentity, error) { r.mu.RLock() defer r.mu.RUnlock() - var out []AuthIdentity + out := make([]AuthIdentity, 0) for _, identity := range r.authIdentities { if identity.UserID == userID { out = append(out, identity) @@ -121,7 +121,7 @@ ORDER BY u.status ASC, u.email ASC return nil, err } defer rows.Close() - var out []WorkspaceUser + out := make([]WorkspaceUser, 0) for rows.Next() { var user WorkspaceUser var recovery string @@ -171,7 +171,7 @@ func (r *PostgresRepository) ListAuthIdentities(ctx context.Context, userID stri return nil, err } defer rows.Close() - var out []AuthIdentity + out := make([]AuthIdentity, 0) for rows.Next() { var identity AuthIdentity if err := rows.Scan(&identity.ID, &identity.UserID, &identity.Issuer, &identity.Subject, &identity.Email, &identity.CreatedAt, &identity.UpdatedAt); err != nil { @@ -223,7 +223,7 @@ ORDER BY user_id ASC, scope_type ASC, scope_id ASC return nil, err } defer rows.Close() - var out []RoleBinding + out := make([]RoleBinding, 0) for rows.Next() { var binding RoleBinding if err := rows.Scan(&binding.ID, &binding.UserID, &binding.Role, &binding.ScopeType, &binding.ScopeID, &binding.CreatedAt, &binding.UpdatedAt); err != nil { diff --git a/backend/internal/controlplane/postgres_repository_test.go b/backend/internal/controlplane/postgres_repository_test.go index 5fd1573..0e76415 100644 --- a/backend/internal/controlplane/postgres_repository_test.go +++ b/backend/internal/controlplane/postgres_repository_test.go @@ -11,6 +11,57 @@ import ( "github.com/astercloud/asterrouter/backend/internal/testutil" ) +func assertEmptyList[T any](t *testing.T, name string, load func() ([]T, error)) { + t.Helper() + items, err := load() + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if items == nil { + t.Fatalf("%s returned a nil slice", name) + } + if len(items) != 0 { + t.Fatalf("%s returned %d items, want 0", name, len(items)) + } +} + +func TestPostgresRepositoryEmptyListContracts(t *testing.T) { + schema := testutil.NewPostgresSchema(t) + ctx := context.Background() + repo, err := NewPostgresRepository(ctx, schema.URL) + if err != nil { + t.Fatalf("NewPostgresRepository(): %v", err) + } + defer repo.Close() + + assertEmptyList(t, "ListDepartments", func() ([]Department, error) { return repo.ListDepartments(ctx) }) + assertEmptyList(t, "ListOrganizationGroups", func() ([]OrganizationGroup, error) { return repo.ListOrganizationGroups(ctx) }) + assertEmptyList(t, "ListGovernancePolicies", func() ([]GovernancePolicy, error) { return repo.ListGovernancePolicies(ctx) }) + assertEmptyList(t, "ListWorkspaceUsers", func() ([]WorkspaceUser, error) { return repo.ListWorkspaceUsers(ctx) }) + assertEmptyList(t, "ListRoleBindings", func() ([]RoleBinding, error) { return repo.ListRoleBindings(ctx) }) + assertEmptyList(t, "ListProviders", func() ([]ProviderConnection, error) { return repo.ListProviders(ctx) }) + assertEmptyList(t, "ListLatestProviderHealthChecks", func() ([]ProviderHealthCheck, error) { return repo.ListLatestProviderHealthChecks(ctx) }) + assertEmptyList(t, "ListRoutingGroups", func() ([]RoutingGroup, error) { return repo.ListRoutingGroups(ctx) }) + assertEmptyList(t, "ListProviderAccounts", func() ([]ProviderAccount, error) { return repo.ListProviderAccounts(ctx) }) + assertEmptyList(t, "ListLatestProviderAccountHealthChecks", func() ([]ProviderAccountHealthCheck, error) { return repo.ListLatestProviderAccountHealthChecks(ctx) }) + assertEmptyList(t, "ListGatewayModels", func() ([]GatewayModel, error) { return repo.ListGatewayModels(ctx) }) + assertEmptyList(t, "ListModelRoutes", func() ([]ModelRoute, error) { return repo.ListModelRoutes(ctx) }) + assertEmptyList(t, "ListModelPricings", func() ([]ModelPricing, error) { return repo.ListModelPricings(ctx) }) + assertEmptyList(t, "ListAPIKeys", func() ([]APIKeyRecord, error) { return repo.ListAPIKeys(ctx) }) + assertEmptyList(t, "ListUsageRecords", func() ([]UsageRecord, error) { return repo.ListUsageRecords(ctx, 100) }) + assertEmptyList(t, "ListGatewayTraces", func() ([]GatewayTrace, error) { return repo.ListGatewayTraces(ctx, 100) }) + assertEmptyList(t, "ListAuditLogs", func() ([]AuditLog, error) { return repo.ListAuditLogs(ctx, 100) }) +} + +func TestListParsersNormalizeJSONNull(t *testing.T) { + if values := parseStringList("null"); values == nil || len(values) != 0 { + t.Fatalf("parseStringList(null) = %#v, want []", values) + } + if rules := parseTempUnschedulableRules("null"); rules == nil || len(rules) != 0 { + t.Fatalf("parseTempUnschedulableRules(null) = %#v, want []", rules) + } +} + func TestPostgresRepositoryPersistsCoreRecordsAcrossRestart(t *testing.T) { schema := testutil.NewPostgresSchema(t) ctx := context.Background() diff --git a/backend/internal/controlplane/repository.go b/backend/internal/controlplane/repository.go index 93d1596..33f63b4 100644 --- a/backend/internal/controlplane/repository.go +++ b/backend/internal/controlplane/repository.go @@ -2839,7 +2839,7 @@ ORDER BY priority ASC, name ASC return nil, err } defer rows.Close() - var out []ProviderConnection + out := make([]ProviderConnection, 0) for rows.Next() { var provider ProviderConnection var models string @@ -2882,7 +2882,7 @@ ORDER BY provider_id, checked_at DESC return nil, err } defer rows.Close() - var out []ProviderHealthCheck + out := make([]ProviderHealthCheck, 0) for rows.Next() { var check ProviderHealthCheck var models string @@ -2922,7 +2922,7 @@ ORDER BY sort_order ASC, name ASC } defer rows.Close() - var out []RoutingGroup + out := make([]RoutingGroup, 0) for rows.Next() { var group RoutingGroup if err := rows.Scan( @@ -3066,7 +3066,7 @@ ORDER BY priority ASC, name ASC } defer rows.Close() - var out []ProviderAccount + out := make([]ProviderAccount, 0) for rows.Next() { var account ProviderAccount var models, groupIDs, tempUnschedulableRules string @@ -3169,7 +3169,7 @@ func (r *PostgresRepository) ListProviderAccountModels(ctx context.Context, acco return nil, err } defer rows.Close() - var out []ProviderAccountModel + out := make([]ProviderAccountModel, 0) for rows.Next() { var model ProviderAccountModel var lastSeenAt sql.NullTime @@ -3217,7 +3217,7 @@ ORDER BY account_id, checked_at DESC return nil, err } defer rows.Close() - var out []ProviderAccountHealthCheck + out := make([]ProviderAccountHealthCheck, 0) for rows.Next() { var check ProviderAccountHealthCheck var models string @@ -3250,7 +3250,7 @@ ORDER BY model ASC return nil, err } defer rows.Close() - var out []ModelPricing + out := make([]ModelPricing, 0) for rows.Next() { var pricing ModelPricing if err := rows.Scan(&pricing.ID, &pricing.Model, &pricing.Currency, &pricing.InputPriceCentsPer1MTokens, &pricing.OutputPriceCentsPer1MTokens, &pricing.Status, &pricing.CreatedAt, &pricing.UpdatedAt); err != nil { @@ -3359,7 +3359,7 @@ ORDER BY created_at DESC`) return nil, err } defer rows.Close() - var out []APIKeyRecord + out := make([]APIKeyRecord, 0) for rows.Next() { key, err := scanAPIKey(rows) if err != nil { @@ -3566,7 +3566,7 @@ FROM usage_records` return nil, err } defer rows.Close() - var out []UsageRecord + out := make([]UsageRecord, 0) for rows.Next() { var record UsageRecord var usageDimensionsJSON []byte @@ -3717,7 +3717,7 @@ FROM gateway_traces` return nil, err } defer rows.Close() - var out []GatewayTrace + out := make([]GatewayTrace, 0) for rows.Next() { var trace GatewayTrace if err := rows.Scan(&trace.ID, &trace.OperationID, &trace.AttemptID, &trace.RequestFingerprint, &trace.APIKeyID, &trace.APIFingerprint, &trace.ProfileScope, &trace.PlatformTenantID, &trace.PlatformTenantName, &trace.GatewayPrincipalID, &trace.GatewayPrincipalName, &trace.ExternalAuthIntegrationID, &trace.ExternalSubjectReference, &trace.Model, &trace.Stream, &trace.MessageCount, &trace.ProviderID, &trace.ProviderAccountID, &trace.GatewayModelID, &trace.RouteID, &trace.RouteGroup, &trace.UpstreamModel, &trace.RouteSource, &trace.RouteReason, &trace.PolicyID, &trace.PolicyName, &trace.PolicySource, &trace.PolicyVersion, &trace.PolicySnapshot, &trace.Status, &trace.HTTPStatus, &trace.ErrorType, &trace.LatencyMS, &trace.InputTokens, &trace.OutputTokens, &trace.RequestSummary, &trace.ResponseSummary, &trace.RouteAttempts, &trace.CreatedAt); err != nil { @@ -3779,7 +3779,7 @@ FROM audit_logs` return nil, err } defer rows.Close() - var out []AuditLog + out := make([]AuditLog, 0) for rows.Next() { var event AuditLog if err := rows.Scan(&event.ID, &event.Actor, &event.Action, &event.ResourceType, &event.ResourceID, &event.Summary, &event.ProfileScope, &event.PlatformTenantID, &event.PlatformTenantName, &event.GatewayPrincipalID, &event.GatewayPrincipalName, &event.ExternalAuthIntegrationID, &event.ExternalSubjectReference, &event.CreatedAt); err != nil { @@ -3846,6 +3846,9 @@ func parseStringList(value string) []string { if err := json.Unmarshal([]byte(value), &out); err != nil { return []string{} } + if out == nil { + return []string{} + } return out } @@ -3862,5 +3865,8 @@ func parseTempUnschedulableRules(value string) []ProviderAccountTempUnschedulabl if err := json.Unmarshal([]byte(value), &out); err != nil { return []ProviderAccountTempUnschedulableRule{} } + if out == nil { + return []ProviderAccountTempUnschedulableRule{} + } return out } diff --git a/backend/internal/controlplane/risk_block_repository.go b/backend/internal/controlplane/risk_block_repository.go index 27e6819..8bf660f 100644 --- a/backend/internal/controlplane/risk_block_repository.go +++ b/backend/internal/controlplane/risk_block_repository.go @@ -27,7 +27,7 @@ func (r *PostgresRepository) ListActiveGatewayRiskBlocks(ctx context.Context, no return nil, err } defer rows.Close() - var out []GatewayRiskBlock + out := make([]GatewayRiskBlock, 0) for rows.Next() { var block GatewayRiskBlock if err := rows.Scan(&block.APIKeyID, &block.RuleID, &block.Reason, &block.ExpiresAt, &block.CreatedAt); err != nil { diff --git a/backend/internal/plugins/artifact_sink_s3.go b/backend/internal/plugins/artifact_sink_s3.go index d10fd6d..0f2748b 100644 --- a/backend/internal/plugins/artifact_sink_s3.go +++ b/backend/internal/plugins/artifact_sink_s3.go @@ -458,6 +458,9 @@ func artifactSinkDestinationRecords(config configRecord) ([]artifactSinkDestinat if err := json.Unmarshal([]byte(raw), &destinations); err != nil { return nil, fmt.Errorf("%w: artifact sink destinations are invalid", ErrPluginConfigInvalid) } + if destinations == nil { + destinations = []artifactSinkDestinationRecord{} + } seen := map[string]struct{}{} for _, destination := range destinations { if err := validateArtifactSinkDestination(destination); err != nil { diff --git a/backend/internal/plugins/catalog_mapper.go b/backend/internal/plugins/catalog_mapper.go index f5528d8..ba50300 100644 --- a/backend/internal/plugins/catalog_mapper.go +++ b/backend/internal/plugins/catalog_mapper.go @@ -46,7 +46,7 @@ func mapRemoteCatalogPlugins(index remoteCatalogIndex, now time.Time) []Plugin { } func mapRemoteCatalogPackages(index remoteCatalogIndex, now time.Time) []packageRecord { - var out []packageRecord + out := make([]packageRecord, 0) for _, item := range index.Plugins { slug := sanitizeCatalogSlug(item.Slug) if slug == "" { diff --git a/backend/internal/plugins/license_service.go b/backend/internal/plugins/license_service.go index ce123ef..037fa19 100644 --- a/backend/internal/plugins/license_service.go +++ b/backend/internal/plugins/license_service.go @@ -430,7 +430,7 @@ func licenseAllowsResource(record licenseRecord, entitlementType string, resourc } func entitlementsFromRecord(record licenseRecord) []Entitlement { - var out []Entitlement + out := make([]Entitlement, 0) if err := json.Unmarshal([]byte(defaultString(record.EntitlementsJSON, "[]")), &out); err != nil { return []Entitlement{} } diff --git a/backend/internal/plugins/package_compatibility.go b/backend/internal/plugins/package_compatibility.go index 69891d8..dee6f3b 100644 --- a/backend/internal/plugins/package_compatibility.go +++ b/backend/internal/plugins/package_compatibility.go @@ -48,7 +48,7 @@ func (s *Service) packageRevocation(ctx context.Context, record packageRecord) ( func (s *Service) revokedAffectedVersions(ctx context.Context, record packageRecord) ([]affectedVersionRecord, error) { keys := []string{record.PluginSlug, record.PluginPublicID, record.PluginID} seen := map[string]struct{}{} - var out []affectedVersionRecord + out := make([]affectedVersionRecord, 0) for _, key := range keys { key = strings.TrimSpace(key) if key == "" { diff --git a/backend/internal/plugins/postgres_repository.go b/backend/internal/plugins/postgres_repository.go index 642f180..85c98e2 100644 --- a/backend/internal/plugins/postgres_repository.go +++ b/backend/internal/plugins/postgres_repository.go @@ -293,7 +293,7 @@ ORDER BY category ASC, tier ASC, name ASC return nil, err } defer rows.Close() - var out []Plugin + out := make([]Plugin, 0) for rows.Next() { plugin, err := scanPlugin(rows) if err != nil { @@ -398,7 +398,7 @@ FROM notification_deliveries` return nil, err } defer rows.Close() - var out []DeliveryAttempt + out := make([]DeliveryAttempt, 0) for rows.Next() { attempt, err := scanDeliveryAttempt(rows) if err != nil { @@ -486,7 +486,7 @@ ORDER BY version DESC, os ASC, arch ASC return nil, err } defer rows.Close() - var out []packageRecord + out := make([]packageRecord, 0) for rows.Next() { record, err := scanPackageRecord(rows) if err != nil { @@ -576,7 +576,7 @@ ORDER BY created_at ASC return nil, err } defer rows.Close() - var out []affectedVersionRecord + out := make([]affectedVersionRecord, 0) for rows.Next() { record, err := scanAffectedVersionRecord(rows) if err != nil { diff --git a/backend/internal/plugins/repository_helpers.go b/backend/internal/plugins/repository_helpers.go index ab3e4dd..5ef915f 100644 --- a/backend/internal/plugins/repository_helpers.go +++ b/backend/internal/plugins/repository_helpers.go @@ -250,6 +250,9 @@ func parseStringList(value string) []string { if err := json.Unmarshal([]byte(value), &out); err != nil { return []string{} } + if out == nil { + return []string{} + } return out } diff --git a/backend/internal/system/s3_backup.go b/backend/internal/system/s3_backup.go index 742ee92..b27a1de 100644 --- a/backend/internal/system/s3_backup.go +++ b/backend/internal/system/s3_backup.go @@ -81,7 +81,7 @@ func (s *S3BackupStore) List(ctx context.Context) ([]S3BackupObject, error) { if prefix != "" { prefix += "/" } - var out []S3BackupObject + out := make([]S3BackupObject, 0) paginator := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{Bucket: aws.String(s.config.Bucket), Prefix: aws.String(prefix)}) for paginator.HasMorePages() { page, err := paginator.NextPage(ctx) diff --git a/frontend/src/api/account.ts b/frontend/src/api/account.ts index 5ebd7bd..92713f7 100644 --- a/frontend/src/api/account.ts +++ b/frontend/src/api/account.ts @@ -1,12 +1,21 @@ import { apiClient } from './client' +import { listOrEmpty } from './normalizers' import type { AccountProfile, AccountSecurityUpdate, TOTPSetup } from '@/types' +function normalizeAccountProfile(profile: AccountProfile): AccountProfile { + return { + ...profile, + auth_identities: listOrEmpty(profile.auth_identities), + login_methods: listOrEmpty(profile.login_methods) + } +} + export async function getAccountProfile(): Promise { - return (await apiClient.get('/account/profile')).data + return normalizeAccountProfile((await apiClient.get('/account/profile')).data) } export async function updateAccountProfile(displayName: string, avatarDataURL: string): Promise { - return (await apiClient.put('/account/profile', { display_name: displayName, avatar_data_url: avatarDataURL })).data + return normalizeAccountProfile((await apiClient.put('/account/profile', { display_name: displayName, avatar_data_url: avatarDataURL })).data) } export async function changeAccountPassword(currentPassword: string, newPassword: string): Promise { @@ -34,7 +43,7 @@ export async function revokeOtherAccountSessions(): Promise { - return (await apiClient.delete(`/account/identities/${encodeURIComponent(provider)}`)).data + return normalizeAccountProfile((await apiClient.delete(`/account/identities/${encodeURIComponent(provider)}`)).data) } export async function beginAccountIdentityBinding(provider: string, returnPath: string): Promise { diff --git a/frontend/src/api/control.test.ts b/frontend/src/api/control.test.ts index 74b01ec..d1163d0 100644 --- a/frontend/src/api/control.test.ts +++ b/frontend/src/api/control.test.ts @@ -45,6 +45,70 @@ describe('control API contracts', () => { expect(await control.getGovernancePolicies()).toEqual([]) }) + it('normalizes nullable collections used by every admin list page', async () => { + const loads: Array<() => Promise> = [ + control.getDepartments, + control.getOrganizationGroups, + control.getWorkspaceUsers, + control.getRoleBindings, + control.getModelPricings, + control.getAuditLogs, + control.getAlerts, + control.getGatewayTraces, + control.getExportJobs + ] + + for (const load of loads) { + client.get.mockResolvedValueOnce({ data: null }) + expect(await load()).toEqual([]) + } + }) + + it('normalizes nested collections consumed directly by admin and portal views', async () => { + client.get.mockResolvedValueOnce({ data: [{ id: 'group-1', member_ids: null }] }) + expect(await control.getOrganizationGroups()).toEqual([{ id: 'group-1', member_ids: [] }]) + + client.get.mockResolvedValueOnce({ data: [{ id: 'policy-1', model_allowlist: null, model_denylist: null }] }) + expect(await control.getGovernancePolicies()).toEqual([{ id: 'policy-1', model_allowlist: [], model_denylist: [] }]) + + client.get.mockResolvedValueOnce({ data: [{ id: 'key-1', scopes: null, model_allowlist: null, allowed_modalities: null, allowed_operations: null, allowed_cidrs: null }] }) + expect(await control.getAPIKeys()).toEqual([{ + id: 'key-1', scopes: [], model_allowlist: [], allowed_modalities: [], allowed_operations: [], allowed_cidrs: [] + }]) + + client.get.mockResolvedValueOnce({ + data: { + api_keys: [{ id: 'key-1', scopes: null, model_allowlist: null, allowed_modalities: null, allowed_operations: null, allowed_cidrs: null }], + usage: { by_model: null, recent: null }, + recent_traces: null, + alerts: null, + models: null + } + }) + expect(await control.getPortalWorkspace()).toMatchObject({ + api_keys: [{ model_allowlist: [] }], + usage: { by_model: [], recent: [] }, + recent_traces: [], + alerts: [], + models: [] + }) + + client.get.mockResolvedValueOnce({ data: { rows: [{ reason_codes: null, provider_billing_routing_health: { reason_codes: null } }], decisions: [{ reason_codes: null, last_evaluation_reason_codes: null }] } }) + expect(await control.getEffectivePricingReport()).toMatchObject({ + rows: [{ reason_codes: [], provider_billing_routing_health: { reason_codes: [] } }], + decisions: [{ reason_codes: [], last_evaluation_reason_codes: [] }] + }) + + client.post.mockResolvedValueOnce({ data: { usage_aggregates: null, warnings: null } }) + expect(await control.inspectProviderBillingSource('account-1')).toMatchObject({ usage_aggregates: [], warnings: [] }) + + client.get.mockResolvedValueOnce({ data: [{ id: 'source-1', warnings: null, routing_health: { reason_codes: null } }] }) + expect(await control.getProviderBillingSources()).toEqual([{ id: 'source-1', warnings: [], routing_health: { reason_codes: [] } }]) + + client.get.mockResolvedValueOnce({ data: { candidates: null } }) + expect(await control.getAPIKeyPolicyExplanation('key-1')).toMatchObject({ candidates: [] }) + }) + it('normalizes nullable provider mutation responses', async () => { const provider = { id: 'provider-1', models: null } client.post.mockResolvedValueOnce({ data: provider }) diff --git a/frontend/src/api/control.ts b/frontend/src/api/control.ts index 08ca8ad..db536a8 100644 --- a/frontend/src/api/control.ts +++ b/frontend/src/api/control.ts @@ -1,5 +1,33 @@ import { apiClient } from './client' -import { listOrEmpty, normalizeDashboard, type DashboardPayload, type NullableList } from './normalizers' +import { + listOrEmpty, + normalizeAIJobAdminDetail, + normalizeAPIKeyCreateResponse, + normalizeAPIKeyRecord, + normalizeArtifactAdminDetail, + normalizeDashboard, + normalizeEffectivePricingDecision, + normalizeEffectivePricingDecisionEvaluation, + normalizeEffectivePricingReport, + normalizeGatewayPolicyExplanation, + normalizeProviderBillingSource, + normalizeProviderBillingSourceInspection, + normalizeProviderBillingSyncResult, + normalizeUsageReport, + stringListOrEmpty, + type AIJobAdminDetailPayload, + type APIKeyCreateResponsePayload, + type APIKeyRecordPayload, + type ArtifactAdminDetailPayload, + type DashboardPayload, + type EffectivePricingDecisionEvaluationPayload, + type EffectivePricingDecisionPayload, + type EffectivePricingReportPayload, + type ProviderBillingSourceInspectionPayload, + type ProviderBillingSourcePayload, + type ProviderBillingSyncResultPayload, + type UsageReportPayload +} from './normalizers' import type { APIKeyCreateRequest, APIKeyCreateResponse, @@ -92,9 +120,18 @@ type ProviderAccountPayload = Omit & { models?: string[] | null } - -function stringListOrEmpty(value: NullableList): string[] { - return listOrEmpty(value).filter((item): item is string => typeof item === 'string') +type OrganizationGroupPayload = Omit & { member_ids?: string[] | null } +type GovernancePolicyPayload = Omit & { + model_allowlist?: string[] | null + model_denylist?: string[] | null +} +type GatewaySimulationPayload = Omit & { candidates?: GatewaySimulation['candidates'] | null } +type CostAllocationReportPayload = Omit & { rows?: CostAllocationReport['rows'] | null } +type ProviderBillingSourceEvidencePayload = Omit & { + source: ProviderBillingSourcePayload + runs?: ProviderBillingSourceEvidence['runs'] | null + balances?: ProviderBillingSourceEvidence['balances'] | null + aggregates?: ProviderBillingSourceEvidence['aggregates'] | null } function normalizeProvider(provider: ProviderConnectionPayload): ProviderConnection { @@ -128,6 +165,18 @@ function normalizeProviderAccountHealthCheck(check: ProviderAccountHealthCheckPa } } +function normalizeOrganizationGroup(group: OrganizationGroupPayload): OrganizationGroup { + return { ...group, member_ids: stringListOrEmpty(group.member_ids) } +} + +function normalizeGovernancePolicy(policy: GovernancePolicyPayload): GovernancePolicy { + return { + ...policy, + model_allowlist: stringListOrEmpty(policy.model_allowlist), + model_denylist: stringListOrEmpty(policy.model_denylist) + } +} + export async function getDashboard(): Promise { const response = await apiClient.get('/admin/dashboard') return normalizeDashboard(response.data) @@ -159,20 +208,21 @@ export async function checkProvider(id: string): Promise { } export async function getDepartments(): Promise { - const response = await apiClient.get('/admin/departments') - return response.data + const response = await apiClient.get('/admin/departments') + return listOrEmpty(response.data) } export async function getOrganizationGroups(): Promise { - return (await apiClient.get('/admin/organization-groups')).data + const response = await apiClient.get('/admin/organization-groups') + return listOrEmpty(response.data).map(normalizeOrganizationGroup) } export async function createOrganizationGroup(payload: OrganizationGroupRequest): Promise { - return (await apiClient.post('/admin/organization-groups', payload)).data + return normalizeOrganizationGroup((await apiClient.post('/admin/organization-groups', payload)).data) } export async function updateOrganizationGroup(id: string, payload: OrganizationGroupRequest): Promise { - return (await apiClient.put(`/admin/organization-groups/${id}`, payload)).data + return normalizeOrganizationGroup((await apiClient.put(`/admin/organization-groups/${id}`, payload)).data) } export async function deleteOrganizationGroup(id: string): Promise { @@ -190,23 +240,23 @@ export async function updateDepartment(id: string, payload: DepartmentRequest): } export async function getGovernancePolicies(): Promise { - const response = await apiClient.get('/admin/policies') - return listOrEmpty(response.data) + const response = await apiClient.get('/admin/policies') + return listOrEmpty(response.data).map(normalizeGovernancePolicy) } export async function createGovernancePolicy(payload: GovernancePolicyRequest): Promise { - const response = await apiClient.post('/admin/policies', payload) - return response.data + const response = await apiClient.post('/admin/policies', payload) + return normalizeGovernancePolicy(response.data) } export async function updateGovernancePolicy(id: string, payload: GovernancePolicyRequest): Promise { - const response = await apiClient.put(`/admin/policies/${id}`, payload) - return response.data + const response = await apiClient.put(`/admin/policies/${id}`, payload) + return normalizeGovernancePolicy(response.data) } export async function getWorkspaceUsers(): Promise { - const response = await apiClient.get('/admin/users') - return response.data + const response = await apiClient.get('/admin/users') + return listOrEmpty(response.data) } export async function createWorkspaceUser(payload: WorkspaceUserRequest): Promise { @@ -220,8 +270,8 @@ export async function updateWorkspaceUser(id: string, payload: WorkspaceUserRequ } export async function getRoleBindings(): Promise { - const response = await apiClient.get('/admin/role-bindings') - return response.data + const response = await apiClient.get('/admin/role-bindings') + return listOrEmpty(response.data) } export async function createRoleBinding(payload: RoleBindingRequest): Promise { @@ -345,8 +395,8 @@ export async function createModelRoute(payload: ModelRouteRequest): Promise { - const response = await apiClient.post('/admin/model-routes/bulk', payload) - return response.data + const response = await apiClient.post & { routes?: ModelRoute[] | null }>('/admin/model-routes/bulk', payload) + return { ...response.data, routes: listOrEmpty(response.data.routes) } } export async function updateModelRoute(id: string, payload: ModelRouteRequest): Promise { @@ -359,16 +409,16 @@ export async function deleteModelRoute(id: string): Promise { } export async function simulateGatewayRouting(model: string, estimatedTokens: number): Promise { - const response = await apiClient.post('/admin/gateway-simulator', { + const response = await apiClient.post('/admin/gateway-simulator', { model, estimated_tokens: estimatedTokens }) - return response.data + return { ...response.data, candidates: listOrEmpty(response.data.candidates) } } export async function getModelPricings(): Promise { - const response = await apiClient.get('/admin/model-pricings') - return response.data + const response = await apiClient.get('/admin/model-pricings') + return listOrEmpty(response.data) } export async function createModelPricing(payload: ModelPricingRequest): Promise { @@ -382,8 +432,8 @@ export async function updateModelPricing(id: string, payload: ModelPricingReques } export async function getEffectivePricingReport(params?: { model?: string; protocol?: string; window_hours?: number }): Promise { - const response = await apiClient.get('/admin/effective-pricing/report', { params }) - return response.data + const response = await apiClient.get('/admin/effective-pricing/report', { params }) + return normalizeEffectivePricingReport(response.data) } export async function getEffectivePricingPolicy(): Promise { @@ -422,31 +472,37 @@ export async function createProviderBillingLine(payload: ProviderBillingLineRequ } export async function inspectProviderBillingSource(providerAccountID: string, adapterID = 'auto'): Promise { - const response = await apiClient.post('/admin/provider-billing-sources/inspect', { + const response = await apiClient.post('/admin/provider-billing-sources/inspect', { provider_account_id: providerAccountID, adapter_id: adapterID }) - return response.data + return normalizeProviderBillingSourceInspection(response.data) } export async function getProviderBillingSources(): Promise { - const response = await apiClient.get('/admin/provider-billing-sources') - return listOrEmpty(response.data) + const response = await apiClient.get('/admin/provider-billing-sources') + return listOrEmpty(response.data).map(normalizeProviderBillingSource) } export async function updateProviderBillingSource(payload: ProviderBillingSourceRequest): Promise { - const response = await apiClient.put('/admin/provider-billing-sources', payload) - return response.data + const response = await apiClient.put('/admin/provider-billing-sources', payload) + return normalizeProviderBillingSource(response.data) } export async function syncProviderBillingSource(id: string): Promise { - const response = await apiClient.post(`/admin/provider-billing-sources/${id}/sync`) - return response.data + const response = await apiClient.post(`/admin/provider-billing-sources/${id}/sync`) + return normalizeProviderBillingSyncResult(response.data) } export async function getProviderBillingSourceEvidence(id: string, limit = 100): Promise { - const response = await apiClient.get(`/admin/provider-billing-sources/${id}/evidence`, { params: { limit } }) - return response.data + const response = await apiClient.get(`/admin/provider-billing-sources/${id}/evidence`, { params: { limit } }) + return { + ...response.data, + source: normalizeProviderBillingSource(response.data.source), + runs: listOrEmpty(response.data.runs), + balances: listOrEmpty(response.data.balances), + aggregates: listOrEmpty(response.data.aggregates) + } } export async function getProviderCacheCapabilities(): Promise { @@ -470,48 +526,48 @@ export async function runProviderCacheProbe(payload: CacheProbeRequest): Promise } export async function getEffectivePricingDecisions(): Promise { - const response = await apiClient.get('/admin/effective-pricing/decisions') - return listOrEmpty(response.data) + const response = await apiClient.get('/admin/effective-pricing/decisions') + return listOrEmpty(response.data).map(normalizeEffectivePricingDecision) } export async function getEffectivePricingDecisionEvaluations(id: string, limit = 100): Promise { - const response = await apiClient.get(`/admin/effective-pricing/decisions/${id}/evaluations`, { params: { limit } }) - return listOrEmpty(response.data) + const response = await apiClient.get(`/admin/effective-pricing/decisions/${id}/evaluations`, { params: { limit } }) + return listOrEmpty(response.data).map(normalizeEffectivePricingDecisionEvaluation) } export async function evaluateEffectivePricingDecision(payload: EffectivePricingDecisionEvaluationRequest): Promise { - const response = await apiClient.post('/admin/effective-pricing/decisions/evaluate', payload) - return response.data + const response = await apiClient.post('/admin/effective-pricing/decisions/evaluate', payload) + return normalizeEffectivePricingDecision(response.data) } export async function actOnEffectivePricingDecision(id: string, action: string, canaryPercent = 0): Promise { - const response = await apiClient.post(`/admin/effective-pricing/decisions/${id}/action`, { action, canary_percent: canaryPercent }) - return response.data + const response = await apiClient.post(`/admin/effective-pricing/decisions/${id}/action`, { action, canary_percent: canaryPercent }) + return normalizeEffectivePricingDecision(response.data) } export async function getAPIKeys(): Promise { const response = await apiClient.get('/admin/api-keys') - return listOrEmpty(response.data) + return listOrEmpty(response.data).map((record) => normalizeAPIKeyRecord(record as APIKeyRecordPayload)) } export async function getAPIKeyPolicyExplanation(id: string): Promise { const response = await apiClient.get(`/admin/api-keys/${id}/policy-explanation`) - return response.data + return normalizeGatewayPolicyExplanation(response.data) } export async function createAPIKey(payload: APIKeyCreateRequest): Promise { - const response = await apiClient.post('/admin/api-keys', payload) - return response.data + const response = await apiClient.post('/admin/api-keys', payload) + return normalizeAPIKeyCreateResponse(response.data) } export async function updateAPIKey(id: string, payload: APIKeyUpdateRequest): Promise { - const response = await apiClient.put(`/admin/api-keys/${id}`, payload) - return response.data + const response = await apiClient.put(`/admin/api-keys/${id}`, payload) + return normalizeAPIKeyRecord(response.data) } export async function rotateAPIKey(id: string, gracePeriodSeconds = 0): Promise { - const response = await apiClient.post(`/admin/api-keys/${id}/rotate`, { grace_period_seconds: gracePeriodSeconds }) - return response.data + const response = await apiClient.post(`/admin/api-keys/${id}/rotate`, { grace_period_seconds: gracePeriodSeconds }) + return normalizeAPIKeyCreateResponse(response.data) } export async function disableAPIKey(id: string): Promise { @@ -519,8 +575,8 @@ export async function disableAPIKey(id: string): Promise { } export async function getAuditLogs(params?: RecordListQuery): Promise { - const response = await apiClient.get('/admin/audit-logs', { params }) - return response.data + const response = await apiClient.get('/admin/audit-logs', { params }) + return listOrEmpty(response.data) } export async function getAuditLogSummary(params?: RecordListQuery): Promise { @@ -529,8 +585,8 @@ export async function getAuditLogSummary(params?: RecordListQuery): Promise { - const response = await apiClient.get('/admin/alerts', { params }) - return response.data + const response = await apiClient.get('/admin/alerts', { params }) + return listOrEmpty(response.data) } export async function getAlertSummary(params?: RecordListQuery): Promise { @@ -553,8 +609,8 @@ export async function exportAuditLogsCSV(params?: RecordListQuery): Promise { - const response = await apiClient.get('/admin/usage', { params }) - return response.data + const response = await apiClient.get('/admin/usage', { params }) + return normalizeUsageReport(response.data) } export async function exportUsageCSV(params?: RecordListQuery): Promise { @@ -562,8 +618,8 @@ export async function exportUsageCSV(params?: RecordListQuery): Promise { } export async function getCostAllocationReport(params?: RecordListQuery): Promise { - const response = await apiClient.get('/admin/cost-allocation', { params }) - return response.data + const response = await apiClient.get('/admin/cost-allocation', { params }) + return { ...response.data, rows: listOrEmpty(response.data.rows) } } export async function exportCostAllocationCSV(params?: RecordListQuery): Promise { @@ -571,8 +627,8 @@ export async function exportCostAllocationCSV(params?: RecordListQuery): Promise } export async function getGatewayTraces(params?: RecordListQuery): Promise { - const response = await apiClient.get('/admin/gateway-traces', { params }) - return response.data + const response = await apiClient.get('/admin/gateway-traces', { params }) + return listOrEmpty(response.data) } export async function getGatewayTraceSummary(params?: RecordListQuery): Promise { @@ -591,8 +647,8 @@ export async function getArtifactSummary(params?: ArtifactListQuery): Promise { - const response = await apiClient.get(`/admin/artifacts/${id}`) - return response.data + const response = await apiClient.get(`/admin/artifacts/${id}`) + return normalizeArtifactAdminDetail(response.data) } export async function getArtifactRuntimes(): Promise { @@ -621,8 +677,8 @@ export async function getAIJobRuntime(): Promise { } export async function getAIJob(id: string): Promise { - const response = await apiClient.get(`/admin/ai-jobs/${id}`) - return response.data + const response = await apiClient.get(`/admin/ai-jobs/${id}`) + return normalizeAIJobAdminDetail(response.data) } export async function cancelAIJob(id: string): Promise { @@ -645,8 +701,8 @@ export async function createExportJob(kind: ExportJobKind, params?: RecordListQu } export async function getExportJobs(limit = 50): Promise { - const response = await apiClient.get('/admin/export-jobs', { params: { limit } }) - return response.data + const response = await apiClient.get('/admin/export-jobs', { params: { limit } }) + return listOrEmpty(response.data) } export async function getExportJob(id: string): Promise { @@ -659,18 +715,26 @@ export async function downloadExportJob(job: ExportJob): Promise { } export async function getPortalWorkspace(): Promise { - const response = await apiClient.get(`${selfServiceAPIBase()}/workspace`) - return response.data + const response = await apiClient.get(`${selfServiceAPIBase()}/workspace`) + const payload = response.data ?? {} as PortalWorkspace + return { + ...payload, + api_keys: listOrEmpty(payload.api_keys).map((record) => normalizeAPIKeyRecord(record as APIKeyRecordPayload)), + usage: normalizeUsageReport(payload.usage as UsageReportPayload), + recent_traces: listOrEmpty(payload.recent_traces), + alerts: listOrEmpty(payload.alerts), + models: stringListOrEmpty(payload.models) + } } export async function createPortalAPIKey(payload: APIKeyCreateRequest): Promise { - const response = await apiClient.post(`${selfServiceAPIBase()}/api-keys`, payload) - return response.data + const response = await apiClient.post(`${selfServiceAPIBase()}/api-keys`, payload) + return normalizeAPIKeyCreateResponse(response.data) } export async function rotatePortalAPIKey(id: string, gracePeriodSeconds = 0): Promise { - const response = await apiClient.post(`${selfServiceAPIBase()}/api-keys/${id}/rotate`, { grace_period_seconds: gracePeriodSeconds }) - return response.data + const response = await apiClient.post(`${selfServiceAPIBase()}/api-keys/${id}/rotate`, { grace_period_seconds: gracePeriodSeconds }) + return normalizeAPIKeyCreateResponse(response.data) } export async function disablePortalAPIKey(id: string): Promise { diff --git a/frontend/src/api/customer.ts b/frontend/src/api/customer.ts index 889ea20..9d1f82b 100644 --- a/frontend/src/api/customer.ts +++ b/frontend/src/api/customer.ts @@ -1,4 +1,5 @@ import { apiClient } from '@/api/client' +import { listOrEmpty } from '@/api/normalizers' export interface CustomerPaymentChannel { id: 'wechat' | 'alipay' @@ -90,19 +91,40 @@ export interface CustomerNotificationList { offset: number } +function normalizeBillingOverview(overview: CustomerBillingOverview | null | undefined): CustomerBillingOverview { + const payload = overview ?? {} as CustomerBillingOverview + return { + ...payload, + recharge_options: listOrEmpty(payload.recharge_options), + payment_channels: listOrEmpty(payload.payment_channels), + vouchers: listOrEmpty(payload.vouchers) + } +} + +function normalizeNotificationSettings(settings: CustomerNotificationSettings | null | undefined): CustomerNotificationSettings { + const payload = settings ?? {} as CustomerNotificationSettings + return { + ...payload, + preferences: listOrEmpty(payload.preferences).map((preference) => ({ + ...preference, + channels: listOrEmpty(preference.channels) + })) + } +} + export async function getCustomerBilling(): Promise { const response = await apiClient.get('/customer/billing') - return response.data + return normalizeBillingOverview(response.data) } export async function getCustomerBillingEntries(query: CustomerBillingQuery = {}): Promise { const response = await apiClient.get('/customer/billing/entries', { params: query }) - return response.data + return { ...response.data, items: listOrEmpty(response.data.items) } } export async function redeemCustomerCode(code: string): Promise { const response = await apiClient.post('/customer/billing/redeem', { code }) - return response.data + return { ...response.data, overview: normalizeBillingOverview(response.data?.overview) } } export async function createCustomerRechargeOrder(payload: { @@ -127,15 +149,16 @@ export async function downloadCustomerBillingCSV(query: CustomerBillingQuery = { } export async function getCustomerNotificationSettings(): Promise { - return (await apiClient.get('/customer/notification-settings')).data + return normalizeNotificationSettings((await apiClient.get('/customer/notification-settings')).data) } export async function updateCustomerNotificationSettings(preferences: CustomerNotificationPreference[]): Promise { - return (await apiClient.put('/customer/notification-settings', { preferences })).data + return normalizeNotificationSettings((await apiClient.put('/customer/notification-settings', { preferences })).data) } export async function getCustomerNotifications(limit = 20, offset = 0): Promise { - return (await apiClient.get('/customer/notifications', { params: { limit, offset } })).data + const response = (await apiClient.get('/customer/notifications', { params: { limit, offset } })).data + return { ...response, items: listOrEmpty(response.items) } } export async function markCustomerNotificationRead(id: string): Promise { diff --git a/frontend/src/api/modules.test.ts b/frontend/src/api/modules.test.ts index 59f2a53..4cfbb04 100644 --- a/frontend/src/api/modules.test.ts +++ b/frontend/src/api/modules.test.ts @@ -176,6 +176,43 @@ describe('API module contracts', () => { expect(await platform.getPlatformUsageDeliveries('sink-1')).toEqual([]) }) + it('normalizes nullable collections across settings, plugins, operator, system, customer, and account APIs', async () => { + for (const load of [settings.getDefaultEmailTemplates, settings.getLocales]) { + client.get.mockResolvedValueOnce({ data: null }) + expect(await load()).toEqual([]) + } + + for (const load of [ + () => plugins.getArtifactSinkDestinations('plugin-1'), + () => plugins.getPluginAPITokens(), + () => plugins.getOfficialFeedStatuses(), + () => plugins.getOfficialFeedSyncRuns(), + () => plugins.getPluginDeliveries('plugin-1'), + () => plugins.getPluginPackages('plugin-1'), + () => operator.listOperatorResource('customers'), + operator.getOperatorBalances, + operator.getOperatorCustomerKeys, + operator.getOperatorRiskBlocks, + system.listSystemBackups, + system.listS3Backups + ]) { + client.get.mockResolvedValueOnce({ data: null }) + expect(await load()).toEqual([]) + } + + client.get.mockResolvedValueOnce({ data: { summary: {}, plugins: null } }) + expect(await plugins.getPluginCatalog()).toMatchObject({ plugins: [] }) + + client.get.mockResolvedValueOnce({ data: { recharge_options: null, payment_channels: null, vouchers: null } }) + expect(await customer.getCustomerBilling()).toMatchObject({ recharge_options: [], payment_channels: [], vouchers: [] }) + + client.get.mockResolvedValueOnce({ data: { preferences: [{ event_type: 'balance_low', channels: null }] } }) + expect(await customer.getCustomerNotificationSettings()).toMatchObject({ preferences: [{ channels: [] }] }) + + client.get.mockResolvedValueOnce({ data: { auth_identities: null, login_methods: null } }) + expect(await account.getAccountProfile()).toMatchObject({ auth_identities: [], login_methods: [] }) + }) + it('uses customer billing and notification endpoint contracts', async () => { await customer.getCustomerBilling() expect(client.get).toHaveBeenLastCalledWith('/customer/billing') diff --git a/frontend/src/api/normalizers.ts b/frontend/src/api/normalizers.ts index 872fb62..9efc169 100644 --- a/frontend/src/api/normalizers.ts +++ b/frontend/src/api/normalizers.ts @@ -1,15 +1,87 @@ -import type { Dashboard } from '@/types' +import type { + AIAttemptAdminRecord, + AIJobAdminDetail, + AIJobEvent, + APIKeyCreateResponse, + APIKeyRecord, + ArtifactAdminDetail, + ArtifactAdminRecord, + ArtifactEvent, + Dashboard, + EffectivePricingDecision, + EffectivePricingDecisionEvaluation, + EffectivePricingReport, + EffectivePricingReportRow, + GatewayPolicyExplanation, + ProviderBillingRoutingHealth, + ProviderBillingSource, + ProviderBillingSourceInspection, + ProviderBillingSyncResult, + UsageModelSummary, + UsageRecord, + UsageReport +} from '@/types' export type NullableList = T[] | null | undefined export type DashboardPayload = Omit & { models?: string[] | null recent_audit?: Dashboard['recent_audit'] | null } +export type APIKeyRecordPayload = Omit & { + scopes?: string[] | null + model_allowlist?: string[] | null + allowed_modalities?: string[] | null + allowed_operations?: string[] | null + allowed_cidrs?: string[] | null +} +export type APIKeyCreateResponsePayload = Omit & { record: APIKeyRecordPayload } +export type UsageReportPayload = Omit & { + by_model?: UsageModelSummary[] | null + recent?: UsageRecord[] | null +} +export type ArtifactAdminDetailPayload = Omit & { events?: ArtifactEvent[] | null } +export type AIJobAdminDetailPayload = Omit & { + attempts?: AIAttemptAdminRecord[] | null + events?: AIJobEvent[] | null + artifacts?: ArtifactAdminRecord[] | null +} +export type EffectivePricingDecisionPayload = Omit & { + reason_codes?: string[] | null + last_evaluation_reason_codes?: string[] | null +} +export type EffectivePricingDecisionEvaluationPayload = Omit & { + reason_codes?: string[] | null +} +export type EffectivePricingReportRowPayload = Omit & { + reason_codes?: string[] | null + provider_billing_routing_health?: ProviderBillingRoutingHealthPayload | null +} +export type EffectivePricingReportPayload = Omit & { + rows?: EffectivePricingReportRowPayload[] | null + decisions?: EffectivePricingDecisionPayload[] | null +} +export type ProviderBillingRoutingHealthPayload = Omit & { reason_codes?: string[] | null } +export type ProviderBillingSourcePayload = Omit & { + warnings?: string[] | null + routing_health?: ProviderBillingRoutingHealthPayload | null +} +export type ProviderBillingSourceInspectionPayload = Omit & { + usage_aggregates?: ProviderBillingSourceInspection['usage_aggregates'] | null + warnings?: string[] | null +} +export type ProviderBillingSyncResultPayload = Omit & { + source: ProviderBillingSourcePayload + aggregates?: ProviderBillingSyncResult['aggregates'] | null +} export function listOrEmpty(value: NullableList): T[] { return Array.isArray(value) ? value : [] } +export function stringListOrEmpty(value: NullableList): string[] { + return listOrEmpty(value).filter((item): item is string => typeof item === 'string') +} + export function normalizeDashboard(value: DashboardPayload): Dashboard { return { ...value, @@ -17,3 +89,108 @@ export function normalizeDashboard(value: DashboardPayload): Dashboard { recent_audit: listOrEmpty(value.recent_audit) } } + +export function normalizeAPIKeyRecord(value: APIKeyRecordPayload): APIKeyRecord { + const payload = value ?? {} as APIKeyRecordPayload + return { + ...payload, + scopes: stringListOrEmpty(payload.scopes), + model_allowlist: stringListOrEmpty(payload.model_allowlist), + allowed_modalities: stringListOrEmpty(payload.allowed_modalities), + allowed_operations: stringListOrEmpty(payload.allowed_operations), + allowed_cidrs: stringListOrEmpty(payload.allowed_cidrs) + } +} + +export function normalizeAPIKeyCreateResponse(value: APIKeyCreateResponsePayload): APIKeyCreateResponse { + const payload = value ?? {} as APIKeyCreateResponsePayload + return { ...payload, record: normalizeAPIKeyRecord(payload.record) } +} + +export function normalizeUsageReport(value: UsageReportPayload): UsageReport { + const payload = value ?? {} as UsageReportPayload + return { + ...payload, + by_model: listOrEmpty(payload.by_model), + recent: listOrEmpty(payload.recent) + } +} + +export function normalizeArtifactAdminDetail(value: ArtifactAdminDetailPayload): ArtifactAdminDetail { + const payload = value ?? {} as ArtifactAdminDetailPayload + return { ...payload, events: listOrEmpty(payload.events) } +} + +export function normalizeAIJobAdminDetail(value: AIJobAdminDetailPayload): AIJobAdminDetail { + const payload = value ?? {} as AIJobAdminDetailPayload + return { + ...payload, + attempts: listOrEmpty(payload.attempts), + events: listOrEmpty(payload.events), + artifacts: listOrEmpty(payload.artifacts) + } +} + +export function normalizeEffectivePricingDecision(value: EffectivePricingDecisionPayload): EffectivePricingDecision { + const payload = value ?? {} as EffectivePricingDecisionPayload + return { + ...payload, + reason_codes: stringListOrEmpty(payload.reason_codes), + last_evaluation_reason_codes: stringListOrEmpty(payload.last_evaluation_reason_codes) + } +} + +export function normalizeEffectivePricingDecisionEvaluation(value: EffectivePricingDecisionEvaluationPayload): EffectivePricingDecisionEvaluation { + const payload = value ?? {} as EffectivePricingDecisionEvaluationPayload + return { ...payload, reason_codes: stringListOrEmpty(payload.reason_codes) } +} + +export function normalizeProviderBillingRoutingHealth(value: ProviderBillingRoutingHealthPayload | null | undefined): ProviderBillingRoutingHealth | undefined { + if (!value) return undefined + return { ...value, reason_codes: stringListOrEmpty(value.reason_codes) } +} + +export function normalizeProviderBillingSource(value: ProviderBillingSourcePayload): ProviderBillingSource { + const payload = value ?? {} as ProviderBillingSourcePayload + return { + ...payload, + warnings: stringListOrEmpty(payload.warnings), + routing_health: normalizeProviderBillingRoutingHealth(payload.routing_health) + } +} + +export function normalizeEffectivePricingReport(value: EffectivePricingReportPayload): EffectivePricingReport { + const payload = value ?? {} as EffectivePricingReportPayload + return { + ...payload, + rows: listOrEmpty(payload.rows).map((row) => ({ + ...row, + reason_codes: stringListOrEmpty(row.reason_codes), + provider_billing_routing_health: normalizeProviderBillingRoutingHealth(row.provider_billing_routing_health) + })), + decisions: listOrEmpty(payload.decisions).map(normalizeEffectivePricingDecision) + } +} + +export function normalizeProviderBillingSourceInspection(value: ProviderBillingSourceInspectionPayload): ProviderBillingSourceInspection { + const payload = value ?? {} as ProviderBillingSourceInspectionPayload + return { + ...payload, + usage_aggregates: listOrEmpty(payload.usage_aggregates), + warnings: stringListOrEmpty(payload.warnings) + } +} + +export function normalizeProviderBillingSyncResult(value: ProviderBillingSyncResultPayload): ProviderBillingSyncResult { + const payload = value ?? {} as ProviderBillingSyncResultPayload + return { + ...payload, + source: normalizeProviderBillingSource(payload.source), + aggregates: listOrEmpty(payload.aggregates) + } +} + +export function normalizeGatewayPolicyExplanation(value: GatewayPolicyExplanation | null | undefined): GatewayPolicyExplanation { + const payload = value ?? {} as GatewayPolicyExplanation + return { ...payload, candidates: listOrEmpty(payload.candidates) } +} diff --git a/frontend/src/api/operator.ts b/frontend/src/api/operator.ts index 233a723..d331bc3 100644 --- a/frontend/src/api/operator.ts +++ b/frontend/src/api/operator.ts @@ -1,20 +1,21 @@ import { apiClient } from './client' +import { listOrEmpty, normalizeAPIKeyCreateResponse, normalizeAPIKeyRecord, normalizeUsageReport, type APIKeyCreateResponsePayload, type APIKeyRecordPayload, type UsageReportPayload } from './normalizers' import type { APIKeyCreateRequest, APIKeyCreateResponse, APIKeyRecord, GatewayRiskBlock, OperatorBalanceEntry, OperatorCustomer, OperatorCustomerGroup, OperatorDashboard, OperatorNotice, OperatorPlan, OperatorPricingRule, OperatorRiskRule, UsageReport } from '@/types' export type OperatorResource = 'customer-groups'|'customers'|'plans'|'pricing-rules'|'risk-rules'|'notices' export type OperatorEntity = OperatorCustomerGroup|OperatorCustomer|OperatorPlan|OperatorPricingRule|OperatorRiskRule|OperatorNotice export async function getOperatorDashboard():Promise{return (await apiClient.get('/operator/dashboard')).data} -export async function listOperatorResource(resource:OperatorResource):Promise{return (await apiClient.get(`/operator/${resource}`)).data || []} +export async function listOperatorResource(resource:OperatorResource):Promise{return listOrEmpty((await apiClient.get(`/operator/${resource}`)).data)} export async function createOperatorResource(resource:OperatorResource,payload:Record):Promise{return (await apiClient.post(`/operator/${resource}`,payload)).data} export async function updateOperatorResource(resource:OperatorResource,id:string,payload:Record):Promise{return (await apiClient.put(`/operator/${resource}/${id}`,payload)).data} export async function deleteOperatorResource(resource:OperatorResource,id:string):Promise{await apiClient.delete(`/operator/${resource}/${id}`)} -export async function getOperatorBalances():Promise{return (await apiClient.get('/operator/balance-entries')).data || []} +export async function getOperatorBalances():Promise{return listOrEmpty((await apiClient.get('/operator/balance-entries')).data)} export async function createOperatorBalance(payload:Record):Promise{return (await apiClient.post('/operator/balance-entries',payload)).data} -export async function getOperatorCustomerKeys():Promise{return (await apiClient.get('/operator/customer-keys')).data || []} -export async function rotateOperatorCustomerKey(id:string,gracePeriodSeconds=0):Promise{return (await apiClient.post(`/operator/customer-keys/${id}/rotate`,{grace_period_seconds:gracePeriodSeconds})).data} +export async function getOperatorCustomerKeys():Promise{return listOrEmpty((await apiClient.get('/operator/customer-keys')).data).map(normalizeAPIKeyRecord)} +export async function rotateOperatorCustomerKey(id:string,gracePeriodSeconds=0):Promise{return normalizeAPIKeyCreateResponse((await apiClient.post(`/operator/customer-keys/${id}/rotate`,{grace_period_seconds:gracePeriodSeconds})).data)} export async function disableOperatorCustomerKey(id:string):Promise{await apiClient.post(`/operator/customer-keys/${id}/disable`)} -export async function createOperatorCustomerKey(customerID:string,payload:APIKeyCreateRequest):Promise{return (await apiClient.post(`/operator/customers/${customerID}/keys`,payload)).data} -export async function getOperatorUsage(params?:Record):Promise{return (await apiClient.get('/operator/usage',{params})).data} -export async function getOperatorRiskBlocks():Promise{return (await apiClient.get('/operator/risk-blocks')).data || []} +export async function createOperatorCustomerKey(customerID:string,payload:APIKeyCreateRequest):Promise{return normalizeAPIKeyCreateResponse((await apiClient.post(`/operator/customers/${customerID}/keys`,payload)).data)} +export async function getOperatorUsage(params?:Record):Promise{return normalizeUsageReport((await apiClient.get('/operator/usage',{params})).data)} +export async function getOperatorRiskBlocks():Promise{return listOrEmpty((await apiClient.get('/operator/risk-blocks')).data)} export async function clearOperatorRiskBlock(apiKeyID:string):Promise{await apiClient.delete(`/operator/risk-blocks/${encodeURIComponent(apiKeyID)}`)} diff --git a/frontend/src/api/platform.ts b/frontend/src/api/platform.ts index dc76f28..68ef52b 100644 --- a/frontend/src/api/platform.ts +++ b/frontend/src/api/platform.ts @@ -1,5 +1,18 @@ import { apiClient } from './client' -import { listOrEmpty, normalizeDashboard, type DashboardPayload } from './normalizers' +import { + listOrEmpty, + normalizeAIJobAdminDetail, + normalizeAPIKeyCreateResponse, + normalizeAPIKeyRecord, + normalizeArtifactAdminDetail, + normalizeDashboard, + stringListOrEmpty, + type AIJobAdminDetailPayload, + type APIKeyCreateResponsePayload, + type APIKeyRecordPayload, + type ArtifactAdminDetailPayload, + type DashboardPayload +} from './normalizers' import type { APIKeyCreateRequest, APIKeyCreateResponse, @@ -50,7 +63,8 @@ export async function getPlatformAIJobRuntime(): Promise { } export async function getPlatformAIJob(id: string): Promise { - return (await apiClient.get(`/platform/ai-jobs/${encodeURIComponent(id)}`)).data + const response = await apiClient.get(`/platform/ai-jobs/${encodeURIComponent(id)}`) + return normalizeAIJobAdminDetail(response.data) } export async function cancelPlatformAIJob(id: string): Promise { @@ -72,7 +86,8 @@ export async function getPlatformArtifactSummary(params?: ArtifactListQuery): Pr } export async function getPlatformArtifact(id: string): Promise { - return (await apiClient.get(`/platform/artifacts/${encodeURIComponent(id)}`)).data + const response = await apiClient.get(`/platform/artifacts/${encodeURIComponent(id)}`) + return normalizeArtifactAdminDetail(response.data) } export async function getPlatformArtifactRuntimes(): Promise { @@ -84,19 +99,19 @@ export async function retryPlatformArtifactDelivery(id: string): Promise { - return listOrEmpty((await apiClient.get('/platform/api-keys')).data) + return listOrEmpty((await apiClient.get('/platform/api-keys')).data).map(normalizeAPIKeyRecord) } export async function createPlatformAPIKey(payload: APIKeyCreateRequest): Promise { - return (await apiClient.post('/platform/api-keys', payload)).data + return normalizeAPIKeyCreateResponse((await apiClient.post('/platform/api-keys', payload)).data) } export async function updatePlatformAPIKey(id: string, payload: APIKeyUpdateRequest): Promise { - return (await apiClient.put(`/platform/api-keys/${encodeURIComponent(id)}`, payload)).data + return normalizeAPIKeyRecord((await apiClient.put(`/platform/api-keys/${encodeURIComponent(id)}`, payload)).data) } export async function rotatePlatformAPIKey(id: string, gracePeriodSeconds = 0): Promise { - return (await apiClient.post(`/platform/api-keys/${encodeURIComponent(id)}/rotate`, { grace_period_seconds: gracePeriodSeconds })).data + return normalizeAPIKeyCreateResponse((await apiClient.post(`/platform/api-keys/${encodeURIComponent(id)}/rotate`, { grace_period_seconds: gracePeriodSeconds })).data) } export async function disablePlatformAPIKey(id: string): Promise { @@ -129,14 +144,17 @@ export async function updateGatewayPrincipal(id: string, payload: GatewayPrincip export async function getExternalAuthIntegrations(): Promise { return listOrEmpty((await apiClient.get('/platform/external-auth-integrations')).data) + .map((integration) => ({ ...integration, model_allowlist: stringListOrEmpty(integration.model_allowlist) })) } export async function createExternalAuthIntegration(payload: ExternalAuthIntegrationRequest): Promise { - return (await apiClient.post('/platform/external-auth-integrations', payload)).data + const response = (await apiClient.post('/platform/external-auth-integrations', payload)).data + return { ...response, record: { ...response.record, model_allowlist: stringListOrEmpty(response.record?.model_allowlist) } } } export async function updateExternalAuthIntegration(id: string, payload: ExternalAuthIntegrationRequest): Promise { - return (await apiClient.put(`/platform/external-auth-integrations/${encodeURIComponent(id)}`, payload)).data + const response = (await apiClient.put(`/platform/external-auth-integrations/${encodeURIComponent(id)}`, payload)).data + return { ...response, model_allowlist: stringListOrEmpty(response.model_allowlist) } } export async function rotateExternalAuthIntegrationSecret(id: string): Promise { diff --git a/frontend/src/api/plugins.ts b/frontend/src/api/plugins.ts index b42d61d..81922e5 100644 --- a/frontend/src/api/plugins.ts +++ b/frontend/src/api/plugins.ts @@ -1,4 +1,5 @@ import { apiClient } from './client' +import { listOrEmpty, stringListOrEmpty } from './normalizers' import type { ArtifactSinkDestination, ArtifactSinkDestinationRequest, @@ -28,19 +29,32 @@ import type { SidecarRuntimeStatus } from '@/types' +type PluginPayload = Omit & { + surfaces?: string[] | null + packages?: PluginPackage[] | null +} + +function normalizePlugin(plugin: PluginPayload): Plugin { + return { + ...plugin, + surfaces: stringListOrEmpty(plugin.surfaces), + packages: listOrEmpty(plugin.packages) + } +} + export async function getPluginCatalog(): Promise { - const response = await apiClient.get('/admin/plugins') - return response.data + const response = await apiClient.get & { plugins?: PluginPayload[] | null }>('/admin/plugins') + return { ...response.data, plugins: listOrEmpty(response.data.plugins).map(normalizePlugin) } } export async function enablePlugin(id: string): Promise { - const response = await apiClient.post(`/admin/plugins/${encodeURIComponent(id)}/enable`) - return response.data + const response = await apiClient.post(`/admin/plugins/${encodeURIComponent(id)}/enable`) + return normalizePlugin(response.data) } export async function disablePlugin(id: string): Promise { - const response = await apiClient.post(`/admin/plugins/${encodeURIComponent(id)}/disable`) - return response.data + const response = await apiClient.post(`/admin/plugins/${encodeURIComponent(id)}/disable`) + return normalizePlugin(response.data) } export async function getPluginConfig(id: string): Promise { @@ -54,8 +68,8 @@ export async function updatePluginConfig(id: string, payload: PluginConfigReques } export async function getArtifactSinkDestinations(pluginID: string): Promise { - const response = await apiClient.get(`/admin/plugins/${encodeURIComponent(pluginID)}/artifact-sinks`) - return response.data + const response = await apiClient.get(`/admin/plugins/${encodeURIComponent(pluginID)}/artifact-sinks`) + return listOrEmpty(response.data) } export async function upsertArtifactSinkDestination(pluginID: string, sinkID: string, payload: ArtifactSinkDestinationRequest): Promise { @@ -71,8 +85,12 @@ export async function deleteArtifactSinkDestination(pluginID: string, sinkID: st } export async function getPluginAPITokens(pluginID = ''): Promise { - const response = await apiClient.get('/admin/plugins/api-tokens', { params: pluginID ? { plugin_id: pluginID } : undefined }) - return response.data + const response = await apiClient.get('/admin/plugins/api-tokens', { params: pluginID ? { plugin_id: pluginID } : undefined }) + return listOrEmpty(response.data).map((token) => ({ + ...token, + scopes: stringListOrEmpty(token.scopes), + surfaces: stringListOrEmpty(token.surfaces) + })) } export async function createPluginAPIToken(payload: PluginAPITokenCreateRequest): Promise { @@ -91,8 +109,8 @@ export async function getOfficialFeedClientInfo(): Promise { - const response = await apiClient.get('/admin/plugins/feeds', { params: serviceKey ? { service_key: serviceKey } : undefined }) - return response.data + const response = await apiClient.get('/admin/plugins/feeds', { params: serviceKey ? { service_key: serviceKey } : undefined }) + return listOrEmpty(response.data) } export async function importOfficialFeed(payload: OfficialFeedImportRequest): Promise { @@ -106,15 +124,15 @@ export async function syncOfficialFeed(serviceKey: string): Promise { - const response = await apiClient.get('/admin/plugins/feeds/sync-runs', { + const response = await apiClient.get('/admin/plugins/feeds/sync-runs', { params: { ...(serviceKey ? { service_key: serviceKey } : {}), limit } }) - return response.data + return listOrEmpty(response.data) } export async function getPluginDeliveries(id: string, params?: { limit?: number; offset?: number; status?: string; alert_id?: string }): Promise { - const response = await apiClient.get(`/admin/plugins/${encodeURIComponent(id)}/deliveries`, { params }) - return response.data + const response = await apiClient.get(`/admin/plugins/${encodeURIComponent(id)}/deliveries`, { params }) + return listOrEmpty(response.data) } export async function getOfficialCatalogStatus(): Promise { @@ -148,8 +166,8 @@ export async function importOfficialLicense(payload: LicenseImportRequest): Prom } export async function getPluginPackages(id: string): Promise { - const response = await apiClient.get(`/admin/plugins/${encodeURIComponent(id)}/packages`) - return response.data + const response = await apiClient.get(`/admin/plugins/${encodeURIComponent(id)}/packages`) + return listOrEmpty(response.data) } export async function downloadPluginPackage(id: string, packageID: string, payload: PluginPackageDownloadRequest = {}): Promise { diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index 81f41f5..d5e2dd0 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -1,9 +1,32 @@ import { apiClient } from './client' +import { listOrEmpty, stringListOrEmpty } from './normalizers' import type { AdminSettings, EmailTemplate, LegalDocument, LocaleInfo, PublicSettings, RetentionCleanupResult } from '@/types' +function normalizePublicSettings(settings: T): T { + return { + ...settings, + enabled_profiles: stringListOrEmpty(settings.enabled_profiles), + enabled_locales: stringListOrEmpty(settings.enabled_locales), + legal_documents: listOrEmpty(settings.legal_documents), + custom_endpoints: listOrEmpty(settings.custom_endpoints), + custom_menu_items: listOrEmpty(settings.custom_menu_items) + } +} + +function normalizeAdminSettings(settings: AdminSettings): AdminSettings { + return { + ...normalizePublicSettings(settings), + runtime_restart_reasons: stringListOrEmpty(settings.runtime_restart_reasons), + allowed_email_domains: stringListOrEmpty(settings.allowed_email_domains), + invitation_codes: stringListOrEmpty(settings.invitation_codes), + email_templates: listOrEmpty(settings.email_templates), + page_size_options: listOrEmpty(settings.page_size_options) + } +} + export async function getPublicSettings(): Promise { const response = await apiClient.get('/settings/public') - return response.data + return normalizePublicSettings(response.data) } export async function getLegalDocument(slug: string): Promise { @@ -13,12 +36,12 @@ export async function getLegalDocument(slug: string): Promise { export async function getAdminSettings(): Promise { const response = await apiClient.get('/admin/settings') - return response.data + return normalizeAdminSettings(response.data) } export async function updateAdminSettings(payload: AdminSettings): Promise { const response = await apiClient.put('/admin/settings', payload) - return response.data + return normalizeAdminSettings(response.data) } export async function runRetentionCleanup(): Promise { @@ -30,8 +53,8 @@ export async function testSMTP(recipient: string): Promise { } export async function getDefaultEmailTemplates(): Promise { - const response = await apiClient.get('/admin/settings/email-templates/defaults') - return response.data + const response = await apiClient.get('/admin/settings/email-templates/defaults') + return listOrEmpty(response.data) } export async function previewEmailTemplate(subject: string, html: string): Promise<{subject: string; html: string}> { @@ -47,10 +70,10 @@ export async function applySetupProfile(profile: string): Promise('/setup/profiles', { profile }) - return response.data + return normalizePublicSettings(response.data) } export async function getLocales(): Promise { - const response = await apiClient.get('/i18n/locales') - return response.data + const response = await apiClient.get('/i18n/locales') + return listOrEmpty(response.data) } diff --git a/frontend/src/api/system.ts b/frontend/src/api/system.ts index 1664c87..47a2314 100644 --- a/frontend/src/api/system.ts +++ b/frontend/src/api/system.ts @@ -1,4 +1,5 @@ import { apiClient } from './client' +import { listOrEmpty } from './normalizers' import type { S3BackupObject, SystemApplyResult, SystemArchiveInfo, SystemRestoreResult, SystemUpdateInfo } from '@/types' export interface SystemProfileBundle { @@ -37,8 +38,8 @@ export async function restartSystem(): Promise { } export async function listSystemBackups(): Promise { - const response = await apiClient.get('/admin/system/backups') - return response.data || [] + const response = await apiClient.get('/admin/system/backups') + return listOrEmpty(response.data) } export async function createSystemBackup(): Promise { @@ -51,7 +52,7 @@ export async function testBackupS3(): Promise { } export async function listS3Backups(): Promise { - return (await apiClient.get('/admin/system/backups/s3')).data || [] + return listOrEmpty((await apiClient.get('/admin/system/backups/s3')).data) } export async function restoreS3Backup(key: string): Promise { diff --git a/frontend/src/views/admin/AdminEffectivePricingView.vue b/frontend/src/views/admin/AdminEffectivePricingView.vue index 3dea46f..f77f2e8 100644 --- a/frontend/src/views/admin/AdminEffectivePricingView.vue +++ b/frontend/src/views/admin/AdminEffectivePricingView.vue @@ -645,7 +645,7 @@ onMounted(load) {{ t('effectivePricing.hardBlocked') }}{{ billingSourceEvidence.source.routing_health.hard_blocked ? t('common.yes') : t('common.no') }} {{ t('effectivePricing.economicSwitchEligible') }}{{ billingSourceEvidence.source.routing_health.economic_switch_eligible ? t('common.yes') : t('common.no') }} {{ t('effectivePricing.evidenceObservedAt') }}{{ formatDate(billingSourceEvidence.source.routing_health.evidence_observed_at) }} -

{{ t('effectivePricing.routingReasons') }}: {{ billingSourceEvidence.source.routing_health.reason_codes.map(billingHealthReasonLabel).join(' · ') }}

+

{{ t('effectivePricing.routingReasons') }}: {{ billingSourceEvidence.source.routing_health.reason_codes.map(billingHealthReasonLabel).join(' · ') }}

{{ t('effectivePricing.syncHistory') }}

{{ t('effectivePricing.sourceStatus') }}{{ t('effectivePricing.syncTrigger') }}{{ t('effectivePricing.adapter') }}{{ t('effectivePricing.errorCode') }}{{ t('effectivePricing.startedAt') }}{{ t('effectivePricing.finishedAt') }}
{{ run.status }}{{ run.trigger }}{{ run.adapter_id }}{{ run.error_code || '-' }}{{ formatDate(run.started_at) }}{{ formatDate(run.finished_at) }}
{{ t('effectivePricing.noSyncHistory') }}

{{ t('effectivePricing.balanceHistory') }}

{{ t('effectivePricing.balanceCapability') }}{{ t('usage.cost') }}{{ t('effectivePricing.observedAt') }}{{ t('effectivePricing.evidenceHash') }}
{{ balanceKindLabel(balance.kind) }}{{ balance.unlimited ? t('effectivePricing.unlimited') : formatMoneyMicros(balance.amount_micros, balance.currency) }}{{ formatDate(balance.observed_at) }}{{ balance.evidence_hash.slice(0, 16) }}
From b1cf3a303712824f13a58ec93bb79d809d2f67c0 Mon Sep 17 00:00:00 2001 From: coso Date: Thu, 16 Jul 2026 13:45:45 +0800 Subject: [PATCH 2/3] docs: add pricing and model operation roadmaps --- docs/product-positioning.md | 8 +- docs/roadmap/effectivepricing/README.md | 374 +++++++++++++++ .../data-model-and-metrics.md | 425 +++++++++++++++++ .../effectivepricing/implementation-plan.md | 417 +++++++++++++++++ docs/roadmap/effectivepricing/prototype.html | 423 +++++++++++++++++ docs/roadmap/effectivepricing/ui-prototype.md | 443 ++++++++++++++++++ docs/roadmap/models/README.md | 414 ++++++++++++++++ docs/roadmap/models/UPDATE_GUIDE.md | 296 ++++++++++++ docs/test/v1/IMPLEMENTATION_STATUS.md | 99 ++-- docs/test/v1/README.md | 2 +- .../v1/performance-baseline.ubuntu-24.04.json | 22 +- 11 files changed, 2863 insertions(+), 60 deletions(-) create mode 100644 docs/roadmap/effectivepricing/README.md create mode 100644 docs/roadmap/effectivepricing/data-model-and-metrics.md create mode 100644 docs/roadmap/effectivepricing/implementation-plan.md create mode 100644 docs/roadmap/effectivepricing/prototype.html create mode 100644 docs/roadmap/effectivepricing/ui-prototype.md create mode 100644 docs/roadmap/models/README.md create mode 100644 docs/roadmap/models/UPDATE_GUIDE.md diff --git a/docs/product-positioning.md b/docs/product-positioning.md index f9e9a30..3c3923d 100644 --- a/docs/product-positioning.md +++ b/docs/product-positioning.md @@ -24,7 +24,7 @@ AsterRouter 是面向企业团队、AI API 平台和客户产品的 AI Access Ga - **更省钱:** 在策略、健康和容量约束内比较合格线路,逐步实现价格插件、成本感知调度和可验证节省。 - **两种对外接入:** 可以直接签发 AsterRouter Gateway API Key,也可以保留 SaaS/OEM 的用户、Session 和权益体系,只委托 AI 访问上下文。 - **企业治理:** 按 Tenant、用户、部门、Group、Key Principal、模型和外部 Subject 管理权限、预算、用量与审计。 -- **灵活交付:** 支持自主部署、私有化交付和托管运维,从单机低成本运行平滑扩展到 Kubernetes。 +- **灵活交付:** 支持自主部署、私有化交付和托管运维;当前以低成本单机运行,Kubernetes 弹性部署属于后续路线图。 - **统一 Core:** 协议、Credential Source 和 Provider 可以扩展,但 Policy、候选规划、账号调度、Usage、Billing 和 Audit 只有一套事实源。 ## 商业场景 @@ -53,7 +53,7 @@ AsterRouter 是面向企业团队、AI API 平台和客户产品的 AI Access Ga `platform` 是第四个独立部署角色,不是 Enterprise 的功能页,也不是 Relay Operator 的另一种 Customer。虽然 Platform 和 Relay 都可使用 API Key,选择的依据是业务所有权:需要余额、套餐、分配和风控运营时选择 Relay;需要把 AI 能力嵌入已有产品、同时不接管该产品用户体系时选择 Platform。Platform 的用量回传只投递可幂等、脱敏的计量事实;产品订单、订阅、余额与用户档案始终留在所属业务系统。 -首次运行只启用一个部署角色。交互安装会显示初始后台以及该角色包含和排除的业务范围,且不预选任何角色;无人值守部署优先使用 `ASTER_DEPLOYMENT_ROLE`,旧的相同 `ASTER_PROFILES` / `ASTER_DEFAULT_PROFILE` 值仍兼容。该选择在新生产实例中不可切换,避免 Provider、Credential、Usage 和 Audit 跨业务模型混用;需要另一种形态时部署独立实例。已有多 Profile 实例保持兼容运行,但不允许改变 Profile 配置。未来只有在端到端 `profile_scope`、Provider Sharing Binding 和租户隔离落地后,才会提供受控多 Profile 迁移。完整规则见 [V4 部署角色与安装分流](./roadmap/v4/profile-bundles-and-installation.md)。 +首次运行只启用一个部署角色。交互安装会显示初始后台以及该角色包含和排除的业务范围,且不预选任何角色;无人值守部署优先使用 `ASTER_DEPLOYMENT_ROLE`,旧的相同 `ASTER_PROFILES` / `ASTER_DEFAULT_PROFILE` 值仍兼容作为首次启动配置。系统管理员可以在设置中切换活动角色,普通实例始终只开放一个角色,旧角色数据保留但入口隐藏;Demo 实例可以同时开放全部形态。这样切换不会删除 Provider、Credential、Usage 和 Audit 数据,也不会让多个业务模型同时成为活动入口。 ## 当前正式接入范围 @@ -88,7 +88,7 @@ AsterRouter 不实现以下能力: - 飞书(中国大陆)/ Lark(国际版)、钉钉企业登录与自动入职,OIDC 作为通用企业 SSO 兼容入口。 - GitHub、Google 作为已验证邮箱的辅助登录源;本地安全登录保留为企业救援入口。 - 企业账户支持多身份记录、安全解绑和持久化会话撤销;身份变更、改密与人员禁用必须即时收回旧令牌权限。 -- Operator 仅用于企业内部使用方、额度策略、成本规则和额度流水治理;不接受月费、充值、退款或订阅售卖业务。 +- Relay Operator 管理 Customer、Plan、余额分配、兑换、账单视图和风险运营;真实支付渠道、订单、退款、税务与订阅合同仍由外部业务系统或独立插件负责,不能进入 Gateway Core。Enterprise 不复用这些中转商业对象。 - 用户、部门、Group 和资源级 RBAC。 - Workspace Key 生命周期、模型白名单、限流、预算和过期策略。 - User/Customer/Service Key 基础类型、Bearer 鉴权、Hash/Fingerprint、禁用、立即轮换和用量归属。 @@ -98,7 +98,7 @@ AsterRouter 不实现以下能力: 员工 Portal 只展示本人可见的 Key、模型、额度和用量,不暴露上游账号、渠道或运营信息。 -后续路线图会在同一 Core 上增加 API Key Principal 的细粒度 Scope 与分布式并发、平台委托鉴权、Responses/Anthropic/Gemini、多模态 Job/Artifact、价格插件、成本感知调度、PostgreSQL + Redis 多模态基础设施,以及可选 Kubernetes 弹性部署。未交付能力不能作为当前产品承诺。 +后续路线图会在同一 Core 上增加 API Key Principal 的细粒度 Scope 与分布式并发、OIDC Introspection/撤销事件与其他平台鉴权适配、Responses/Anthropic/Gemini、多模态 Job/Artifact、价格插件、成本感知调度、PostgreSQL + Redis 多模态基础设施,以及可选 Kubernetes 弹性部署。HMAC 与 RS256 JWT/JWKS 平台委托鉴权已经交付;未交付能力不能作为当前产品承诺。 ## 产品原则 diff --git a/docs/roadmap/effectivepricing/README.md b/docs/roadmap/effectivepricing/README.md new file mode 100644 index 0000000..ea950ba --- /dev/null +++ b/docs/roadmap/effectivepricing/README.md @@ -0,0 +1,374 @@ +# 第三方 API 有效价格与缓存感知路由 + +> 状态:MVP、连续窗口自动提升/回滚和管理端证据闭环已实现;首个 `sub2api-compatible` 账单源 Adapter 已支持 API Key 契约检测、持久化配置、余额/额度与聚合快照、手动/定时同步、多实例租约和历史证据,并已联动账单健康、路由硬阻断和经济切换门禁。该契约不提供增量游标、逐请求账单行或价格 Feed;PostgreSQL 16 专用环境仍待验收。 +> +> 本专题是 [AsterRouter 省钱系统](../../savemoney/README.md) 在“采购第三方中转站/API 服务商”场景下的实施设计,不建立第二套路由器。 + +[数据模型与指标](./data-model-and-metrics.md) · [管理端原型](./ui-prototype.md) · [实施计划](./implementation-plan.md) · [上位成本感知设计](../../savemoney/cost-aware-routing.md) + +## 1. 结论 + +AsterRouter 可以提高缓存命中的机会,但不能保证第三方内部或其源头一定命中缓存。 + +应交付的不是一个“开启缓存”开关,而是以下闭环: + +1. 尽量把同一客户、同一会话、同一模型的请求送到同一个第三方供应商和我方采购账号。 +2. 对经过验证的第三方能力透传 `session_id`、`prompt_cache_key`、`cache_control` 等缓存或亲和信号。 +3. 规范化采集缓存读、缓存写、未缓存输入、TTFT 和第三方实扣成本。 +4. 用受控探针识别第三方是否接受缓存字段、是否命中、是否兑现折扣,以及内部号池是否破坏会话亲和。 +5. 同时展示标称倍率和缓存调整后的真实有效倍率,防止低倍率制造“便宜”的错觉。 +6. 先过滤不合格线路,再按有效采购成本、缓存经济性和服务质量决定保持、推荐切换或自动切换。 +7. 将账单监控证据分为“硬阻断”和“仅冻结经济切换”:监控异常不能中断正常推理,但不能继续把不健康候选提升为更优线路。 + +原始缓存命中率不能单独作为供应商好坏结论。唯一 Prompt 很多的工作负载天然命中低;响应变快也不能单独证明缓存命中。决策必须同时看工作负载可缓存性、响应 Usage、账单实扣和受控探针。 + +## 2. 业务边界 + +AsterRouter 的实际采购链路是: + +```text +下游调用方 + -> AsterRouter + -> ProviderConnection(第三方中转站/API 服务商) + -> ProviderAccount(我方在第三方的采购账号/API Key/套餐) + -> 第三方内部账号池、区域和源头路由(通常不可见) +``` + +因此系统只能对以下对象做选择和归因: + +- 第三方服务商连接。 +- 我方持有的第三方采购账号。 +- AsterRouter 的 Gateway Model / Model Route。 +- 第三方返回并可验证的上游模型、缓存 Usage 和账单事实。 + +系统不得: + +- 把第三方宣传的 Anthropic、Vertex、Bedrock 等源头当成已验证事实。 +- 因为响应延迟低就断言发生缓存命中。 +- 用 AsterRouter 对下游的 `model_pricings` 反推采购成本。 +- 用 `ProviderAccount.rate_multiplier` 代替供应商账单或可信采购报价。 +- 把第三方声称的倍率直接用于自动切换。 + +如果第三方内部账号池不可见,系统只能标记为 `pool_fragmentation_suspected`,不能声称已经识别到具体内部账号。 + +## 3. 号池为何会破坏缓存 + +第三方中转站可能在一个入口后维护数百或数千个订阅账号。即使 AsterRouter 连续调用同一个第三方 API Key,第三方仍可能把相邻请求分配到不同内部账号、组织、区域或 Provider endpoint。源头缓存通常不会跨这些边界共享,于是产生以下现象: + +```text +同一会话请求 A -> 第三方入口 -> 内部账号 17 -> 缓存写入 +同一会话请求 B -> 第三方入口 -> 内部账号 842 -> 再次冷启动 +同一会话请求 C -> 第三方入口 -> 内部账号 31 -> 再次冷启动 +``` + +仅在 AsterRouter 侧固定第三方供应商还不够。第三方还必须满足至少一项: + +- 接受并使用会话亲和字段。 +- 自己根据稳定 Prompt 前缀执行内部账号粘性。 +- 内部缓存域可以跨账号共享。 +- 对客户提供固定账号、固定组织或固定缓存的产品组。 + +`sub2api` 是号池实现的直接参考:它明确提供多账号管理和 Sticky Session,并把会话绑定到内部账号;这说明大型号池要维持缓存,需要在中转内部再次实现亲和,而不能依赖随机轮换。相关代码证据见[来源与查证](#12-来源与查证)。 + +## 4. 总体架构 + +```mermaid +flowchart LR + Client[下游客户/会话] + Gateway[Gateway Pipeline] + Affinity[客户与会话亲和] + Planner[Candidate Planner] + Supplier[第三方供应商] + Usage[缓存 Usage 归一化] + Billing[第三方账单/余额实扣] + Probe[受控缓存探针] + Rollup[有效价格与质量汇总] + Decision[保持/切换决策] + + Client --> Gateway --> Affinity --> Planner --> Supplier + Supplier --> Usage --> Rollup + Supplier --> Billing --> Rollup + Probe --> Supplier + Probe --> Rollup + Rollup --> Decision --> Planner +``` + +所有模块沿用现有 Gateway Pipeline: + +- Provider Adapter 解释协议字段和 Usage,不决定线路。 +- 价格源插件提交采购报价,不直接改路由。 +- Core 计算可信度、有效成本、亲和和切换决策。 +- Trace 保存候选、价格版本、缓存证据和切换原因。 + +## 5. 两级亲和路由 + +### 5.1 供应商亲和 + +目标是让同一客户的请求尽量保持在同一个第三方供应商,减少跨供应商缓存域切换。 + +建议绑定维度: + +```text +tenant/customer + gateway principal + model family + route group + policy version +``` + +供应商亲和使用较长 TTL,例如 1 小时到 24 小时,由管理员按业务配置。它只固定第三方供应商,不要求同一客户的所有并发请求挤在一个采购账号上。 + +### 5.2 会话与采购账号亲和 + +目标是在已选供应商内,尽量复用同一个我方 Provider Account,并向第三方传递其已验证的亲和信号。 + +建议绑定维度: + +```text +credential + requested model/route group + protocol + session hash +``` + +会话键按以下优先级获得: + +1. 现有 `X-AsterRouter-Sticky-Key`。 +2. 协议中明确、稳定且允许作为会话标识的字段,例如 OpenAI 请求的 `user`。 +3. 经过租户隔离的会话元数据。 +4. 没有会话键时,回退到短时客户亲和,而不是对 Prompt 全文做长期绑定。 + +存储前使用实例密钥做 HMAC,Trace 只保存哈希和来源,不保存原始客户标识。第一阶段复用现有 Header,不再新增同义 Header。 + +向已验证第三方注入 `prompt_cache_key` 时使用客户/会话/模型/协议/供应商账号共同派生的稳定 HMAC,不为整个租户复用单一 Key。OpenAI 当前文档明确说明稳定 Key 能帮助共享长前缀的请求路由到同一缓存,同时建议高流量按稳定映射拆分 Key,避免单个 key-prefix 过热导致缓存溢出;第三方中转站是否采用相同机制仍以 AsterRouter 探针和 Usage 观测为准。 + +### 5.3 有界粘性 + +亲和只在合格候选中提权,不能覆盖以下门禁: + +- 凭据、模型、地区、数据和能力策略。 +- 禁用、过期、余额不足或人工隔离。 +- 健康、熔断、冷却、429 和失败率。 +- 并发、RPM、TPM 和预算。 +- 缓存能力明显劣化或有效成本持续超阈值。 + +若粘性候选只是短时满载,可在 Direct 请求允许的短窗口内等待;超过窗口则故障转移并记录 `affinity_break_reason`。不能为了缓存让同步请求无限排队。 + +## 6. 第三方缓存能力识别 + +每个 `provider_account + upstream_model + protocol` 维护独立状态: + +| 状态 | 含义 | +| --- | --- | +| `unknown` | 没有足够样本 | +| `claimed` | 文档或销售声称支持,尚未实测 | +| `accepted` | 缓存/亲和字段未被拒绝,但未观察到命中 | +| `observed` | 响应 Usage 已观察到缓存读或写 | +| `billed_verified` | 响应 Usage 与第三方实扣折价一致 | +| `degraded` | 曾验证有效,近期命中或账单一致性显著下降 | +| `unsupported` | 多次受控探针确认不支持或明确拒绝 | + +能力识别分为四层,不能跳级: + +1. **协议接受:** 字段是否被第三方接受和透传。 +2. **Usage 可见:** 是否返回缓存读写 Token;字段缺失与值为 0 必须区分。 +3. **行为有效:** 在相同前缀、相同会话和 TTL 内是否稳定命中。 +4. **经济兑现:** 第三方账单或余额实扣是否确实下降。 + +同时维护 `pool_affinity_grade`: + +| 等级 | 判断 | +| --- | --- | +| `verified` | 同会话探针稳定命中,生产样本一致,账单可验证 | +| `probable` | 同会话明显优于不同会话,但账单证据不足 | +| `opaque` | 无内部账号信息,样本不足 | +| `fragmented` | 稳定前缀和会话信号下仍持续冷写/未命中,且负对照正常 | + +## 7. 受控缓存探针 + +探针只使用无敏感信息的合成长前缀,并受每日 Token 和金额预算约束。 + +单次探针序列: + +1. 选择明确支持的模型,使稳定前缀达到该模型最低缓存 Token。 +2. 用固定 `probe_session_id` 发首个请求建立缓存,输出上限尽量小。 +3. 在 TTL 内发送相同前缀、变化后缀的第二个请求。 +4. 再发送突变前缀或不同会话 ID 作为负对照。 +5. 采集响应 Usage、TTFT、第三方请求 ID、账单行和余额变化。 +6. 重复多个时间窗口;单次 Miss 不直接判坏。 + +探针必须具备: + +- 账号、模型和协议级冷却。 +- 每日 Token/金额预算与全局并发上限。 +- 首次响应开始后才发送复用请求,避免缓存尚未可读。 +- 生产流量优先,容量紧张时自动跳过。 +- 完整审计、手动停用和 `observe_only` 默认模式。 + +探针不能发送客户 Prompt,也不能为了达到最低 Token 而复制客户内容。 + +## 8. 真正价格与倍率 + +页面必须并列展示四个概念: + +| 指标 | 用途 | +| --- | --- | +| 标称倍率 | 第三方宣传或采购合同的报价,不代表实扣 | +| 账单倍率 | 第三方实扣相对同口径官方未缓存基准的倍率 | +| 缓存调整后有效倍率 | 当前真实工作负载包含缓存读写后的采购成本倍率 | +| 标准化有效倍率 | 用统一工作负载/探针缓存分布比较供应商,减少流量结构偏差 | + +文本请求的采购成本为: + +```text +actual_input_cost + = uncached_input_tokens * uncached_input_price + + cache_write_5m_tokens * cache_write_5m_price + + cache_write_1h_tokens * cache_write_1h_price + + cache_read_tokens * cache_read_price + +actual_request_cost + = recharge_multiplier * ( + actual_input_cost + + output_tokens * output_price + + request_fee + + other_usage_dimension_cost + ) +``` + +真实有效倍率为: + +```text +official_uncached_equivalent_cost + = total_input_tokens * official_input_price + + output_tokens * official_output_price + +workload_effective_multiplier + = actual_procurement_cost / official_uncached_equivalent_cost +``` + +只有总扣费而没有输入、输出、缓存分项时,可以展示“全请求有效成本”和 `derived/estimated` 可信度,不能伪造精确的 Input/M、Output/M 或缓存单价。创建采购价必须显式提交未缓存输入、缓存读、缓存写 5m/1h、输出、请求费和官方输入/输出基准;明确免费填 `0`,缺失字段直接拒绝,避免把未配置误当成免费。 + +详细口径见[数据模型与指标](./data-model-and-metrics.md)。 + +## 9. 供应商选择与切换依据 + +决策不是“缓存高就切”,而是分四步: + +### 9.1 硬过滤 + +先排除能力、治理、健康、容量、余额、模型或协议不合格的供应商。缓存和价格不能让不合格线路重新进入候选。 + +### 9.2 可比性门禁 + +只有模型能力、请求约束、计费单位和数据时间窗口可比时,才比较有效成本。数据过期或样本不足时保持当前稳定供应商并输出建议,不自动切换。 + +切换决策必须同时保存两个不同语义的模型字段: + +- `gateway_model`:客户请求中的公共模型名,用于 AsterRouter 热路径匹配决策。 +- `upstream_model`:第三方 API 实际承接的模型名,用于绑定采购价、缓存 Usage、账单、错误率和 P95 证据。 + +当前与候选线路只有在 `upstream_model + protocol` 相同且采购账号不同的情况下才允许比较。公共模型名不能从上游模型自动推断,否则模型别名或路由映射会让决策无法命中,或把多模型账号的成本与缓存证据串用。 + +### 9.3 经济排序 + +优先比较同一工作负载下的预计有效采购成本。若存在可信且显著更低的供应商,进入切换评估;不能仅因标称倍率更低就切换。 + +### 9.4 缓存质量决胜 + +如果没有供应商在有效成本上形成显著优势,则在服务质量合格的供应商中优先: + +1. `billed_verified` 等级更高。 +2. 同会话缓存经济收益更高。 +3. 号池亲和一致性更高。 +4. 缓存 Usage 覆盖和账单一致性更高。 +5. 错误率和延迟不劣于当前线路。 + +默认切换阈值建议作为可配置初值,而不是硬编码业务事实: + +- 至少 200 个合格请求或多轮成功探针。 +- 至少覆盖 24 小时,并同时参考 7 天窗口。 +- 有效成本改善至少 8%。 +- 当成本改善不足时,缓存 Token 命中率至少改善 10 个百分点且缓存净节省率真实提高,或号池亲和一致率至少改善 10 个百分点,才允许进入缓存质量决胜。高命中但缓存读不打折的线路不能因此胜出。 +- 缓存质量决胜最多容忍候选有效成本回退 2%;超过该值必须保持当前线路。 +- 账单一致率至少 95%。 +- 错误率不得恶化超过 0.5 个百分点。 +- P95 延迟不得恶化超过 20%,除非企业明确选择 `cost_first`。 +- 连续 3 个评估窗口满足条件才提升;连续 2 个窗口劣化才经济降级。 + +对应策略字段为 `min_cache_hit_rate_improvement=0.10`、`min_affinity_improvement=0.10`、`max_cache_tiebreak_cost_regression=0.02`、`max_error_rate_regression=0.005` 和 `max_p95_latency_regression=0.20`。缓存决胜还要求当前与候选均能由完整缓存分项和采购价计算净节省;缺失时记录 `cache_economics_evidence_missing`。缓存或亲和胜出且所有门禁通过时才记录 `cache_quality_tiebreaker`;错误率、P95 或证据缺失分别记录 `error_rate_regression_exceeded`、`p95_latency_regression_exceeded`、`p95_latency_evidence_missing` 并保持当前线路。`cost_first` 只允许跳过 P95 门禁,不能跳过错误率、样本、覆盖率、账单一致性和成本可信度门禁。 + +紧急故障转移不受经济滞回限制。经济切换默认走 `observe_only -> recommend -> canary -> active`。Canary 使用客户级 HMAC cohort key 做稳定分桶;已有客户/会话绑定继续复用到期或因硬故障中断,尚无有效绑定的客户按 Canary 比例进入候选供应商,避免同一客户因 Prompt 不同在供应商之间跳动。 + +## 10. 管理界面 + +页面布局、交互状态和桌面/移动端线框见[管理端原型](./ui-prototype.md)。 + +### 10.1 有效价格对比 + +按模型和协议展示供应商行: + +- 标称倍率、账单倍率、缓存调整后有效倍率。 +- 标称 Input/Output/Cache Read/Cache Write 价格。 +- 观测有效输入单价和全请求有效成本。 +- 合格请求命中率、Token 命中率、写读比、净缓存节省。 +- 账单验证等级、样本数、数据新鲜度和置信区间。 +- 当前供应商、建议供应商、预计节省与切换原因。 + +### 10.2 缓存与号池质量 + +按第三方供应商和采购账号展示: + +- 缓存能力状态和亲和等级。 +- 生产流量与受控探针分开的指标。 +- 同会话复用率、意外换供应商率和亲和中断原因。 +- 受控探针的首写、复用、负对照和账单结果。 +- `pool_fragmentation_suspected`、字段剥离、折价不兑现等异常。 + +### 10.3 切换中心 + +每条建议必须解释: + +- 当前线路与候选线路。 +- 使用的价格、Usage、账单和探针版本。 +- 预计有效成本差、可靠性差和缓存差。 +- 触发阈值、样本量和不确定性。 +- 预计影响的未绑定客户 cohort 比例、现有绑定到期时间和回滚条件。 + +## 11. MVP 交付状态与剩余边界 + +已交付: + +- OpenAI-compatible、Anthropic 与 Gemini JSON/SSE 缓存 Usage 归一化,区分字段缺失和值为 0,并采集 TTFT、第三方请求 ID、缓存读写 Token;Gemini 按 `promptTokenCount`、`cachedContentTokenCount`、`cacheTokensDetails` 和 `candidatesTokenCount + thoughtsTokenCount` 计算有效输入/输出。 +- 采购报价、第三方账单行、Usage 对账、采购成本可信度、标称倍率、真实有效倍率、无缓存等效成本和缓存净节省报告;充值实付系数实际进入成本计算,报告用 `cost_available` 区分零成本与缺失证据;下游 `model_pricings` 语义保持不变。 +- 客户级供应商亲和、会话级采购账号亲和和 HMAC 绑定键;亲和只重排合格候选,不覆盖健康、容量、熔断和 Fallback。生产网关会按 `provider_account + upstream_model + protocol` 查询已验证能力,并以不暴露原始客户/会话标识的 HMAC 值注入第三方 Header、Body 字段或 `prompt_cache_key`;能力查询失败时请求继续转发,同时在 Route Reason 记录 `upstream cache affinity unavailable`。 +- 可选 Redis-compatible 原子亲和协调层:Lua 单键首写保证多实例并发首次请求只有一个供应商/账号 owner,同 owner 可刷新 TTL,不同 owner 不能覆盖;Redis 故障时回退现有 Memory/PostgreSQL Repository。真实 Redis 已验证 64 路并发首写、过期/刷新和 10,000 个账号绑定,启用方式为 `ASTER_ROUTING_AFFINITY_DRIVER=redis`。 +- `warm -> reuse -> negative_control` 受控探针、Token/金额预算、账号冷却、并发门禁、原子预算预留,以及连续三次有效失败后的疑似号池碎片化判断。 +- 生产流量与探针样本分离,缓存能力、账单一致性、有效价格和切换原因可独立审计。 +- `observe_only/recommend/canary/active/rollback` 决策链路,以及成本优势不足时受最大成本回退约束的缓存质量决胜;缓存决胜同时要求命中率达标和净节省率真实提高,候选错误率和成功请求 P95 相对当前线路越界或证据缺失时保持当前线路。 +- 连续窗口监控、持久化健康/劣化 streak 和不可变窗口证据;同一决策窗口通过唯一约束和事务在多实例间只累计一次。自动动作默认关闭,Canary 仍需人工批准;启用后默认连续 3 个健康窗口自动激活、连续 2 个真实劣化窗口自动回滚,证据不足只记为 `inconclusive`。 +- `provider_billing_sources`、`provider_billing_sync_runs`、`provider_balance_snapshots` 和 `provider_usage_aggregate_snapshots` 持久化闭环;配置使用版本 CAS,定时任务使用 `FOR UPDATE SKIP LOCKED` 和租约 fencing,多实例只认领一次,过期 run 显式记为 `lease_expired`。成功事务原子写入快照,失败只保存稳定错误码和审计,不保存第三方错误正文。 +- 管理端有效价格、缓存质量、切换中心、探针记录、账单源五个 Tab,支持完整缓存读写采购价录入、第三方亲和能力配置、真实错误率/P95 对比、完整策略阈值、预算确认、账单源检测/配置/立即同步/历史证据和桌面/移动响应式布局。 + +仍待完成: + +- `sub2api-compatible` 没有增量游标、逐请求账单行或价格 Feed,因此仍需保留人工账单/报价导入,不能用聚合金额制造请求级对账;后续只能在第三方真实提供对应契约时扩展能力位。 +- 为提供稳定逐请求账单 API 或签名价格 Feed 的其他第三方增加具体 Adapter。 +- 在专用 PostgreSQL 16 环境完成 clean install、历史升级、重复初始化和运行时 Schema parity 实测;当前 PostgreSQL 18 隔离实例已通过 clean schema、重复迁移、Repository 契约和 Schema parity。 + +## 12. 来源与查证 + +查证时间:2026-07-15。 + +### 官方缓存行为 + +- [OpenAI Prompt Caching](https://developers.openai.com/api/docs/guides/prompt-caching.md):精确前缀匹配、最低 Token、稳定 `prompt_cache_key` 的缓存路由、高流量稳定分片、缓存读写 Usage 和保留策略。该口径已通过 Context7 `/websites/developers_openai_api` 于 2026-07-15 复核。 +- [Anthropic Prompt Caching](https://platform.claude.com/docs/en/build-with-claude/prompt-caching.md):`cache_control`、tools/system/messages 前缀顺序、5 分钟/1 小时 TTL、缓存读写 Usage 与价格倍率。 +- [Gemini Context Caching](https://ai.google.dev/gemini-api/docs/caching):隐式缓存默认行为、稳定前缀建议和缓存 Token Usage。 +- [Gemini generateContent Caching](https://ai.google.dev/gemini-api/docs/generate-content/caching):显式缓存对象、TTL 与 Usage。 +- [Gemini GenerateContent API](https://ai.google.dev/api/generate-content):`promptTokenCount` 包含缓存内容的有效 Prompt 总量,`cachedContentTokenCount` 是缓存子集,`cacheTokensDetails` 提供缓存模态明细,`candidatesTokenCount + thoughtsTokenCount` 构成完整输出;该字段口径已通过 Context7 `/websites/ai_google_dev_api` 于 2026-07-15 复核。 + +### 中转站行为参考 + +- [OpenRouter Prompt Caching](https://openrouter.ai/docs/guides/best-practices/prompt-caching.md):同会话固定 Provider endpoint、`session_id`、`cached_tokens`、`cache_write_tokens` 和 `cache_discount`。这是中转站可实现能力的参考,不代表所有第三方都支持。 +- [sub2api README](https://github.com/Wei-Shaw/sub2api/blob/e316ebf52838a89d57fc790981cce7520f819ac8/README.md#L170-L212):多账号管理、Sticky Session,以及代理丢弃 `session_id` 会破坏多账号粘性的说明。 +- [sub2api 会话哈希与绑定](https://github.com/Wei-Shaw/sub2api/blob/e316ebf52838a89d57fc790981cce7520f819ac8/backend/internal/service/gateway_service.go#L721-L803):从稳定信号派生会话键,并把会话绑定到具体内部账号。 +- [sub2api 负载感知调度](https://github.com/Wei-Shaw/sub2api/blob/e316ebf52838a89d57fc790981cce7520f819ac8/backend/internal/service/gateway_scheduling.go#L89-L180):从缓存读取 Sticky Account,账号不可用或容量不足时才进入等待/回退。 +- [sub2api 用户 Usage 接口](https://github.com/Wei-Shaw/sub2api/blob/e316ebf52838a89d57fc790981cce7520f819ac8/backend/internal/handler/usage_handler.go#L223-L418):`/api/v1/usage` 和 `/api/v1/usage/stats` 使用用户 JWT,可返回分页逐请求明细,但 AsterRouter 的采购账号 API Key 不能直接使用该认证面。 +- [sub2api API Key Usage 接口](https://github.com/Wei-Shaw/sub2api/blob/e316ebf52838a89d57fc790981cce7520f819ac8/backend/internal/handler/gateway_handler.go#L1229-L1494):`/v1/usage` 使用采购调用所用 API Key,返回钱包余额、Key 配额或订阅周期额度,以及今日/累计和默认近 30 天模型维度的成本、Token、缓存 Token 聚合;不返回逐请求账单行、账单行 ID、价格 Feed 或增量游标。 +- [sub2api API Key 鉴权边界](https://github.com/Wei-Shaw/sub2api/blob/e316ebf52838a89d57fc790981cce7520f819ac8/backend/internal/server/middleware/api_key_auth.go#L29-L150):支持 `Authorization: Bearer`、`x-api-key` 和 `x-goog-api-key`,并仅对 `/v1/usage` 跳过计费执行以允许查询已耗尽 Key 的用量。 + +这些来源证明“稳定前缀 + 会话/Provider endpoint 亲和”能提高命中机会,但不能证明某一家待采购第三方已经实现。`sub2api-compatible` 结构匹配同样不能确认对方就是 sub2api 或识别其内部具体账号。因此第三方供应商仍必须经过 AsterRouter 自己的生产观测、探针和账单验证。 diff --git a/docs/roadmap/effectivepricing/data-model-and-metrics.md b/docs/roadmap/effectivepricing/data-model-and-metrics.md new file mode 100644 index 0000000..1b80b0a --- /dev/null +++ b/docs/roadmap/effectivepricing/data-model-and-metrics.md @@ -0,0 +1,425 @@ +# 数据模型、真实价格与缓存质量指标 + +> 本文定义 [第三方 API 有效价格与缓存感知路由](./README.md) 的事实模型和统一计算口径。 + +## 1. 设计原则 + +1. **原始事实不可覆盖。** 第三方原始 Usage、账单行、报价快照和规范化结果分别保存。 +2. **缺失不等于零。** 缓存字段没有返回时保存 `NULL` 和 `present=false`;明确返回 0 才是零。 +3. **报价不等于实扣。** 宣传倍率、采购目录价和账单成本必须分层。 +4. **请求成本不等于输入单价。** 只有总扣费时不能反推精确的输入、输出和缓存单价。 +5. **工作负载与供应商能力分开。** 生产命中率低可能是 Prompt 不可复用,不必然代表供应商差。 +6. **调度只消费发布快照。** 原始观测和未验证账单不直接进入热路径。 + +## 2. 三层价格事实 + +| 层次 | 事实 | 用途 | +| --- | --- | --- | +| 采购报价 | 第三方目录价、套餐组、充值折扣、宣传倍率 | 预估和异常对比 | +| 采购实扣 | Usage Cost Line、账单行、余额变化、充值现金成本 | 财务事实和自动调度 | +| 下游计费 | AsterRouter `model_pricings` 和客户计费策略 | 下游预算/销售,不参与采购价反推 | + +采购成本可信度: + +| 级别 | 定义 | 能否自动切换 | +| --- | --- | --- | +| `exact` | 账单行与请求 ID 精确匹配,包含分项成本 | 可以 | +| `derived` | 总扣费可匹配请求,并能用同版本可信费率卡分摊 | 达到样本门槛后可以 | +| `estimated` | 仅根据费率卡和本地 Usage 估算 | 只能建议/灰度 | +| `unallocated` | 只有窗口余额变化或总扣费,不能分到请求/模型 | 不可以 | +| `unknown` | 没有足够证据 | 不可以 | + +## 3. Canonical Cache Usage + +Provider Adapter 把不同协议归一化为互斥维度: + +| 字段 | 类型 | 含义 | +| --- | --- | --- | +| `total_input_tokens` | `BIGINT` | 本请求完整输入 Token | +| `uncached_input_tokens` | `BIGINT NULL` | 未读缓存、未写缓存的输入 Token | +| `cache_read_tokens` | `BIGINT NULL` | 从缓存读取的 Token | +| `cache_write_5m_tokens` | `BIGINT NULL` | 写入短 TTL 缓存的 Token | +| `cache_write_1h_tokens` | `BIGINT NULL` | 写入长 TTL 缓存的 Token | +| `output_tokens` | `BIGINT` | 输出 Token | +| `cache_fields_present` | `BOOLEAN` | 第三方是否返回缓存字段 | +| `normalization_status` | enum | `exact/derived/partial/unknown` | +| `raw_usage` | `JSONB` | 脱敏后的原始 Usage 证据 | + +协议差异由 Adapter 处理: + +- OpenAI 的总输入字段包含缓存部分,`cached_tokens`/`cache_write_tokens` 是细分字段。 +- Anthropic 的 `input_tokens`、`cache_creation_input_tokens`、`cache_read_input_tokens` 是不同部分,总输入需要相加。 +- Gemini 隐式缓存通过缓存 Token Usage 字段报告,具体字段随 API 表面不同,由 Adapter 映射。 + +任何归一化都必须满足: + +```text +total_input_tokens + = uncached_input_tokens + + cache_read_tokens + + cache_write_5m_tokens + + cache_write_1h_tokens +``` + +如果第三方语义不足以完成互斥拆分,保留原始总量并把状态设为 `partial`,不能为了满足等式强行填 0。 + +## 4. 建议数据结构 + +第一阶段只增加完成闭环所需的表和字段,避免复制参考项目的用户、开 Key 和本地分组业务。 + +### 4.1 Usage 扩展 + +在现有 Usage Ledger 增加: + +```text +ttft_ms BIGINT NULL +total_input_tokens BIGINT NULL +uncached_input_tokens BIGINT NULL +cache_read_tokens BIGINT NULL +cache_write_5m_tokens BIGINT NULL +cache_write_1h_tokens BIGINT NULL +cache_fields_present BOOLEAN NOT NULL DEFAULT false +usage_normalization_status TEXT NOT NULL DEFAULT 'unknown' +upstream_request_id TEXT NOT NULL DEFAULT '' +procurement_cost_micros BIGINT NULL +procurement_cost_currency TEXT NOT NULL DEFAULT '' +procurement_cost_source TEXT NOT NULL DEFAULT 'unknown' +procurement_cost_confidence TEXT NOT NULL DEFAULT 'unknown' +price_snapshot_id TEXT NOT NULL DEFAULT '' +billing_line_id TEXT NOT NULL DEFAULT '' +``` + +保留当前 `cost_cents` 作为兼容字段;在完成迁移前明确命名为下游估算成本,不改变其既有语义。 + +### 4.2 `provider_price_observations` + +保存插件、API 或人工导入的原始采购报价: + +```text +id, provider_id, provider_account_id, protocol, upstream_model +currency, uncached_input_price, output_price +cache_read_price, cache_write_5m_price, cache_write_1h_price +request_price, quoted_multiplier, recharge_multiplier +source_kind, source_reference, evidence_hash +observed_at, effective_from, expires_at, confidence, raw_payload +``` + +### 4.3 `provider_price_snapshots` + +Core 校验后发布的不可变快照。增加统一币种、汇率版本、税费和归一化结果。Gateway 只引用 Snapshot ID,不直接读取 Observation。 + +### 4.4 `provider_billing_lines` + +保存第三方账单或 Usage Cost Line: + +```text +id, provider_id, provider_account_id +external_request_id, external_line_id, upstream_model +started_at, ended_at, currency, amount_micros +input_cost_micros, output_cost_micros +cache_read_cost_micros, cache_write_cost_micros +source_kind, confidence, raw_payload_hash +reconciliation_status, usage_id +``` + +余额快照只能产生 `unallocated` 成本,除非有稳定请求关联或账单明细支持分摊。 + +### 4.5 `provider_cache_capabilities` + +每个账号、模型和协议的能力状态: + +```text +provider_account_id, upstream_model, protocol +support_status, pool_affinity_grade +affinity_transport, cache_control_mode, usage_mapping_version +claimed_at, first_observed_at, billed_verified_at +last_success_at, last_failure_at, degraded_reason +production_sample_count, probe_sample_count +updated_at +``` + +`affinity_transport` 由 Adapter 声明,例如 `none/header/body/custom`,并保存字段映射版本。未经验证时只透传原字段,不自动把一个协议的缓存语义翻译成另一个协议。 + +### 4.6 `provider_cache_probe_runs` + +```text +id, provider_account_id, upstream_model, protocol +probe_series_id, phase, session_hash +prefix_fingerprint, prefix_tokens, suffix_variant +cache_fields_present, cache_read_tokens, cache_write_tokens +ttft_ms, upstream_request_id +quoted_cost_micros, billed_cost_micros, billing_confidence +status, failure_reason, started_at, finished_at +``` + +`phase` 使用 `warm/reuse/negative_control`,便于验证“重复前缀命中、突变前缀不命中”。 + +### 4.7 `provider_effective_cost_rollups` + +按 `provider_account + upstream_model + protocol + workload_class + window` 生成不可变或可重算汇总: + +```text +request_count, eligible_request_count +cache_metrics_covered_requests +total_input_tokens, cache_read_tokens, cache_write_tokens +actual_procurement_cost_micros +uncached_equivalent_cost_micros +quoted_multiplier, billed_multiplier +workload_effective_multiplier, standardized_effective_multiplier +cache_support_grade, workload_cache_effectiveness +affinity_consistency_rate, billing_consistency_rate +cost_confidence, window_start, window_end, computed_at +``` + +## 5. 缓存指标 + +### 5.1 覆盖率 + +```text +cache_metrics_coverage + = cache_fields_present_request_count / successful_request_count +``` + +覆盖率低时,命中率只能代表可见子集,不能参与自动切换。 + +### 5.2 合格请求命中率 + +```text +eligible_request_hit_rate + = eligible_requests_with_cache_read / eligible_requests_with_cache_metrics +``` + +`eligible` 由模型最低 Token、已知缓存模式、稳定前缀和 TTL 判断;无法判断时标记 `unknown`,不能混入分母。 + +### 5.3 Token 命中率 + +```text +cache_token_hit_rate + = sum(cache_read_tokens) / sum(total_input_tokens) +``` + +同时展示 P50/P95 请求级 Token 命中率,避免少量超长 Prompt 掩盖大量 Miss。 + +### 5.4 写读比 + +```text +cache_write_read_ratio + = sum(cache_write_tokens) / max(sum(cache_read_tokens), 1) +``` + +长期写读比高,说明重复写入多、复用少,常见原因包括会话被打散、TTL 不合适或前缀不稳定。 + +### 5.5 亲和一致率 + +```text +affinity_consistency_rate + = successful_reuse_probe_series / completed_reuse_probe_series +``` + +生产流量另算: + +```text +supplier_affinity_reuse_rate + = requests_reusing_bound_supplier / requests_with_existing_supplier_binding +``` + +必须按 `affinity_break_reason` 排除主动故障转移、限流和策略变更,避免把合理切换误判为号池碎片化。 + +### 5.6 缓存经济收益 + +```text +uncached_equivalent_input_cost + = total_input_tokens * uncached_input_price + +net_cache_savings + = uncached_equivalent_input_cost + - actual_input_cost + +cache_savings_rate + = net_cache_savings / uncached_equivalent_input_cost +``` + +缓存写可能比普通输入更贵,因此单次请求的 `net_cache_savings` 可以为负数,不能只累计正数。 + +TTFT 改善仅作为辅助指标: + +```text +ttft_improvement_rate + = (uncached_probe_ttft - cached_probe_ttft) / uncached_probe_ttft +``` + +延迟变化受队列、网络和模型负载影响,不构成缓存命中的独立证据。 + +## 6. 真正价格和倍率换算 + +### 6.1 请求实扣成本 + +```text +actual_procurement_cost + = billing_line.amount +``` + +没有精确账单行时: + +```text +estimated_procurement_cost + = recharge_multiplier * ( + uncached_input_tokens * snapshot.uncached_input_price + + cache_write_5m_tokens * snapshot.cache_write_5m_price + + cache_write_1h_tokens * snapshot.cache_write_1h_price + + cache_read_tokens * snapshot.cache_read_price + + output_tokens * snapshot.output_price + + request_fee + ) +``` + +API 创建或更新采购价时,上述八个价格分项必须显式出现;明确免费应提交 `0`,字段缺失会被拒绝。`recharge_multiplier` 未提交或为兼容旧数据的零值时按 `1` 处理,新写入值必须大于零。 + +### 6.2 观测有效输入单价 + +只有账单存在输入/输出分项或可可信分摊时计算: + +```text +observed_effective_input_price_per_1m + = sum(actual_input_cost) / sum(total_input_tokens) * 1,000,000 +``` + +若账单只有请求总额,改为展示: + +```text +observed_effective_request_cost + = sum(actual_procurement_cost) / request_count +``` + +### 6.3 账单倍率 + +用于检查第三方宣传倍率是否真实: + +```text +billed_multiplier + = actual_procurement_cost_without_cache_benefit + / official_uncached_equivalent_cost +``` + +`actual_procurement_cost_without_cache_benefit` 只有在账单分项足够时才可推导;否则不展示。 + +### 6.4 工作负载有效倍率 + +用于回答“这个供应商在我们的真实流量下到底多少钱”: + +```text +workload_effective_multiplier + = actual_procurement_cost + / official_uncached_equivalent_cost +``` + +该值包含第三方倍率、充值折扣、缓存读写、输出、请求费和实际工作负载分布。 + +当前报告 API 同时返回 `cost_available`、采购价八个分项、`recharge_multiplier`、`uncached_cost_micros_per_1m`、`cache_savings_micros_per_1m`、`cache_savings_rate` 和 `cache_economics_available`。`cost_available` 用于区分已验证零成本和缺少成本证据。只有窗口内存在缓存观测、缓存分项 Token 与总输入守恒且采购缓存单价存在时,才把净节省标记为可用;正常错误率不会单独抹掉指标,无法解释的 Token 缺口仍会拒绝计算,缓存写入溢价可使净节省为负数。 + +### 6.5 标准化有效倍率 + +直接比较生产流量会被客户结构影响。对供应商切换还要使用同一标准工作负载: + +```text +standardized_effective_cost(provider) + = standard_uncached_tokens * provider_uncached_price + + standard_cache_write_tokens * provider_cache_write_price + + standard_cache_read_tokens * provider_cache_read_price + + standard_output_tokens * provider_output_price +``` + +标准工作负载来自同一租户/模型最近窗口,或受控探针固定分布。所有候选必须使用同一个快照版本。 + +### 6.6 盈亏平衡命中率 + +在简化为普通输入、缓存写和缓存读三种价格时: + +```text +effective_input_price + = miss_rate * uncached_price + + write_rate * cache_write_price + + hit_rate * cache_read_price +``` + +页面可求出候选供应商相对当前线路的盈亏平衡 `hit_rate`,但必须展示假设的写入率、TTL 和价格版本,不能把模拟值当成实扣事实。 + +## 7. 双维度评分 + +### 7.1 `cache_support_grade` + +判断第三方是否真实透传并兑现缓存: + +- 协议字段接受率。 +- Usage 覆盖率。 +- 受控探针成功率。 +- 账单一致率。 +- 号池亲和一致率。 + +### 7.2 `workload_cache_effectiveness` + +判断我们的请求是否适合缓存: + +- 合格请求占比。 +- 稳定前缀复用率。 +- Token 命中率。 +- 写读比。 +- 净缓存节省。 + +供应商评分不能因某个客户的唯一 Prompt 很多而下降;工作负载问题应反馈给客户或 Prompt 设计,而不是触发供应商切换。 + +## 8. 切换决策快照 + +每次评估保存: + +```text +decision_id, policy_id, current_provider_account_id +candidate_provider_account_id, gateway_model, upstream_model, protocol, workload_class +current_cost_snapshot_id, candidate_cost_snapshot_id +current_effective_cost, candidate_effective_cost +cache_support_grade, affinity_consistency_rate +error_rate_delta, latency_delta, cost_delta +sample_count, confidence, decision +reason_codes, observed_at, valid_until +``` + +`gateway_model` 是客户请求的公共路由键;`upstream_model` 是采购证据维度。评估必须用 `provider_account_id + upstream_model + protocol` 精确定位当前与候选报告行,运行时则用 `gateway_model + protocol` 匹配客户请求。现有决策表不重复持久化 `upstream_model`,API 从不可变的候选价格快照派生该字段;这样避免双写漂移,也不需要新增数据库列。 + +`decision` 使用: + +- `hold`:保持当前供应商。 +- `recommend`:有优势但需人工确认。 +- `canary`:只让配置比例的未绑定客户 cohort 进入候选,已有客户/会话绑定不主动打断。 +- `promote`:成为尚无有效绑定客户的默认供应商,现有绑定按 TTL 或硬故障迁移。 +- `degrade`:降低权重,不中断现有健康会话。 +- `emergency_failover`:健康或容量硬故障立即切换。 + +原因码至少包括: + +```text +effective_cost_better +cache_economics_better +pool_affinity_better +billing_verified +insufficient_samples +price_stale +cache_metrics_missing +cache_economics_evidence_missing +billing_mismatch +pool_fragmentation_suspected +error_rate_regression_exceeded +p95_latency_regression_exceeded +p95_latency_evidence_missing +cache_quality_tiebreaker +affinity_preserved +affinity_broken_for_health +``` + +## 9. 数据保留与隐私 + +- 会话、客户和前缀只保存 HMAC;不同租户使用隔离命名空间。 +- 不保存探针以外的 Prompt 内容,不用客户 Prompt 做缓存探针。 +- 原始账单 Payload 加密或只保存内容哈希和必要字段。 +- 请求级原始证据按企业策略短期保留;日/周汇总长期保留。 +- 删除 API Key 或客户时清理可关联亲和绑定,聚合财务事实按审计策略匿名保留。 +- 插件只能写自己负责供应商的 Observation,不能读取客户 Prompt、Gateway Key 原文或其他供应商账单。 diff --git a/docs/roadmap/effectivepricing/implementation-plan.md b/docs/roadmap/effectivepricing/implementation-plan.md new file mode 100644 index 0000000..0b01a44 --- /dev/null +++ b/docs/roadmap/effectivepricing/implementation-plan.md @@ -0,0 +1,417 @@ +# 实施计划 + +> 本计划将 [第三方 API 有效价格与缓存感知路由](./README.md) 落到 AsterRouter 现有 Gateway、Control Plane、PostgreSQL、Redis、Trace 和 Vue 管理界面。 + +> 交付状态:Phase 0-6 核心闭环已交付。亲和绑定支持 PostgreSQL 共享存储和可选 Redis 原子首写协调;连续窗口自动提升/回滚支持多实例去重、事务证据和默认关闭的 Kill Switch。`sub2api-compatible` 账单源已支持配置持久化、余额/聚合快照、手动与定时同步、多实例租约和历史证据;账单源健康已联动路由硬阻断与有效价格经济切换门禁。PostgreSQL 16 专用环境验收,以及具备逐请求明细/价格 Feed 的其他 Adapter 仍待完成。 + +## 0. MVP 交付矩阵 + +| 能力 | 状态 | 当前边界 | +| --- | --- | --- | +| Cache Usage、TTFT、第三方请求 ID | 已交付 | OpenAI-compatible、Anthropic、Gemini JSON/SSE;Gemini 原生网关入口仍不在本专题范围 | +| 采购报价、账单行、有效倍率 | 部分交付 | 支持管理 API/人工导入;`sub2api-compatible` 已持久化余额/额度和聚合成本快照,但该上游契约没有逐请求账单行或价格 Feed | +| 受控探针与号池疑似碎片化 | 已交付 | 合成长前缀、预算、冷却、并发、负对照、连续失败门禁 | +| 客户/会话两级亲和 | 已交付 | HMAC 键、有界重排、PostgreSQL 共享绑定、Redis 原子首写和已验证第三方亲和信号注入已落地;百万绑定长期压测待补 | +| 缓存质量决胜 | 已交付 | 10% 命中改善且净缓存节省真实提高,或 10% 亲和改善;最多 2% 成本回退,并执行错误率和 P95 相对质量门禁 | +| Canary、激活与回滚 | 已交付 | 人工批准 Canary;连续窗口自动激活/回滚默认关闭,可独立启用并审计 | +| 账单健康与切换门禁 | 已交付 | active 来源的明确无效 Key、认证拒绝、Key 配额耗尽和有限订阅额度硬阻断;同步失败/证据过期只冻结经济性提升;监控查询异常对推理 fail-open | +| 管理端与原型 | 已交付 | 五个 Tab、策略、预算确认、账单源检测/配置/同步/历史证据、桌面/移动布局 | + +## 1. 实施约束 + +- 不新增旁路 Router,继续使用现有 Candidate Planner、Provider Account Scheduler 和 Gateway Pipeline。 +- 不改变 `model_pricings` 的下游计费含义。 +- 不假设第三方内部源头或账号池可见。 +- 不默认重写客户 Prompt;第一阶段只透传已验证字段并提供缓存友好度诊断。 +- 新调度能力从 `observe_only` 开始,完成账单验证后才允许自动切换。 +- Direct 请求只做短时有界等待;不会为了缓存或低价隐式变成持久任务。 + +## 2. 当前代码映射 + +| 能力 | 当前基础 | 需要补齐 | +| --- | --- | --- | +| Sticky Key | 已读取 `X-AsterRouter-Sticky-Key`、OpenAI `user`,使用 HMAC 保存来源隔离后的绑定键,并按已验证能力注入第三方 Header、Body 字段或 `prompt_cache_key` | 扩展更多第三方映射 Fixture,禁止未经验证的字段自动注入 | +| Sticky 绑定 | 已实现客户级供应商亲和和会话级采购账号亲和,Memory/PostgreSQL Repository 均可保存,Redis Lua 首写协调可选启用,仍受容量、熔断与 Fallback 约束 | 增加百万绑定长期压测与容量告警 | +| 候选执行 | 已接入有效价格决策、Canary/Active 重排、连续窗口自动提升和回滚 | 增加具体供应商账单同步后的端到端长周期灰度 | +| Usage | 已增加缓存读写、未缓存输入、TTFT、第三方请求 ID、采购成本与可信度 | 增加更多第三方别名 Fixture | +| JSON/SSE 解析 | OpenAI-compatible、Anthropic 与 Gemini 缓存 Usage 已归一化且不改变透传 | 增加更多第三方别名 Fixture | +| 成本 | 已新增采购报价、第三方账单行、对账和真实有效倍率;`sub2api-compatible` 已支持持久化账单源、余额/聚合快照和定时同步 | 增加有逐请求明细、价格 Feed 或增量游标的具体 Adapter | +| 缓存质量 | 已提供生产/探针分离指标、号池亲和等级、预算探针、窗口趋势和切换门禁 | 大规模长期基准待补 | + +## 3. Phase 0:契约和术语收敛 + +交付: + +1. 定义 Canonical Cache Usage、采购成本可信度、能力状态和切换原因码。 +2. 明确 `ProviderConnection -> ProviderAccount -> 第三方内部号池` 边界。 +3. 在上位 [成本感知调度设计](../../savemoney/cost-aware-routing.md) 中只保留通用 Price Observation;本专题负责第三方 API 场景细节。 +4. 为 Provider Adapter 增加只读能力描述: + +```go +type CacheCapability struct { + AffinityTransport string + CacheControlMode string + UsageSchema string + SupportsProbe bool +} +``` + +验收:相同术语在 Core、插件、API 和界面中含义一致;没有把宣传倍率命名为 `actual_cost`。 + +## 4. Phase 1:Usage 与流式可观测性(部分完成) + +### 4.1 数据迁移 + +为 Usage Ledger 增加 nullable 缓存字段、TTFT、第三方请求 ID、采购成本与可信度。为历史记录设置 `usage_normalization_status=unknown`,不得回填伪造的 0。 + +### 4.2 协议归一化 + +在 Provider Adapter 或独立 Usage Normalizer 实现: + +- OpenAI Chat/Responses JSON。 +- OpenAI Chat/Responses SSE 终态事件。 +- Anthropic Messages JSON/SSE。 +- Gemini generateContent/streamGenerateContent。 +- OpenAI-compatible 第三方的兼容别名,但必须保留来源 Schema。 + +解析器输出 `field present` 和 `value`,不使用 `int` 零值判断字段是否存在。 + +当前状态:OpenAI-compatible、Anthropic 与 Gemini JSON/SSE 已交付;Gemini 已支持原生 `generateContent` 受控探针和管理端协议选择,但原生 Gemini 网关转发入口仍不在本专题范围。 + +### 4.3 SSE 旁路累积 + +SSE 转发过程中只复制必要终态 Usage 字段到有界状态机: + +- 不缓存完整响应。 +- 不改变事件顺序、分块和客户端可见内容。 +- 客户断开后仍按已观察到的上游 Usage 写入最终记录。 +- 上游没有终态 Usage 时保存 `partial/unknown`。 + +### 4.4 TTFT + +记录上游请求开始到首个语义 Token/事件的时间。心跳、注释和纯元数据事件不算 TTFT。 + +验收:JSON 与 SSE 的同一 Usage Fixture 归一化结果一致;字段缺失和 0 有单独测试。 + +## 5. Phase 2:采购报价、账单与真实倍率(MVP 已完成) + +### 5.1 价格观测 + +实现 `provider_price_observations` 和不可变 `provider_price_snapshots`: + +- 允许 API、签名 Feed、人工导入和受控浏览器来源。 +- 记录缓存读、5 分钟写、1 小时写、普通输入、输出和请求费。 +- 保存币种、汇率、税费、充值折扣、适用套餐和有效期;充值实付系数进入估算成本和缓存净节省计算。 +- API 要求八个价格分项显式出现,明确免费提交 `0`,不得把遗漏字段静默解释为免费。 +- 冲突或过期价格不能发布为自动调度快照。 + +### 5.2 第三方账单 Adapter + +当前只读检测端口按供应商实现: + +```go +type ProviderBillingReader interface { + ID() string + Inspect(ctx context.Context, target ProviderBillingReadTarget) (ProviderBillingSourceInspection, error) +} +``` + +默认注册表先实现 `sub2api_compatible`:使用采购账号原有 API Key 读取 `/v1/usage`,自动识别钱包余额、API Key 配额或订阅周期额度,并读取今日/累计与默认近 30 天模型维度的 Token、缓存 Token、模型原价成本和第三方实扣聚合。结构匹配只标记 `schema_match`,不确认供应商内部实现。 + +该契约没有逐请求账单行、价格 Feed 或增量游标,因此: + +- `usage_cost_lines=false`、`incremental_sync=false`、`price_feed=false`。 +- 聚合总额只做校验证据,不写入 `provider_billing_lines`。 +- Key 配额和订阅额度不伪装成钱包余额。 +- 没有账单 API 的供应商继续人工导入;只有余额变化时保持 `unallocated`。 + +持久化阶段已新增: + +1. `provider_billing_sources`:采购账号、Adapter、启用状态、同步间隔、游标、下次运行时间、连续失败和最后错误码。 +2. `provider_balance_snapshots`:不可变金额、`wallet_balance/api_key_quota_remaining/subscription_period_remaining` 语义、币种、观测时间和证据哈希。 +3. `provider_usage_aggregate_snapshots`:不可变统计范围、模型维度、请求数、输入/输出/缓存 Token、模型原价成本、第三方实扣聚合、币种、观测时间和证据哈希。 +4. `provider_billing_sync_runs`:每次检测/同步的开始结束时间、能力快照、发现/导入/跳过数量和失败原因。 + +以上四张表已由 `062_provider_billing_sources.sql` 和 runtime schema 同步创建。配置更新使用 `version` CAS;自动同步默认关闭,最短间隔 60 秒。调度器通过 PostgreSQL `FOR UPDATE SKIP LOCKED` 或 Memory 临界区认领,到期租约会将旧 run 标记为 `lease_expired`,旧 worker 的完成写入会被 fencing token 和过期时间共同拒绝。成功完成在同一事务中提交 run、source、余额和聚合快照;失败只保存稳定错误码、能力状态和审计,不保存第三方原始错误正文,也不会产生任何快照。 + +管理 API 已提供配置列表/保存、手动同步和证据查询;后台 Worker 挂载现有 `monitorCtx`,默认每分钟扫描有限批次,单一账单源失败不会阻塞其他来源。`sub2api-compatible` 没有增量游标,因此当前 `cursor` 保持为空,但调度和表结构不会把聚合结果伪装成增量账单行。 + +### 5.3 对账 + +匹配顺序: + +1. 第三方请求 ID。 +2. 供应商账单行 ID/请求时间/模型/Token 的唯一组合。 +3. 时间窗口聚合,只产生 `derived/unallocated`。 + +对账不得修改原始 Usage 或账单行,只写关联和状态。 + +### 5.4 有效价格汇总 + +生成 1 小时、24 小时、7 天滚动汇总,页面同时展示: + +- 标称倍率。 +- 账单倍率。 +- 工作负载有效倍率。 +- 标准化有效倍率。 +- 样本量、覆盖率和可信度。 + +验收:低标称倍率但缓存差的 Fixture 能显示为实际更贵;只有总额的账单不会生成虚假分项单价。 + +### 5.5 账单健康与路由联动 + +账单源是采购账号的外部证据,不是上游源头的内部状态。系统只在 `active` 来源上让证据改变路由: + +- `observe_only` 和 `disabled` 只展示健康状态,不过滤流量,也不参与自动经济切换。 +- `provider_billing_key_invalid`、`provider_billing_auth_rejected`、`provider_billing_key_quota_exhausted` 和 `provider_billing_subscription_exhausted` 是硬阻断原因;该账号从模型候选中移除,全部候选被移除时返回 `route_unavailable`。 +- `provider_billing_sync_unhealthy`、`provider_billing_evidence_stale` 和 `provider_billing_evidence_missing` 不阻断正常推理,只将 `economic_switch_eligible` 置为 false,禁止新 Canary/Active 提升,并阻止既有经济决策把候选重排到首位。 +- 钱包余额为零不直接硬阻断,因为可能是后付费;只有 API Key 配额为零,或非 unlimited 的订阅周期额度不大于零,才按额度耗尽处理。 +- 最新余额按每个 `source_id` 的 `observed_at DESC, id DESC` 选择;证据新鲜度为 `max(2 * sync_interval, 6h)`。监控读取失败不影响正常推理,经济切换管理操作则 fail-closed。 + +派生状态通过 `ProviderBillingRoutingHealth` 暴露:`status`、`hard_blocked`、`economic_switch_eligible`、`reason_codes`、证据观测时间和新鲜度阈值。该状态不持久化,不新增迁移;来源、同步运行和余额快照仍是唯一事实源。 + +## 6. Phase 3:缓存能力、号池识别与探针(MVP 已完成) + +### 6.1 能力注册 + +Provider Adapter 声明第三方支持的字段和映射,例如: + +- 透传 `prompt_cache_key`。 +- 透传或翻译经过验证的 `session_id`/自定义 Header。 +- 透传 `cache_control`。 +- 从响应映射 `cached_tokens`、`cache_write_tokens` 或 Anthropic 缓存字段。 + +默认策略是 `passthrough_if_present`。协议间自动翻译必须由具体 Adapter 显式启用并有契约测试。 + +### 6.2 探针执行器 + +实现 PostgreSQL 任务状态 + 现有 Worker/Job 基础上的低频探针,不新增消息队列: + +- 每账号/模型/协议串行。 +- 每日 Token 和金额预算。 +- `warm -> reuse -> negative_control` 三阶段。 +- 指数冷却和手动暂停。 +- 探针流量使用独立 API Key/Principal 和 Trace 标签,不混入客户成本 KPI。 + +### 6.3 号池碎片化判断 + +仅在以下条件同时成立时标记 `pool_fragmentation_suspected`: + +1. 前缀达到最低 Token,字段被接受。 +2. 同一会话、同一第三方采购账号、同一模型、TTL 内重复。 +3. 多轮重复仍冷写或 Miss。 +4. 不同前缀负对照行为符合预期。 +5. 已排除价格/模型变更、TTL 超时、并发首写未完成和 AsterRouter 主动 Fallback。 + +不能通过第三方请求 ID、延迟或响应内容猜测具体内部账号。 + +验收:探针预算、冷却、并发和审计都有失败测试;单次 Miss 不会把供应商降为 `fragmented`。 + +## 7. Phase 4:客户与会话亲和(Redis 原子协调闭环已完成) + +### 7.1 复用现有 Sticky 契约 + +继续使用 `X-AsterRouter-Sticky-Key`,并记录来源枚举。客户端 SDK/文档建议每个多轮会话使用稳定、无 PII 的随机 ID。 + +### 7.2 两级绑定 + +Redis-compatible 实际 Key: + +```text +asterrouter:{:routing_affinity}:affinity_ +``` + +Redis Hash 保存不可逆 owner hash 和 Provider/Route/Account、绑定版本、创建时间、最后复用时间及到期时间;Key 只包含 HMAC scope,不包含客户、会话或 Prompt 原文。Repository 抽象继续提供 Memory 和 PostgreSQL 实现,作为审计副本和 Redis 故障时的降级路径。 + +当前状态:HMAC 绑定键、两级亲和重排、PostgreSQL 持久化和 Redis Lua 原子首写已交付。生产请求仅在能力为 `accepted/observed/billed_verified` 且存在 Sticky Key 时,向第三方注入由客户/会话/模型/协议/供应商账号共同派生的 HMAC 亲和值;Header、Body 和 `prompt_cache_key` 映射均禁止覆盖认证及协议保留字段,且不会把整个租户压到单一缓存 Key。能力或 Redis 查询异常时 fail-open 继续转发,并保留 Repository 降级路径。 + +运行时通过以下配置独立启用,不要求 AI Job 队列同时使用 Redis: + +```text +ASTER_ROUTING_AFFINITY_DRIVER=redis +ASTER_REDIS_URL=redis://:/ +ASTER_REDIS_NAMESPACE=asterrouter +``` + +### 7.3 路由顺序 + +```text +Canonical Auth / Request + -> 治理、模型、健康、余额和容量硬过滤 + -> Canary/Active 有效价格决策重排 + -> 当前会话采购账号亲和是否仍在合格候选 + -> 当前客户供应商亲和是否仍在合格候选 + -> Capacity Permit + -> 请求成功后刷新绑定 +``` + +没有会话键时: + +- 供应商层仍使用客户亲和。 +- 不建立采购账号级绑定,避免把客户的全部并发请求长期锁到一个我方账号。 +- 不读取或保存完整 Prompt 来制造隐式长期会话。 + +### 7.4 亲和中断 + +统一原因: + +```text +account_disabled, balance_blocked, health_failed, circuit_open +rpm_exhausted, tpm_exhausted, concurrency_exhausted +model_incompatible, policy_changed, price_degraded +cache_degraded, binding_expired, emergency_failover +``` + +每次中断进入 Trace。经济切换只对尚无有效绑定的客户 cohort 重新分桶;已有客户/会话绑定继续复用,紧急故障可以立即打破绑定。 + +验收:PostgreSQL 共享存储下多实例最终收敛到同一供应商/账号;Redis 下 64 路并发首次请求只有一个 owner,同 owner 刷新 TTL、不同 owner 不能覆盖;10,000 个账号绑定、抽样读取和清理已在真实 Redis 完成。熔断或容量不足时仍可安全 Fallback,租户之间绑定不泄漏。 + +## 8. Phase 5:切换决策与自动调度(已完成) + +### 8.1 决策模式 + +| 模式 | 行为 | +| --- | --- | +| `observe_only` | 计算候选和潜在差异,不改路由 | +| `recommend` | 生成管理员可审批建议 | +| `canary` | 用客户级 HMAC cohort key,把配置比例的未绑定客户发往候选 | +| `balanced` | 在质量门禁后综合有效成本和缓存经济性 | +| `cost_first` | 在硬门禁内优先有效采购成本 | +| `fixed_route` | 保持固定供应商,只在硬故障时切换 | + +### 8.2 排序输入 + +决策创建时要求管理员分别确认公共 `gateway_model` 和证据 `upstream_model`。当前/候选账号列表先按 `upstream_model + protocol` 过滤并去重,服务端再按 `provider_account_id + upstream_model + protocol` 精确校验。决策列表从 `candidate_snapshot_id` 派生上游模型;热路径只使用 `gateway_model + protocol` 命中决策,不能将两者互换。 + +硬过滤后按以下顺序: + +1. 强制路由与企业策略。 +2. 当前健康会话/客户亲和。 +3. 可信的标准化有效成本。 +4. 缓存经济收益和号池亲和一致性。 +5. 错误率、延迟、容量余量和价格新鲜度。 +6. 同等级候选使用现有权重分散。 + +当没有候选在有效成本上达到显著改善阈值时,优先选择缓存账单已验证、号池亲和更稳定的供应商,而不是回到宣传倍率排序。 + +MVP 默认策略为:`min_cache_hit_rate_improvement=0.10`、`min_affinity_improvement=0.10`、`max_cache_tiebreak_cost_regression=0.02`、`max_error_rate_regression=0.005`、`max_p95_latency_regression=0.20`。缓存决胜要求命中率改善达标且候选净缓存节省率高于当前线路;高命中但无折价、缓存分项不完整或 Token 不守恒时不得以缓存胜出。缓存决胜不能绕过错误率、账单一致性、指标覆盖率、样本量和成本可信度门禁;除 `cost_first` 外,P95 缺失或相对当前线路越界同样保持当前线路。 + +### 8.3 滞回与回滚 + +- 提升需要连续多窗口满足样本、成本和质量门槛。 +- 经济降级也需要连续窗口,防止短时波动来回切换。 +- 硬故障立即 Fallback,不等待统计窗口。 +- Canary 只接收按客户级 HMAC 稳定分桶且当前无有效绑定的流量;旧绑定按 TTL 自然到期。 +- Canary 的有效成本、错误率或延迟越界时自动回到上一快照。 + +验收:相同决策快照可重放出相同候选顺序;自动切换有审计、回滚和 Kill Switch。 + +当前状态:推荐、Canary、激活、候选重排、人工回滚、连续窗口监控和自动动作已交付。策略持久化 `evaluation_interval_minutes`、`promotion_window_count`、`degradation_window_count` 与 `automatic_actions_enabled`;自动动作默认关闭。每个窗口保存成本、缓存、亲和、错误率、P95、覆盖率和账单一致率证据;`decision_id + window_end` 唯一约束和事务 CAS 防止多实例重复累计或覆盖人工动作。缺失样本和证据归类为 `inconclusive`,不会自动回滚。 + +## 9. Phase 6:管理界面(MVP 已完成) + +实现以[管理端原型](./ui-prototype.md)为交互基线,并保持与现有管理端导航、表格、抽屉、权限和审计模式一致。 + +### 9.1 有效价格页 + +在同一表格内比较: + +- 供应商、采购账号、模型、协议。 +- 标称倍率、账单倍率、工作负载有效倍率、标准化有效倍率。 +- 缓存读写价格、观测有效价格、真实总成本。 +- `cost_available` 明确区分零成本与缺少证据。 +- 数据可信度、样本量、覆盖率和更新时间。 + +默认按标准化有效成本排序;标称倍率只作为参考列,不使用醒目的“最便宜”标签。 + +### 9.2 缓存质量页 + +- 缓存能力状态、号池亲和等级。 +- 生产与探针两套命中率。 +- Token 命中率、写读比、净节省、账单一致率。 +- 字段缺失、疑似剥离、疑似号池碎片化和最近探针失败。 +- 提供亲和传输、亲和字段、缓存控制模式和 Usage Schema 配置入口;号池等级与生产指标保持系统只读。 + +### 9.3 切换建议页 + +- 保持/推荐/Canary/提升/降级状态。 +- 当前与候选的有效成本和质量差。 +- 当前与候选的错误率和成功请求 P95 延迟。 +- 阈值、样本和原因码。 +- 审批、启动 Canary、回滚和暂停自动调度。 + +### 9.4 第三方账单源页 + +- 先对采购账号执行只读 Adapter 检测,再保存 `observe_only/active/disabled` 配置。 +- 自动同步开关默认关闭,间隔限制为 60-86400 秒;保存使用版本 CAS,防止覆盖其他管理员或 Worker 的更新。 +- 支持立即同步,并展示 `running/succeeded/failed/lease_expired` 运行历史、稳定错误码和触发来源。 +- 分开展示钱包余额、API Key 配额、订阅周期额度,以及模型/周期聚合历史;币种不依赖余额字段存在。 +- 聚合金额保持证据语义,不生成 `provider_billing_lines`,不参与请求级精确对账。 + +## 10. 测试策略 + +### 后端单元测试 + +- OpenAI、Anthropic、Gemini JSON/SSE Usage Fixture。 +- 缓存字段缺失、零、字符串错误、溢出和部分字段。 +- 价格、倍率、盈亏平衡和币种舍入。 +- 对账精确、派生、未分配和冲突。 +- 探针状态机、预算、冷却和负对照。 +- 亲和绑定、过期、HMAC 隔离、容量等待和故障转移。 +- 切换样本门槛、滞回、Canary 和回滚。 +- 账单源 CAS、并发认领、租约过期恢复、旧 worker fencing、失败无快照和错误正文脱敏。 +- 账单健康原因码、observe-only 不过滤、active 硬阻断、额度语义、过期证据冻结经济切换,以及最新余额 Memory/PostgreSQL 契约。 + +### PostgreSQL 与迁移测试 + +- 新旧 Usage 兼容读取。 +- Nullable 缓存字段不被默认 0 污染。 +- 不可变价格快照和账单幂等导入。 +- Rollup 可从原始事实重算。 +- `062` 重复执行、状态/区间/唯一约束、runtime schema parity,以及账单源重启持久化。 +- Memory nearest-rank P95 与 PostgreSQL `PERCENTILE_DISC(0.95)` 对成功请求采用一致口径,并排除错误请求。 + +### Gateway 契约测试 + +- 不修改客户请求中未知字段。 +- 只有 Adapter 声明后才添加/翻译亲和字段。 +- SSE 事件字节、顺序和 Flush 行为不回归。 +- 上游提交点之后不会因经济策略重复调用。 +- Trace 包含选择价格、缓存状态和亲和中断原因。 + +### UI 测试 + +- 标称倍率与真实有效倍率并列且口径清晰。 +- 缺少账单时不显示伪精确单价。 +- 小样本、过期和冲突有明显状态。 +- 账单源配置、自动同步、手动同步、失败错误码、余额和聚合历史可见。 +- 账单源路由健康、硬阻断、自动经济切换资格和原因码可见;移动视图不发生横向溢出。 +- 桌面与移动视口无表格/数字溢出。 + +### 远程与性能测试 + +- PostgreSQL 生产迁移和回滚演练。 +- Redis 多实例亲和一致性、故障降级和 10k 账号绑定已验证;百万绑定规模下的内存、Key 过期和热路径延迟仍待长期压测。 +- Rollup 与账单同步不阻塞 Gateway 主链路。 + +## 11. 灰度顺序 + +1. 只上线 Usage 和缓存字段采集,不改变路由。 +2. 上线采购报价和账单对账,只展示真实倍率。 +3. 对少量测试账号启用低预算探针。 +4. 配置 `ASTER_ROUTING_AFFINITY_DRIVER=redis`,灰度观察客户/会话亲和与 Repository 降级指标。 +5. 启用 `observe_only`,对比当前路由与建议路由至少 7 天。 +6. 管理员批准后,对未绑定客户 cohort 做 1% -> 5% -> 20% Canary。 +7. 达到成本、质量和账单一致性门槛后才允许自动提升。 + +任一阶段都提供独立 Kill Switch:缓存字段透传、探针、有效成本排序、客户亲和和自动切换可以分别关闭。 + +## 12. 完成标准 + +- 能回答每家第三方在指定模型上的标称倍率和真实有效倍率。 +- 能区分缓存字段缺失、明确为 0、缓存写、缓存读和账单折价。 +- 能判断缓存问题主要来自客户工作负载、AsterRouter 路由打散,还是第三方疑似号池碎片化。 +- 同一客户尽量保持同一供应商,同一会话尽量保持同一采购账号。 +- 健康、容量和策略变化时可以有证据地打破亲和并 Fallback。 +- 没有显著更便宜供应商时,能优先选择缓存经济性和号池亲和更好的供应商。 +- 自动切换不使用宣传倍率,且所有选择可回放、可灰度、可回滚。 diff --git a/docs/roadmap/effectivepricing/prototype.html b/docs/roadmap/effectivepricing/prototype.html new file mode 100644 index 0000000..827c0c3 --- /dev/null +++ b/docs/roadmap/effectivepricing/prototype.html @@ -0,0 +1,423 @@ + + + + + + AsterRouter 有效价格与缓存感知路由原型 + + + +
+ +
+
+
管理端 / 推理与路由 / 有效价格
+
+
+
+
+

有效价格与缓存感知路由

把采购账单、缓存 Usage、号池风险和客户亲和放到同一份路由证据中。

+
+
+ + +
+
+
+
+
+
+ +
+
+
官方未缓存等价成本¥42.80 / M基准已验证
+
当前生产有效成本¥18.40 / M较标称低 21.8%
+
账单覆盖96.4%12,284 / 12,742 请求
+
同客户供应商复用率91.7%亲和窗口 24 小时
+
+
+

供应商有效价格对比

默认按标准化有效成本排序,标称倍率只作为采购报价参考。

数据截至 18:30
+
+ + + + + + + +
供应商 / 采购账号标称倍率账单倍率有效倍率有效成本 / M缓存价格结构缓存读占比写 / 读可信度建议
渠道 A采购 1 · claude-3-5-sonnet
0.20x宣传价0.61x账单 94.1%0.68x标准化¥29.10无缓存 ¥31.80 · 净节省 8.5%读 0.80x写 5m 1.25x · 1h 2.00x12.4%3.2k 请求1.72derived
渠道 B采购 2 · claude-3-5-sonnet
0.55x合同价0.54x账单 99.3%0.43x标准化¥18.40无缓存 ¥42.80 · 净节省 57.0%读 0.10x写 5m 1.25x · 1h 2.00x67.8%12.8k 请求0.21billed
渠道 C采购 3 · claude-3-5-sonnet
0.38x销售报价账单未接入0.47x*样本推导¥20.10*无缓存 ¥42.80 · 净节省 —读 0.25x写价缺失 · 不可自动切换59.1%*820 请求0.34estimated
+
+
+

* 估算或样本不足的数字不会直接触发自动切换;点击行操作查看价格快照、Usage、账单匹配和路由 Trace。

+
+ + + + + + + + +
+
+
+ +
+
+
+
+
+ + + + diff --git a/docs/roadmap/effectivepricing/ui-prototype.md b/docs/roadmap/effectivepricing/ui-prototype.md new file mode 100644 index 0000000..0cba757 --- /dev/null +++ b/docs/roadmap/effectivepricing/ui-prototype.md @@ -0,0 +1,443 @@ +# 管理端原型 + +> 本原型对应 [第三方 API 有效价格与缓存感知路由](./README.md)。页面中的供应商名称和数值均为示意数据,不代表真实报价或评测结果。 + +[总体方案](./README.md) · [数据模型与指标](./data-model-and-metrics.md) · [实施计划](./implementation-plan.md) + +## 0. 可浏览原型图 + +原型已整理为一个无需后端即可打开的静态页面:[prototype.html](./prototype.html)。页面包含五个 Tab、价格证据抽屉、完整采购价弹窗、第三方缓存能力配置、Canary 审批弹窗、受控探针和账单源能力检测;账单源历史区同时展示路由健康、硬阻断、自动经济切换资格和原因码,所有数值均为示意数据。 + +桌面端有效价格页: + +![有效价格桌面端原型](./assets/effective-pricing-desktop.png) + +桌面端缓存质量页: + +![缓存质量桌面端原型](./assets/cache-quality-desktop.png) + +桌面端缓存感知采购价: + +![缓存感知采购价原型](./assets/procurement-price-dialog-desktop.png) + +![缓存感知采购价移动端原型](./assets/procurement-price-dialog-mobile.png) + +桌面端第三方缓存能力配置: + +![第三方缓存能力配置原型](./assets/cache-capability-dialog-desktop.png) + +![第三方缓存能力配置移动端原型](./assets/cache-capability-dialog-mobile.png) + +桌面端切换中心: + +![切换中心桌面端原型](./assets/switch-center-desktop.png) + +移动端有效价格摘要: + +![有效价格移动端原型](./assets/effective-pricing-mobile.png) + +桌面端受控探针预算确认: + +![受控探针预算确认桌面端原型](./assets/probe-budget-dialog-desktop.png) + +移动端受控探针预算确认: + +![受控探针预算确认移动端原型](./assets/probe-budget-dialog-mobile.png) + +桌面端账单源路由健康: + +![账单源路由健康桌面端原型](./assets/billing-source-routing-health-desktop.png) + +移动端账单源路由健康: + +![账单源路由健康移动端原型](./assets/billing-source-routing-health-mobile.png) + +真实 Vue 管理端采购价弹窗验收: + +![真实管理端采购价桌面验收](./assets/runtime-effective-pricing-desktop.png) + +![真实管理端采购价移动验收](./assets/runtime-effective-pricing-mobile.png) + +截图验收口径:桌面端覆盖 `1440×900` 和 `1280×800`,移动端覆盖 `390×844`;移动端不使用横向宽表,缓存缺失、估算价格和疑似号池碎片均保留明确文字状态。采购价弹窗必须覆盖缓存读、5 分钟写、1 小时写、输出、请求费、充值系数、官方基准和来源;能力弹窗必须覆盖亲和传输、字段、缓存控制和 Usage Schema。探针弹窗必须展示账号、模型、协议、合成前缀 Token、金额上限、预计请求量、每日预算和冷却,并在管理员明确确认前禁用提交。 + +## 1. 信息架构 + +```mermaid +flowchart LR + P[有效价格] + C[缓存质量] + S[切换中心] + B[账单源] + D[供应商详情] + E[证据抽屉] + + P -->|查看缓存依据| C + P -->|查看供应商| D + P -->|评估切换| S + P -->|核对采购凭据能力| B + C -->|查看账号/探针| D + S -->|核对价格与账单| E + D -->|请求/账单/探针证据| E +``` + +建议在现有管理端“推理与路由”下增加一级入口“有效价格”,页内使用五个 Tab: + +```text +有效价格 | 缓存质量 | 切换中心 | 探针记录 | 账单源 +``` + +供应商详情复用现有 Provider Account 详情入口,不再创建一套供应商管理页面。 + +## 2. 有效价格对比 + +目标:第一眼看出“宣传倍率”和“我们实际支付的倍率”是否一致,并能判断缓存差异是否足以改变供应商选择。 + +```text +┌──────────────────────────────────────────────────────────────────────────────────────────────┐ +│ 有效价格 [导出] [策略设置] │ +│ [模型: claude-* ▼] [协议: Anthropic ▼] [窗口: 24 小时 ▼] [客户: 全部 ▼] [仅可比 ✓] │ +├──────────────────────────────────────────────────────────────────────────────────────────────┤ +│ 当前模型基准 官方未缓存等价成本 ¥42.80/M 数据截至 18:30 账单覆盖 96.4% │ +├──────────┬────────┬────────┬────────┬──────────┬────────┬────────┬────────┬─────────────────┤ +│ 供应商 │ 标称 │ 账单 │ 有效 │ 有效/无缓存│ 缓存读│ 写/读 │ 可信度 │ 建议 │ +│ /账号 │ 倍率 │ 倍率 │ 倍率 │ /净节省 │ 占比 │ 比 │ │ │ +├──────────┼────────┼────────┼────────┼──────────┼────────┼────────┼────────┼─────────────────┤ +│ ● 渠道 A │ 0.20x │ 0.61x │ 0.68x │ ¥29.10 │ 12.4% │ 1.72 │ 派生 │ 降低权重 │ +│ 采购 1 │ 宣传价 │ │ │ ¥31.80/8.5%│ │ │ 3.2k │ [查看依据] │ +├──────────┼────────┼────────┼────────┼──────────┼────────┼────────┼────────┼─────────────────┤ +│ ✓ 渠道 B │ 0.55x │ 0.54x │ 0.43x │ ¥18.40 │ 67.8% │ 0.21 │ 实扣 │ 当前默认 │ +│ 采购 2 │ │ │ │ ¥42.80/57%│ │ │ 12.8k │ [查看依据] │ +├──────────┼────────┼────────┼────────┼──────────┼────────┼────────┼────────┼─────────────────┤ +│ 渠道 C │ 0.38x │ - │ 0.47x* │ ¥20.10* │ 59.1% │ 0.34 │ 估算 │ 建议 Canary │ +│ 采购 3 │ │ │ │ │ │ │ 820 │ [评估切换] │ +└──────────┴────────┴────────┴────────┴──────────┴────────┴────────┴────────┴─────────────────┘ + * 估算值不参与正式节省 KPI;鼠标悬停显示价格版本、样本窗口和计算口径。 +``` + +### 2.1 展示规则 + +- 默认排序使用“标准化有效成本”,不是标称倍率。 +- 标称倍率仅作为普通列,不使用绿色或“最低价”强化。 +- `exact/derived/estimated/unallocated/unknown` 以文字显示,颜色只是辅助。 +- 缓存价格结构直接显示缓存读取、5 分钟写入、1 小时写入相对未缓存输入的倍率;写入溢价不能被命中率隐藏。 +- `cost_available=false` 显示“未配置采购价”,与已验证的零成本严格区分。 +- 样本不足、价格过期和账单不一致时,价格后显示 `*`,操作降级为“观察”。 +- 行点击打开证据抽屉;“评估切换”只进入决策页,不直接改变生产路由。 + +### 2.2 价格拆解抽屉 + +```text +┌───────────────────────────────────────────────┐ +│ 渠道 B / 采购 2 [×] │ +├───────────────────────────────────────────────┤ +│ 工作负载有效倍率 0.43x │ +│ 标称倍率 0.55x │ +│ 差异 -21.8%│ +├───────────────────────────────────────────────┤ +│ 输入成本拆解 │ +│ 未缓存输入 3.2M × ¥3.00/M ¥9.60 │ +│ 缓存写入 0.4M × ¥3.75/M ¥1.50 │ +│ 缓存读取 7.1M × ¥0.30/M ¥2.13 │ +│ 输出 0.8M × ¥15.00/M ¥12.00 │ +├───────────────────────────────────────────────┤ +│ 账单匹配 12,284 / 12,742 请求 96.4% │ +│ 价格快照 price_20260714_1830 │ +│ 数据窗口 2026-07-13 18:30 → 2026-07-14 18:30 │ +│ [查看账单样本] [查看计算明细] │ +└───────────────────────────────────────────────┘ +``` + +没有成本分项时只显示“请求总成本、请求数、平均请求成本”,隐藏输入拆解,不能用总额倒推出虚假单价。 + +### 2.3 缓存感知采购价弹窗 + +管理员录入采购报价时必须一次覆盖以下真实计价维度: + +```text +供应商/采购账号 上游模型 协议 币种 +未缓存输入 缓存读取 缓存写入 5m 缓存写入 1h +输出 单请求费 标称倍率 充值实付系数 +官方输入基准 官方输出基准 可信度 报价来源/合同编号 +``` + +任一缓存写价格缺失时,页面可以展示现有观测,但不得把缓存净节省标为可用于自动切换。明确为零的费用显示为零,不能显示成缺失。 + +## 3. 缓存与号池质量 + +目标:区分“客户 Prompt 不适合缓存”“AsterRouter 打散会话”和“第三方内部号池疑似打散”三类问题。 + +```text +┌──────────────────────────────────────────────────────────────────────────────────────────────┐ +│ 缓存质量 [生产流量 ●] [受控探针 ○] [窗口: 7 天 ▼] [最小样本: 200 ✓] │ +├──────────┬──────────┬────────┬────────┬────────┬────────┬────────┬───────────┬──────────────┤ +│ 供应商 │ 能力状态 │ 指标 │ 合格命 │ Token │ 写/读 │ 账单一 │ 号池亲和 │ 判断 │ +│ /模型 │ │ 覆盖率 │ 中率 │ 命中率 │ 比 │ 致率 │ 一致率 │ │ +├──────────┼──────────┼────────┼────────┼────────┼────────┼────────┼───────────┼──────────────┤ +│ 渠道 A │ degraded │ 99.2% │ 18.0% │ 12.4% │ 1.72 │ 94.1% │ 31.5% │ 疑似号池碎片 │ +│ claude-* │ │ │ │ │ │ │ │ [查看探针] │ +├──────────┼──────────┼────────┼────────┼────────┼────────┼────────┼───────────┼──────────────┤ +│ 渠道 B │ billed │ 98.7% │ 74.2% │ 67.8% │ 0.21 │ 99.3% │ 96.8% │ 正常 │ +│ claude-* │ verified │ │ │ │ │ │ │ [查看探针] │ +├──────────┼──────────┼────────┼────────┼────────┼────────┼────────┼───────────┼──────────────┤ +│ 渠道 C │ observed │ 61.3% │ 69.5%* │ 59.1%* │ 0.34 │ - │ 82.0%* │ 样本不足 │ +│ claude-* │ │ │ │ │ │ │ │ [继续观察] │ +└──────────┴──────────┴────────┴────────┴────────┴────────┴────────┴───────────┴──────────────┘ +``` + +### 3.1 第三方缓存与亲和能力配置 + +缓存质量列表每行提供“配置能力”,用于录入第三方明确公开或经过受控验证的字段: + +```text +供应商/采购账号 上游模型 协议 能力状态 +亲和传输 none/header/body 亲和字段 +缓存控制 passthrough_if_present/prompt_cache_key +Usage Schema +``` + +号池亲和等级、生产命中率、账单一致率和探针结果是系统观测字段,只读展示;管理员不能通过配置弹窗把供应商手工标成 `verified`。亲和字段必须通过保留字段校验,且只有能力为 `accepted/observed/billed_verified` 时才进入请求注入。 + +### 3.2 单供应商时间线 + +```text +缓存读占比 +80% ┤ ╭────── 渠道 B +60% ┤ ╭─────────────────╯ +40% ┤ ─ ─ ─ ─ 评估下限 +20% ┤╭──╮ ╭──╮ ╭── 渠道 A + 0% ┼╯ ╰──╯ ╰──╯──────────────────────────────── + 7/08 7/09 7/10 7/11 7/12 7/13 7/14 + + ● 7/11 14:20 渠道 A 同会话探针连续 3 次 Miss + ● 7/12 09:10 渠道 A 账单有效倍率升至 0.68x + ● 7/12 11:00 系统建议未绑定客户 cohort 切到渠道 B +``` + +时间线必须允许同时叠加“亲和中断事件”和“价格快照变更”,避免把 AsterRouter 主动 Fallback 误判为第三方缓存失败。 + +## 4. 切换中心 + +目标:让管理员在执行前看清收益、风险、证据和影响范围。 + +```text +┌──────────────────────────────────────────────────────────────────────────────────────────────┐ +│ 客户网关模型:claude-sonnet 上游模型:claude-3-5-sonnet Anthropic Messages │ +│ 切换评估只比较同一上游模型和协议;网关模型用于命中客户请求。 状态:建议 Canary │ +├───────────────────────────────┬──────────────────────────────────────────────────────────────┤ +│ 当前:渠道 A / 采购 1 │ 候选:渠道 B / 采购 2 │ +│ 有效倍率 0.68x │ 有效倍率 0.43x │ +│ 24h 实扣 ¥2,910.00 │ 同流量预计 ¥1,840.00 │ +│ 缓存 Token 命中 12.4% │ 缓存 Token 命中 67.8% │ +│ 缓存净节省 8.5% │ 缓存净节省 57.0% │ +│ 号池亲和一致率 31.5% │ 号池亲和一致率 96.8% │ +├───────────────────────────────┴──────────────────────────────────────────────────────────────┤ +│ 门禁 │ +│ ✓ 模型/协议可比 ✓ 账单验证 ✓ 样本 12.8k ✓ 错误率未恶化 ✓ P95 延迟未越界 │ +│ ✓ 连续 3 个窗口满足 ✓ 候选余额充足 ! 现有有效绑定 286 个,将按 TTL 到期 │ +├──────────────────────────────────────────────────────────────────────────────────────────────┤ +│ Canary 比例 [ 5% ▼] 客户稳定分桶 [✓] 观察窗口 [2 小时 ▼] │ +│ 回滚条件:错误率 +0.5pp / P95 +20% / 有效成本不再改善 │ +│ [保持当前] [启动 Canary] │ +└──────────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +新建评估弹窗必须把“网关模型”和“供应商上游模型”作为两个独立字段。网关模型不从报告行自动填写,管理员需按现有模型路由显式确认;上游模型和协议从报告证据选择,当前/候选下拉框只显示该维度下可比较的不同采购账号。 + +质量门禁原因码使用实现中的精确值:`error_rate_regression_exceeded`、`p95_latency_regression_exceeded`、`p95_latency_evidence_missing`。只有成本优势不足、缓存/亲和改善达标且全部质量门禁通过时,才显示 `cache_quality_tiebreaker`。 + +### 4.1 状态流 + +```mermaid +stateDiagram-v2 + [*] --> ObserveOnly + ObserveOnly --> Recommend: 达到样本和改善阈值 + Recommend --> Canary: 管理员批准 + Canary --> Active: 连续窗口通过 + Canary --> RolledBack: 成本/质量越界 + Active --> Degraded: 连续窗口劣化 + Degraded --> Active: 恢复且通过滞回 + Active --> EmergencyFailover: 健康硬故障 + EmergencyFailover --> ObserveOnly: 稳定后重新评估 +``` + +### 4.2 操作约束 + +- “启动 Canary”是唯一高强调操作。 +- 默认只对未绑定客户做稳定分桶;现有健康客户/会话保持原绑定。 +- 紧急故障转移不显示成本审批,但仍展示触发原因和回滚目标。 +- `estimated/unallocated/unknown` 成本不能直接启用自动提升。 +- 策略弹窗提供独立“自动提升与自动回滚”开关、评估间隔、连续健康窗口和连续劣化窗口;开关默认关闭,且只在 `canary/balanced/cost_first` 模式生效。 +- 切换卡展示最近窗口结论、健康/劣化 streak 和窗口结束时间,并可打开窗口证据抽屉查看成本、缓存命中、错误率、原因码和自动动作。 +- 每次审批显示策略版本,并写入 Audit Log。 + +## 5. 账单源检测 + +目标:在接入自动同步前先回答“这个采购 API Key 实际能提供什么”,避免看见一个余额字段就误认为已经接入精确账单。 + +```text +┌────────────────────────────────────────────────────────────────────────────┐ +│ 第三方账单源检测 │ +│ [采购账号: 渠道 A / 采购 1 ▼] [自动检测] │ +├────────────────────────────────────────────────────────────────────────────┤ +│ schema_match sub2api_compatible · sub2api_v1_usage · 证据 0123456789abcdef│ +├────────────┬────────────┬────────────┬────────────┬─────────────────────────┤ +│ 逐请求账单 │ 聚合用量 │ 余额/额度 │ 增量同步 │ 价格 Feed │ +│ 不可用 │ 可用 │ 可用 │ 不可用 │ 不可用 │ +├────────────────────────────────────────────────────────────────────────────┤ +│ API Key 剩余额度 $7.50 │ +├────────┬────────┬──────────┬──────────┬──────────┬──────────┬──────────────┤ +│ 范围 │ 请求 │ 输入 │ 输出 │ 缓存读取 │ 模型原价 │ 第三方实扣 │ +│ 累计 │ 10 │ 500 │ 80 │ 200 │ $8.00 │ $4.25 │ +│ claude-sonnet · 近 30 天│ 7 │ 350 │ 60 │ 180 │ $6.50 │ $3.25 │ +└────────┴────────┴──────────┴──────────┴──────────┴──────────┴──────────────┘ +``` + +展示规则: + +- `schema_match` 只说明响应结构兼容,不能显示成“已识别第三方就是 sub2api”。 +- 钱包余额、API Key 配额和订阅周期额度使用三个不同标签。 +- 聚合 `actual_cost` 只能进入校验证据,不能显示成已导入账单行。 +- 逐请求账单、增量游标和价格 Feed 不存在时必须明确显示“不可用”。 +- 失败响应不展示第三方原始 Body,避免错误页中的 Secret 或账户信息泄漏。 + +同步与历史证据区在余额历史前增加路由健康摘要: + +```text +┌────────────────────────────────────────────────────────────────────────────┐ +│ 路由健康 降级 硬阻断 否 自动经济切换 否 │ +│ 证据观测 7/15 16:00 路由依据:账单同步连续失败 · 证据已过期 │ +└────────────────────────────────────────────────────────────────────────────┘ +``` + +`observe_only/disabled` 显示为“仅观测/已停用”,不会被误标记为故障;硬阻断只用于明确的 Key 无效、认证拒绝或额度耗尽。同步失败、过期和缺失证据只显示为“降级”,并解释为“禁止自动经济切换”,不宣称第三方推理不可用。 + +## 6. 供应商详情 + +详情页在现有 Provider Account 页面增加以下 Tab: + +```text +概览 | 路由与容量 | 有效价格 | 缓存 | 账单对账 | 探针 | Trace +``` + +“缓存”Tab: + +```text +┌────────────────────────────────────────────────────────────────────────────┐ +│ 渠道 B / 采购 2 / claude-* billed_verified │ +├────────────────────────────────────────────────────────────────────────────┤ +│ 亲和传输 body.session_id 缓存模式 cache_control passthrough │ +│ 首次命中 2026-07-08 10:42 最近验证 2026-07-14 18:12 │ +│ 号池亲和 verified 数据新鲜度 18 分钟 │ +├────────────────────────────────────────────────────────────────────────────┤ +│ 最近探针 │ +│ 18:12 warm 写 4,096 / 读 0 实扣 ¥0.0150 │ +│ 18:13 reuse 写 0 / 读 4,096 实扣 ¥0.0012 │ +│ 18:14 negative_control 写 4,096 / 读 0 实扣 ¥0.0150 │ +│ [查看完整证据] [重跑] │ +└────────────────────────────────────────────────────────────────────────────┘ +``` + +“重跑”先进入预算确认弹窗,显示预计 Token、金额、冷却和当日已用额度,不能直接发送探针。 + +## 7. 证据抽屉 + +所有列表使用同一个证据抽屉组件,避免不同页面各自解释一套成本: + +```text +摘要 | 价格快照 | Usage 样本 | 账单匹配 | 探针序列 | 路由 Trace +``` + +最小字段: + +- 时间窗口、模型、协议、供应商和采购账号。 +- 标称报价来源、价格快照 ID 和有效期。 +- Usage Schema、缓存字段是否存在及归一化状态。 +- 账单行匹配数量、未匹配数量和可信度。 +- 亲和绑定复用/中断次数及原因。 +- 切换决策 ID、阈值和策略版本。 + +证据抽屉不能显示客户 Prompt、原始 API Key 或第三方凭据。 + +## 8. 空状态与异常状态 + +| 状态 | 页面表现 | 可用操作 | +| --- | --- | --- | +| 无采购报价 | 有效价格显示“未配置采购价” | 配置价格源 | +| 有报价无账单 | 显示估算值和 `estimated` | 接入账单/继续观察 | +| 缓存字段缺失 | 命中率显示“-”,不是 0% | 查看能力/运行探针 | +| 明确返回 0 | 显示 0%,并保留样本量 | 查看工作负载/探针 | +| 样本不足 | 数值加 `*`,禁止自动切换 | 延长观察窗口 | +| 价格过期 | 行降级,退出自动排序 | 同步价格 | +| 账单不一致 | 显示阻断状态 | 查看对账异常 | +| 疑似号池碎片化 | 显示风险说明,不断言内部账号 | 查看同会话探针 | +| 供应商硬故障 | 显示 Fallback 结果和原因 | 查看 Trace/恢复评估 | + +## 9. 移动端原型 + +移动端不压缩桌面宽表,改为供应商摘要列表和详情页: + +```text +┌──────────────────────────────┐ +│ 有效价格 [claude-* ▼] │ +├──────────────────────────────┤ +│ 渠道 B / 采购 2 当前 │ +│ 有效倍率 0.43x │ +│ 标称 0.55x · 实扣验证 │ +│ 缓存命中 67.8% · 12.8k 样本 │ +│ [查看依据] │ +├──────────────────────────────┤ +│ 渠道 C / 采购 3 候选 │ +│ 有效倍率 0.47x* │ +│ 标称 0.38x · 估算 │ +│ 缓存命中 59.1% · 820 样本 │ +│ [查看依据] [评估] │ +└──────────────────────────────┘ +``` + +移动端只保留核心比较字段;完整价格拆解、账单和探针进入独立详情页,不使用横向滚动宽表。 + +## 10. 前端落点 + +建议路由: + +```text +/admin/effective-pricing +/admin/effective-pricing/cache +/admin/effective-pricing/switches +/admin/effective-pricing/probes +/admin/provider-accounts/:id?tab=effective-price +``` + +建议组件边界: + +```text +EffectivePricingView + EffectivePricingFilters + EffectivePricingTable + PricingEvidenceDrawer + +CacheQualityView + CacheQualityTable + CacheQualityTimeline + CacheProbeEvidence + +SupplierSwitchView + SwitchComparison + SwitchGuardrails + CanaryApprovalDialog +``` + +筛选、表格、抽屉和审批对话框复用现有管理端样式与权限模型。第一阶段不做自定义仪表盘编辑器,也不允许从价格列表直接绕过审批切生产路由。 + +## 11. 原型验收 + +- 用户无需打开账单详情就能看出标称倍率与真实有效倍率的差异。 +- 用户可以录入并比较缓存读、缓存写、请求费和充值折扣,不再只填未缓存输入价。 +- 管理员可以从缓存质量页配置经过确认的第三方亲和字段,系统观测的号池等级保持只读。 +- 缓存字段缺失不会显示成 0% 命中。 +- 生产命中率和受控探针结果可以切换查看,且不会混在同一分母。 +- 号池风险使用“疑似”表达,并能追溯同会话探针和亲和中断事件。 +- 切换前能看见成本、缓存、质量、样本、现有会话和回滚条件。 +- 经济切换默认只作用于未绑定客户 cohort;紧急故障转移有独立状态。 +- 桌面宽表和移动摘要均不截断供应商名称、倍率、金额和状态文字。 +- 账单源页能直接区分“不能路由”和“仍可推理但不能自动切价”;硬阻断、经济切换资格和原因码在桌面/移动端均可读。 diff --git a/docs/roadmap/models/README.md b/docs/roadmap/models/README.md new file mode 100644 index 0000000..61a25f3 --- /dev/null +++ b/docs/roadmap/models/README.md @@ -0,0 +1,414 @@ +# 模型目录、库存与路由治理 + +> 状态:已实施 +> 最后更新:2026-07-16 +> 相关指引:[模型更新与运维指南](./UPDATE_GUIDE.md) + +## 1. 结论 + +AsterRouter 不维护一份写死在代码或前端中的“最新完整模型列表”。模型发布速度、账号授权、区域、套餐和上游兼容性都在变化,任何随版本发布的静态列表都会过期。 + +当前实现采用一个观察快照和四层路由事实: + +1. `ProviderConnection.models` 是连接级健康检查最近发现的模型快照,只用于观察,不是配置入口。 +2. `ProviderAccountModel` 保存某个采购账号真实发现或手工配置的上游模型库存。 +3. `ProviderAccount.models` 仍是网关热路径的已启用模型集合。 +4. `GatewayModel` 是对客户端稳定、可治理的公共模型标识。 +5. `ModelRoute` 显式连接公共模型、路由组、采购账号和上游模型。 + +新模型通过账号的 `/models` 能力动态进入库存,默认只预览、不自动启用;管理员确认后一次性同步。上游消失的模型标记为 `missing`,保留关联路由证据,不会被静默删除。 + +## 2. 为什么不是静态全集 + +“所有最新模型”不是一个全局事实,而是多个事实的交集: + +```text +供应商发布目录 + ∩ 账号实际授权 + ∩ 区域/套餐可用性 + ∩ 上游接口真实返回 + ∩ AsterRouter 管理员启用范围 + ∩ 对外 GatewayModel 与路由策略 +``` + +因此,本方案承诺的是“对每个账号持续获得最新、完整、可追溯的实际库存”,而不是“发布一份看起来很全但无法证明可用的名称表”。 + +## 3. 参考实现与取舍 + +| 项目 | 有价值的设计 | 不直接照搬的部分 | AsterRouter 选择 | +| --- | --- | --- | --- | +| OpenRouter | `/api/v1/models` 提供稳定 ID、能力、上下文、价格、生命周期;Provider endpoint 与公共模型分离;路由支持 order、fallback、延迟/吞吐排序和数据策略 | OpenRouter 是统一托管市场,能掌握 endpoint 全局事实;自托管网关通常只能看到自身账号 | 分离公共模型、账号库存和显式路由;库存以账号实测为准 | +| LiteLLM | 大规模可更新模型元数据目录;模型组、部署和 fallback 分层 | 中央 JSON 元数据不能证明某个账号此刻可调用 | 外部元数据未来可作为注释源,不能替代账号发现和健康事实 | +| Portkey Gateway / Models | Gateway 配置负责 fallback、负载均衡;模型仓库独立维护 2,000+ 模型的价格与配置 | 社区目录更新频繁,价格和能力仍需来源、版本和可信度 | 运行时库存与价格目录解耦,不把价格数据混入路由真相 | +| new-api | Channel/Ability 能表达模型可用性,并提供“已引用但缺少模型元数据”的检查 | Channel 字符串、Ability、模型元数据和 mapping 容易形成多处事实源 | 使用规范化库存表和 Route 外键,不依赖 JSON mapping 作为唯一关系 | +| sub2api | 支持额外模型、分组模型配置和模型映射链,适合中转号池 | 聚合字段便于配置,但来源、最后发现时间、缺失状态和路由影响不够直接 | 每个账号、每个模型一行,明确 source、availability、enabled 和时间证据 | + +2026-07-16 复核 OpenRouter 当前文档时,公共 Model 已同时返回 `canonical_slug`、模态、上下文、支持参数、价格和 `expiration_date`,Provider endpoint 则单独返回延迟、吞吐、uptime、量化和 Provider 级价格。这进一步说明“公共产品目录”和“可路由 Provider endpoint”必须分层,不能合并成一个模型字符串数组。 + +关键来源: + +- [OpenRouter Models API](https://openrouter.ai/docs/guides/overview/models) +- [OpenRouter Provider Routing](https://openrouter.ai/docs/guides/routing/provider-selection) +- [LiteLLM](https://github.com/BerriAI/litellm) 与其可更新的 [model_prices_and_context_window.json](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json) +- [Portkey Gateway](https://github.com/Portkey-AI/gateway) 与 [Portkey Models](https://github.com/Portkey-AI/models) +- 本地参考:[new-api/model/channel.go](/Users/coso/Documents/dev/go/new-api/model/channel.go)、[new-api/model/missing_models.go](/Users/coso/Documents/dev/go/new-api/model/missing_models.go) +- 本地参考:[sub2api channel monitor schema](/Users/coso/Documents/dev/go/sub2api/backend/ent/schema/channel_monitor.go)、[sub2api group schema](/Users/coso/Documents/dev/go/sub2api/backend/ent/schema/group.go) + +## 4. 设计原则 + +- 动态发现:从账号真实接口取得模型,不在前端写供应商模型数组。 +- 显式启用:发现事实和路由授权是不同动作,默认不自动扩大范围。 +- 稳定对外:客户端使用 `GatewayModel.model_id`,不直接依赖供应商名称。 +- 可追溯:保留来源、首次发现、最后发现、缺失和受影响路由。 +- 不静默破坏:上游缺失不自动删除模型或 Route。 +- 热路径简单:请求调度继续读取 `ProviderAccount.models + ModelRoute`,不在请求时调用 `/models`。 +- 原子变更:账号与库存同事务保存;批量路由先完整校验,再同事务写入。 +- 目录只读:Provider CRUD 即使收到兼容字段 `models` 也会忽略;更新连接配置会保留最近发现快照。 +- 失效关闭:active Route 只能绑定 active `GatewayModel`;disabled 公共模型只允许保留 disabled 历史 Route。 + +## 5. 系统架构 + +```mermaid +flowchart LR + Admin[管理员] + UI[管理端模型库存编辑器] + API[Admin Control API] + Discovery[Model Discovery Adapter] + Upstream[Provider /models] + Inventory[(provider_account_models)] + Account[(provider_accounts.models)] + ConnectionSnapshot[(provider_connections.models)] + Public[(gateway_models)] + Routes[(model_routes)] + ProviderHealth[连接健康检查] + AccountHealth[账号健康检查] + Gateway[Gateway 请求热路径] + + Admin --> UI --> API + API --> Discovery --> Upstream + Discovery --> API + API --> Inventory + API --> Account + ProviderHealth --> Discovery + ProviderHealth --> ConnectionSnapshot + AccountHealth --> Discovery + AccountHealth --> Inventory + AccountHealth --> Account + API --> Public + API --> Routes + Gateway --> Public + Gateway --> Routes + Gateway --> Account +``` + +解释: + +- Control Plane 负责发现、比较、审核和配置。 +- Data Plane 只读取已持久化事实,不等待上游目录接口。 +- `provider_account_models` 是库存与证据;`provider_accounts.models` 是启用快照。 +- `provider_connections.models` 仅是最近一次连接级发现快照,Provider 表单不允许手工维护它。 +- `gateway_models` 和 `model_routes` 是客户端稳定性与供应商替换的隔离层。 + +## 6. 领域边界 + +| 对象 | 单一职责 | 是否进入请求热路径 | +| --- | --- | --- | +| `ProviderConnection` | Provider 类型、Base URL、连接级凭证、状态和最近发现快照;快照不授权路由 | 是(模型快照否) | +| `ProviderAccount` | 采购账号、容量、健康、启用模型和凭证 | 是 | +| `ProviderAccountModel` | 账号模型库存、来源和发现证据 | 否 | +| `GatewayModel` | 对客户端公开的稳定模型 ID、模态和默认路由组 | 是 | +| `ModelRoute` | 公共模型到账号上游模型的显式映射 | 是 | +| `ProviderAccountHealthCheck` | 某次探测的模型集合、延迟和结果 | 否 | + +## 7. 数据模型 + +```mermaid +erDiagram + PROVIDER_CONNECTION ||--o{ PROVIDER_ACCOUNT : owns + PROVIDER_ACCOUNT ||--o{ PROVIDER_ACCOUNT_MODEL : inventories + PROVIDER_ACCOUNT ||--o{ PROVIDER_ACCOUNT_HEALTH_CHECK : observes + GATEWAY_MODEL ||--o{ MODEL_ROUTE : exposes + PROVIDER_ACCOUNT ||--o{ MODEL_ROUTE : serves + + PROVIDER_ACCOUNT { + text id PK + text provider_id FK + text models "enabled snapshot" + boolean auto_enable_new_models + text status + } + PROVIDER_ACCOUNT_MODEL { + text provider_account_id PK_FK + text model_id PK + text source "discovered|manual" + boolean enabled + text availability "available|missing|unverified" + timestamptz first_seen_at + timestamptz last_seen_at + timestamptz updated_at + } + GATEWAY_MODEL { + text id PK + text model_id UK + text modality + text default_route_group + text status + } + MODEL_ROUTE { + text id PK + text gateway_model_id FK + text provider_account_id FK + text route_group + text upstream_model + int priority + int weight + text status + } +``` + +数据库约束: + +- 库存主键为 `(provider_account_id, model_id)`。 +- `source` 仅允许 `discovered | manual`。 +- `availability` 仅允许 `available | missing | unverified`。 +- 账号删除时库存级联删除。 +- 批量 Route 在服务层检测现存重复和批次内重复,PostgreSQL 事务保证不部分写入。 + +## 8. 库存状态机 + +```mermaid +stateDiagram-v2 + [*] --> ManualUnverified: 管理员手工添加 + [*] --> DiscoveredAvailable: /models 首次发现 + ManualUnverified --> DiscoveredAvailable: 后续被 /models 返回 + DiscoveredAvailable --> DiscoveredAvailable: 再次发现,刷新 last_seen_at + DiscoveredAvailable --> DiscoveredMissing: 后续未被返回 + DiscoveredMissing --> DiscoveredAvailable: 再次出现 + ManualUnverified --> ManualUnverified: 上游不提供可验证目录 +``` + +`enabled` 与状态机正交:可用模型可以不启用,缺失模型也可以暂时保持启用,以便管理员先迁移 Route。 + +## 9. API 契约 + +| 方法 | 路径 | 语义 | +| --- | --- | --- | +| `GET` | `/admin/provider-accounts/:id/models` | 读取库存、自动启用配置和 Route 计数 | +| `POST` | `/admin/provider-accounts/:id/models/discover` | 实时发现并返回差异预览,不修改启用集合 | +| `POST` | `/admin/provider-accounts/:id/models/sync` | 重新发现、校验并原子保存启用集合与库存 | +| `POST` | `/admin/model-routes/bulk` | 最多 500 条,整批预校验后原子创建 Route | + +所有接口沿用现有 Admin 鉴权和审计边界。响应不会返回账号密钥。 + +API Key、治理策略和外部集成中的 allowlist/denylist 是兼容历史配置和分阶段部署的软引用,不承担模型存在性。管理端新建操作只提供 active `GatewayModel.model_id`;请求执行时 `ResolveGatewayModel` 再次只解析 active GatewayModel,因此直接 API 写入未知或已停用字符串也不能绕过公共模型目录或产生路由候选。 + +Provider CRUD 中的 `models` 仅为旧客户端 wire compatibility 保留,服务端忽略创建值并在更新时保留当前发现快照。`enabled_models=[]` 是合法同步请求,用于显式停用账号的全部模型;库存行和发现证据继续保留,账号因无可用 Route 不会进入调度。 + +## 10. 模型发现预览时序 + +```mermaid +sequenceDiagram + actor A as 管理员 + participant UI as Model Editor + participant API as Control API + participant S as Model Service + participant P as Provider /models + participant DB as Repository + + A->>UI: 点击“发现模型” + UI->>API: POST .../models/discover + API->>S: DiscoverProviderAccountModels + S->>DB: 读取账号、现有库存、活动 Route + S->>P: 带账号凭证请求模型目录 + P-->>S: 当前可用模型(可分页) + S->>S: 计算 added/missing/unchanged + S->>S: 计算 missing 影响的 Route IDs + S-->>API: 差异预览 + API-->>UI: 200 discovery + UI-->>A: 展示新增、缺失与路由警告 +``` + +预览不保存启用变更,避免一次网络探测直接改变生产路由面。 + +## 11. 同步应用时序 + +```mermaid +sequenceDiagram + actor A as 管理员 + participant UI as Model Editor + participant API as Control API + participant S as Model Service + participant P as Provider /models + participant DB as PostgreSQL + + A->>UI: 确认启用集合与自动启用开关 + UI->>API: POST .../models/sync + API->>S: enabled_models + auto_enable_new_models + S->>P: 重新发现,避免应用过期预览 + P-->>S: 当前目录 + S->>S: 清洗、去重、计算库存状态 + S->>DB: BEGIN + S->>DB: UPSERT provider_accounts + S->>DB: REPLACE provider_account_models + S->>DB: COMMIT + S-->>API: account + inventory + discovery + API-->>UI: 同步成功 +``` + +重新发现是有意设计:预览与点击应用之间,上游目录可能已变化。 + +## 12. 健康检查自动更新流程 + +```mermaid +flowchart TD + Start[启动账号健康检查] --> Config{账号、Provider、URL、凭证有效?} + Config -- 否 --> HealthError[记录 warning/error] + Config -- 是 --> Adapter{Provider 有安全 discovery adapter?} + Adapter -- 否,例如 Azure OpenAI --> Manual[保留手工库存并记录 warning] + Adapter -- 是 --> Discover[请求 /models] + Discover --> Success{请求和解析成功?} + Success -- 否 --> HealthError + Success -- 是 --> Diff[刷新 available/missing/last_seen_at] + Diff --> Auto{auto_enable_new_models?} + Auto -- 否 --> Keep[保持已启用集合] + Auto -- 是 --> Merge[把新发现模型合并进启用集合] + Keep --> Atomic[账号与库存原子保存] + Merge --> Atomic + Atomic --> HealthOK[保存健康检查与审计] +``` + +默认 `auto_enable_new_models=false`。启用自动模式意味着管理员接受供应商新模型进入账号可路由集合,但仍不会自动创建公共 `GatewayModel` 或 `ModelRoute`。 + +## 13. 请求路由流程 + +```mermaid +flowchart TD + Request[客户端请求 model] --> Resolve[解析 GatewayModel 与 route group] + Resolve --> Found{公共模型存在且 active?} + Found -- 否 --> Reject[拒绝:模型不可用] + Found -- 是 --> Routes[读取匹配的 active ModelRoute] + Routes --> Eligible[校验账号 active、schedulable、未过期、未冷却] + Eligible --> Exposed{upstream_model 在 account.models?} + Exposed -- 否 --> Skip[跳过候选并保留 Trace 原因] + Exposed -- 是 --> Rank[按优先级、权重、容量、熔断和亲和排序] + Rank --> Select{存在合格候选?} + Select -- 否 --> Unavailable[返回 route unavailable] + Select -- 是 --> Forward[改写为 upstream_model 并转发] +``` + +库存表不参与每次请求查询,避免控制面扩展影响网关延迟。 + +## 14. Provider Adapter 覆盖 + +| Provider 类型 | 发现方式 | 规范化 | 当前状态 | +| --- | --- | --- | --- | +| `openai_compatible` | `GET {base_url}/models`,Bearer Token | 读取 `data[].id`、`models[].id` 或数组 `id` | 已实现 | +| `self_hosted` | OpenAI-compatible `/models` | 同上 | 已实现 | +| `anthropic` | `GET {base_url}/models`,`x-api-key`,按 `after_id` 分页 | 读取 `data[].id` | 已实现 | +| `gemini` | `GET {base_url}/models`,`x-goog-api-key`,按 `pageToken` 分页 | `models/{id}` 规范化为 `{id}` | 已实现 | +| `azure_openai` | 需要 Azure Management Plane 枚举 deployment | 无管理面凭证时不能可靠发现 | 明确手工模式 | + +所有 adapter 都有 15 秒超时、2 MiB 单页上限和最多 100 页限制。发现请求禁止跟随重定向,避免凭证被转发到其他主机。 + +## 15. 管理端信息架构 + +```mermaid +flowchart LR + Providers[Provider 连接] --> ConnectionCheck[连接健康检查] + ConnectionCheck --> Snapshot[只读发现快照] + Accounts[路由资源] --> Edit[编辑账号] + Edit --> Inventory[模型库存] + Inventory --> Search[搜索/状态筛选] + Inventory --> Preview[动态发现与差异预览] + Inventory --> Manual[添加自定义模型] + Inventory --> Enable[启用/停用] + Inventory --> Apply[发现并应用] + Apply --> Routes[模型路由] + Routes --> Bulk[批量匹配] + Bulk --> Exact[同名自动匹配] + Bulk --> Review[未匹配人工选择] + Bulk --> Commit[整批原子创建] +``` + +新建账号允许以空库存保存。创建成功后弹窗切换为编辑态并立即执行动态发现预览,管理员勾选允许模型后再原子应用;不支持目录发现的 Azure deployment 或私有上游才需要添加自定义模型。空库存账号没有可建立的 ModelRoute,不会参与请求转发。 + +Provider 连接页面不提供推荐模型、手工模型或白名单输入。连接级健康检查复用同一组 discovery adapter,并用本次发现结果整体替换只读快照,确保已下线模型不会永久残留。 + +### 15.1 GatewayModel 消费路径 + +```mermaid +flowchart TD + Inventory[ProviderAccountModel 库存] --> Enabled[ProviderAccount.models 已启用集合] + Enabled --> Route[ModelRoute] + Route --> Catalog[active GatewayModel] + Catalog --> Picker[GatewayModelPicker] + Picker --> AdminKey[管理员 Workspace Key] + Picker --> PlatformKey[Platform Key / 外部集成] + Picker --> OperatorKey[Operator 使用方 Key] + Catalog --> Console[个人控制台与 curl 示例] + Catalog --> Simulator[Gateway Simulator] + Catalog --> Pricing[模型定价] + Catalog --> EffectiveDecision[有效价格切换决策] + Catalog --> Policy[治理策略 allow / deny] + Enabled --> Procurement[采购价格 / 账单 / 探针模型] + Legacy[旧记录中的下线模型] --> Historical[历史模型标记] + Historical --> Picker +``` + +所有新建公共模型表单只展示 `status=active` 的 `GatewayModel.model_id`。编辑旧 Key、策略、集成、定价或 Route 时,如果原值已不在 active 目录中,界面保留并标记为历史模型,由管理员显式移除;系统不会在一次页面加载中静默改写既有授权。采购价格、第三方账单、缓存探针和能力证据使用所选账号的 `ProviderAccount.models`,而不是公共模型目录或 Provider 连接快照。 + +## 16. 兼容与迁移 + +迁移 `065_provider_account_model_inventory.sql`: + +- 为 `provider_accounts` 增加 `auto_enable_new_models`,默认 `false`。 +- 新增 `provider_account_models` 及查询索引。 +- 旧账号无需离线回填;首次读取会把 `provider_accounts.models` 投影为 `manual/unverified`。 +- 旧账号首次编辑、健康检查或同步时,库存会在同一事务中持久化。 +- 网关热路径和现有 API 的 `models` 字段保持兼容。 + +## 17. 实施位置 + +| 能力 | 代码 | +| --- | --- | +| 库存领域与服务 | `backend/internal/controlplane/provider_account_model*.go` | +| 账号原子保存 | `backend/internal/controlplane/repository.go` | +| 批量 Route 服务与事务 | `gateway_model_service.go`、`gateway_model_repository.go` | +| Admin HTTP API | `backend/internal/server/admin_routes.go` | +| 数据库迁移 | `backend/migrations/065_provider_account_model_inventory.sql` | +| 前端 API 和类型 | `frontend/src/api/control.ts`、`frontend/src/types.ts` | +| 库存编辑器 | `frontend/src/components/provider/ProviderAccountModelEditor.vue` | +| 公共模型选择器 | `frontend/src/components/model/GatewayModelPicker.vue` | +| 管理页面 | `AdminProvidersView.vue`、`AdminProviderAccountsView.vue`、`AdminModelRoutesView.vue` | +| GatewayModel 消费页面 | `AdminApiKeysView.vue`、`AdminPoliciesView.vue`、`AdminGatewaySimulatorView.vue`、`AdminModelPricingsView.vue`、`AdminEffectivePricingView.vue`、`ConsoleHomeView.vue`、`PlatformKeysView.vue`、`PlatformIntegrationsView.vue`、`OperatorCustomersView.vue` | + +## 18. 验证矩阵 + +| 风险 | 覆盖 | +| --- | --- | +| 新增、缺失、手工模型状态错误 | Service 单元测试 | +| 缺失模型的活动 Route 未提示 | Service 单元测试 | +| 默认意外自动启用 | 健康检查回归测试 | +| Anthropic/Gemini 分页遗漏 | Adapter 单元测试 | +| 重定向泄露账号密钥 | 重定向拒绝测试 | +| 批量 Route 部分写入 | Memory 与 PostgreSQL 事务测试 | +| 迁移不可重复执行或约束失效 | Migration schema 测试 | +| 前端 API 路径漂移 | API contract 测试 | +| 管理端发现、同步、批量匹配回归 | Vue 组件与页面测试 | +| 静态推荐模型再次进入 Provider 弹窗 | Provider 页面回归测试与硬编码扫描 | +| Key 或 curl 示例误用 Provider 快照 | API Key 与 Console 页面回归测试 | +| 下线模型在编辑时被静默删除 | `GatewayModelPicker` 历史模型测试 | +| Provider CRUD 伪造或覆盖发现快照 | Service 只读快照回归测试 | +| disabled 公共模型获得新 active Route | Service 拒绝测试与 Route 页面 active 目录测试 | +| 账号无法显式清空启用集合 | Service 与库存编辑器空集合测试 | +| 有效价格页混用公共模型和上游模型 | 页面级动态目录测试 | + +## 19. 后续边界 + +当前版本不把 OpenRouter、LiteLLM 或 Portkey 的公共元数据自动写入运行时库存,因为它们不能证明本地采购账号可用。以后若引入模型能力和价格 Feed,必须作为独立、带来源与版本的 metadata snapshot,并保持以下优先级: + +```text +账号实时发现 > 管理员显式启用 > 公共目录注释 +``` + +公共目录可以补充显示名、上下文、模态、能力和参考价格,不能直接创建生产 Route 或覆盖账号发现事实。 diff --git a/docs/roadmap/models/UPDATE_GUIDE.md b/docs/roadmap/models/UPDATE_GUIDE.md new file mode 100644 index 0000000..3604afe --- /dev/null +++ b/docs/roadmap/models/UPDATE_GUIDE.md @@ -0,0 +1,296 @@ +# 模型更新与运维指南 + +> 适用范围:Provider 连接发现快照、Provider 账号模型库存、公共 GatewayModel、ModelRoute 和所有模型授权表单。 +> 核心规则:供应商模型列表不得硬编码到前端、后端常量或迁移脚本。 + +## 1. 日常新模型更新 + +新模型正常情况下不需要发布 AsterRouter 新版本。 + +1. 进入“路由资源”,新建或编辑目标 Provider 账号。新账号可以不填写模型,首次保存后系统会自动执行发现预览。 +2. 已有账号可在“上游模型库存”点击“发现模型”刷新预览。 +3. 检查新增、缺失、未验证模型和受影响 Route。 +4. 勾选允许进入该账号路由范围的新模型。 +5. 点击“发现并应用”。系统会重新发现一次并原子保存。 +6. 若要对客户端公开该模型,在“网关模型”创建或选择稳定公共模型 ID。 +7. 在“模型路由”使用“批量匹配模型”,复核 route group、优先级和权重后提交。 +8. 用 Gateway Simulator 检查候选和调度结果,再发送最小真实请求。 + +创建或启用 `GatewayModel` 后,管理员 Key、Platform Key、外部集成、Operator 使用方 Key、治理策略、个人控制台、Simulator、模型定价和有效价格切换决策会在下次加载时自动读取最新目录,不需要修改任何前端推荐数组。 + +推荐默认: + +- `auto_enable_new_models=false` +- 先发现、后审核、再创建 Route +- 公共模型 ID 使用稳定名称,不直接暴露带日期的供应商部署名,除非产品明确需要固定版本 + +### 1.1 各页面读取哪个事实源 + +| 页面或能力 | 唯一模型来源 | +| --- | --- | +| Provider 连接列表 | `ProviderConnection.models` 最近发现快照,只读 | +| Provider 账号与 Route | `ProviderAccountModel` 库存和 `ProviderAccount.models` 启用集合 | +| Key、外部集成、治理策略 | active `GatewayModel.model_id` | +| Console curl 示例、Simulator、模型定价 | active `GatewayModel.model_id` | +| 有效价格切换决策 | active `GatewayModel.model_id` | +| 采购价格、第三方账单、缓存能力和探针 | 所选 `ProviderAccount.models`;历史证据保留原值 | +| Portal / Customer / Account 接入配置 | 后端 Workspace 响应中的可访问 GatewayModel | + +编辑既有记录时,下线的 GatewayModel 会显示为历史模型。只有管理员显式移除并保存后,旧授权才会改变。Provider 连接表单不能手工录入模型;需要手工补充的上游模型只能进入具体 Provider 账号库存。 + +allowlist/denylist 在数据库中保留字符串软引用,以支持历史记录和先策略后目录的自动化部署;它们不会让未知模型可调用。网关请求仍必须先成功解析 active GatewayModel,再匹配 active ModelRoute 和账号启用库存。 + +## 2. 自动启用模式 + +只有满足以下条件才开启“自动启用新发现模型”: + +- 该账号的 `/models` 目录可信且稳定。 +- 新增账号模型不会绕过客户模型授权。 +- 没有依赖严格模型白名单的合规边界。 +- 运维接受健康检查扩大 `ProviderAccount.models`。 + +自动模式只更新账号启用集合,不会自动创建 `GatewayModel` 和 `ModelRoute`,因此新模型不会仅凭一次健康检查直接暴露给客户端。 + +关闭自动模式不会删除已经启用的模型。需要在库存编辑器中显式取消启用。 + +## 3. 上游目录不完整或不支持发现 + +以下情况使用“添加自定义模型”: + +- Azure OpenAI deployment 只能通过管理面枚举,而当前账号仅配置数据面 API Key。 +- 中转站 `/models` 隐藏套餐模型或返回不完整。 +- 私有部署没有实现 OpenAI-compatible `/models`。 +- 模型需要特殊名称,但上游目录尚未同步。 + +手工模型状态为 `manual/unverified`。这不表示模型不可用,只表示 AsterRouter 没有目录证据。创建 Route 后应通过 Gateway Simulator 和最小请求验证。 + +不要为了让页面“看起来完整”批量复制公开厂商列表。只添加账号真实可调用的模型。 + +## 4. 模型下线处理 + +发现结果中的 `missing` 是告警,不是删除命令。 + +```mermaid +flowchart TD + Missing[发现 missing 模型] --> Routes{存在活动 Route?} + Routes -- 是 --> Replacement[确认替代模型或其他账号] + Replacement --> AddRoute[先创建替代 Route] + AddRoute --> Simulate[运行 Gateway Simulator 和最小请求] + Simulate --> DisableOldRoute[停用旧 Route] + DisableOldRoute --> DisableModel[从账号启用集合取消模型] + Routes -- 否 --> Verify[确认不是上游目录暂时异常] + Verify --> DisableModel +``` + +处理顺序: + +1. 记录发现时间、上游响应和受影响 Route IDs。 +2. 排除短时目录故障、权限变化和分页异常。 +3. 先增加替代 Route,再停用旧 Route。 +4. 最后从账号启用集合移除模型。 +5. 保留库存记录和最后发现时间用于审计。 + +不要直接删除公共 `GatewayModel`。删除公共模型会影响客户端契约,应采用停用和迁移窗口。 + +## 5. 公共模型与上游模型如何命名 + +示例: + +```text +GatewayModel.model_id = gateway-chat-stable +ModelRoute.upstream_model = provider-chat-versioned +ModelRoute.provider_account_id = account-provider-primary +ModelRoute.route_group = default +``` + +原则: + +- 公共 ID 表达产品承诺。 +- 上游 ID 表达供应商真实调用参数。 +- 同一公共模型可以有多个 Provider Account Route。 +- 固定版本和滚动别名要分开建模,不在请求时做字符串猜测。 +- route group 用于明确的隔离或版本策略,不把 Provider 名称编码进公共模型 ID。 + +## 6. 批量创建 Route + +“批量匹配模型”执行以下规则: + +1. 从所选账号的已启用模型生成候选行。 +2. `upstream_model == GatewayModel.model_id` 时自动匹配。 +3. 已存在的等价 Route 自动标记并排除。 +4. 未同名模型必须人工选择公共模型。 +5. 统一设置 route group、priority 和 weight。 +6. 前端提交所选行;后端重新验证全部外键、账号模型和重复项。 +7. 任意一行失败则整批不写入。 + +后端还会拒绝把 active Route 绑定到 disabled `GatewayModel`。历史映射如需保留,应保持 Route 为 disabled;恢复流量前必须先重新启用公共模型并复核客户端契约。 + +单批上限为 500 条。更大变更应按账号或 route group 拆分,以便审核和回滚。 + +## 7. 新增 Provider Discovery Adapter + +只有 Provider 存在安全、可验证的模型目录 API 时才新增 adapter。同一 adapter 同时用于连接级健康快照和账号级库存发现,二者不得复制解析逻辑。 + +代码位置: + +```text +backend/internal/controlplane/provider_account_model_service.go +backend/internal/controlplane/provider_account_model_service_test.go +``` + +实施步骤: + +1. 确认官方目录 endpoint、认证 Header、分页、限流和模型 ID 语义。 +2. 实现 `providerModelDiscoveryAdapter`,不要把密钥写入 URL、日志或错误响应。 +3. 在 `providerModelDiscoveryAdapterFor` 注册现有 `ProviderConnection.type`。 +4. 将返回名称规范化为网关实际转发时使用的 `upstream_model`。 +5. 使用标准 JSON 解析,不用字符串截取。 +6. 复用或保持以下防护:15 秒超时、2 MiB 单页限制、100 页上限、禁止重定向。 +7. 添加分页、空列表、非 2xx、无效 JSON、超大响应和重定向泄密测试。 +8. 验证健康检查在 `auto_enable_new_models=false/true` 两种模式下的行为。 +9. 更新本文件的 Adapter 覆盖表和运维说明。 + +禁止: + +- 根据 Provider 名称猜测模型列表。 +- 把 API Key 放进 query string,除非上游只支持该方式且经过单独安全评审。 +- 在 adapter 中直接创建公共模型或 Route。 +- 将“目录没返回”解释成“立即删除”。 + +## 8. Provider 特殊说明 + +| 类型 | 运维说明 | +| --- | --- | +| OpenAI-compatible | Base URL 应指向包含 `/models` 的 API 版本根路径,例如 `/v1` | +| self-hosted | 必须实现 OpenAI-compatible 模型列表,否则使用手工模型 | +| Anthropic | Base URL 通常指向 `/v1`;adapter 使用 `x-api-key` 和官方版本 Header | +| Gemini | Base URL 通常指向 `/v1beta`;库存 ID 会移除响应中的 `models/` 前缀 | +| Azure OpenAI | 当前保持手工模式;只有引入 Azure 管理面身份和 deployment 作用域后才能自动发现 | + +## 9. 数据库迁移与部署 + +迁移文件: + +```text +backend/migrations/065_provider_account_model_inventory.sql +``` + +部署前: + +1. 备份 PostgreSQL。 +2. 在隔离数据库运行全部 migration schema tests。 +3. 确认应用实例使用同一版本,避免旧实例忽略新开关。 +4. 先部署数据库迁移,再切换新应用实例。 + +迁移是向前兼容的:旧账号继续使用 `provider_accounts.models`。库存表首次读取为空时,服务会把旧列表投影为 `manual/unverified`,不会中断路由。 + +## 10. 回滚 + +应用层回滚不需要删除新表: + +1. 关闭所有账号的自动启用开关。 +2. 恢复账号原启用模型集合。 +3. 停用本次新增 Route,不要先删除。 +4. 用 Simulator 确认旧 Route 恢复为候选。 +5. 回滚应用版本。 + +保留 `provider_account_models` 数据不会影响旧网关热路径。不要在紧急回滚时执行 `DROP TABLE`;数据库结构回退应单独评审并在备份后进行。 + +## 11. 排障 + +### 发现返回 401/403 + +- 连接健康检查确认使用 Provider Connection 凭证;账号库存发现确认使用 Provider Account 凭证。 +- 检查 Header 方式与 Provider 类型是否匹配。 +- 确认账号具备读取模型目录的权限。 + +### 发现返回 404 + +- 检查 Base URL 是否已经包含 `/v1` 或 `/v1beta`。 +- 不要把 completion endpoint 填为 Base URL。 +- Azure OpenAI 应使用手工模式,不要伪造 `/models`。 + +### 大量模型突然 missing + +- 先视为目录或权限事故,不要批量停用。 +- 检查上游分页字段是否变化。 +- 对比最近健康检查和 Provider 状态。 +- 保留受影响 Route,等待替代路径验证完成。 + +### 新模型已启用但客户端不可见 + +账号库存不是公共目录。继续检查: + +1. 是否存在 active `GatewayModel`。 +2. 是否存在匹配 route group 的 active `ModelRoute`。 +3. Route 的 `upstream_model` 是否仍在账号 `models` 中。 +4. 账号是否 active、schedulable、未冷却且有容量。 + +### 批量 Route 整批失败 + +错误会标识 `routes[index]`。修正该行后重试;事务保证此前没有部分写入。常见原因是重复 Route、账号未启用上游模型、GatewayModel 不存在或已停用、权重超出 `1..10000`。 + +### 需要暂时停用账号全部模型 + +在库存编辑器点击“清空已启用模型”,再点击“发现并应用”。系统会以 `enabled_models=[]` 原子保存空启用集合,同时保留发现库存、missing 状态和 Route 计数。无需删除库存或 Provider 账号;现有 Route 因上游模型不再暴露而不会成为候选。 + +## 12. 验证命令 + +后端快速验证: + +```bash +cd backend +go test ./internal/controlplane ./internal/server -count=1 +go test -race ./internal/controlplane ./internal/server +``` + +PostgreSQL 迁移验证: + +```bash +cd backend +ASTER_TEST_DATABASE_URL='postgres://...' go test ./migrations -count=1 +``` + +前端验证: + +```bash +cd frontend +npm run test:unit +npm run typecheck +npm run build +npm run check:enterprise-surface +``` + +生产代码硬编码扫描: + +```bash +rg -n --glob '!**/*_test.go' --glob '!**/*.test.ts' \ + '(gpt-[0-9]|claude-(opus|sonnet|haiku|[0-9])|gemini-[0-9]|grok-[0-9])' \ + backend/internal frontend/src +``` + +扫描结果应为空。协议名、客户端产品名、测试夹具和 Provider 身份前缀规则不属于运行时模型目录,必须人工判断,不能机械删除。 + +浏览器至少验证: + +- `1440x900`、`1280x800`、`390x844` +- 中文和英文 +- 明色和暗色主题 +- 键盘焦点、表格横向滚动、弹窗纵向滚动 +- 新增、missing、手工模型和批量 Route 状态 + +## 13. 更新检查清单 + +- [ ] 模型来自账号实时发现或明确手工证据 +- [ ] 没有新增硬编码供应商模型数组 +- [ ] Provider 连接模型仅来自健康检查快照,表单不能手工编辑 +- [ ] Key、策略、集成、Console、Simulator 和定价只读取 active GatewayModel +- [ ] Route 和有效价格切换决策只选择 active GatewayModel +- [ ] 采购价格、账单、缓存能力和探针读取账号启用模型,不读取 Provider 快照 +- [ ] 旧记录中的下线模型被标记为历史值,没有静默删除 +- [ ] 新模型先进入账号启用集合,再创建公共模型和 Route +- [ ] missing 模型的活动 Route 已检查 +- [ ] 批量 Route 已通过 Simulator 验证 +- [ ] 自动启用开关符合该账号风险级别 +- [ ] 中英文与移动端界面已验证 +- [ ] 单元、迁移、构建和浏览器测试已通过 diff --git a/docs/test/v1/IMPLEMENTATION_STATUS.md b/docs/test/v1/IMPLEMENTATION_STATUS.md index 4042742..be8e60b 100644 --- a/docs/test/v1/IMPLEMENTATION_STATUS.md +++ b/docs/test/v1/IMPLEMENTATION_STATUS.md @@ -1,73 +1,68 @@ # AsterRouter 测试计划 v1 实施状态 -> 最近核验:2026-07-14 CST +> 最近核验:2026-07-14 15:46 CST > -> 基准提交:`f8378c5c58a94ac56295872eb5919e4d506f939a`(dirty worktree) +> 证据提交:`7eb4c5ece7bb5fa68f22028ee651f72970ca8193`(`v0.6.0`) > -> 结论:v1 的本地实现、测试入口和 CI/release/nightly 门禁已落地。当前不能宣称候选版本可发布,直到本轮变更在 GitHub Actions 的 PostgreSQL 16、Linux、Docker、arm64 和 nightly 环境产生真实成功证据。 +> 结论:Phase 0-4 的实现、候选发布验收和三次固定环境 Nightly 证据均已完成,性能基线已确认。当前还需推送 Nightly `pipefail` 回归门禁并在新提交上复验,然后由仓库管理员将 CI/security checks 配置为 `main` required checks。 -本轮本地复核已通过:`GOTOOLCHAIN=go1.25.1 bash scripts/test.sh all`、`go test -race -count=1 -timeout=15m ./...`、J04 六场景失败矩阵、20 秒 normal/SSE soak(1,615 请求,goroutine 增量 3)、独立空库 setup、以及四个独立单 Profile runtime 的 Chromium 验收。所有临时 runtime 使用独立端口和内存数据,不能替代 PostgreSQL 或 Linux candidate artifact 证据。 +当前主工作树包含其他未提交的控制面和网关开发修改。本文件只把已推送的 `7eb4c5e` 及其 GitHub Actions 产物作为发布证据;本轮测试门禁修改与并行业务修改必须分别暂存。 -## 1. 当前自动化入口 +## 1. 当前自动化与证据 -| 能力 | 自动化入口 | 当前本地证据 | +| 能力 | 自动化入口 | 已核验证据 | | --- | --- | --- | -| 后端全量 | `cd backend && go test -count=1 ./...` | 通过;本机无 PostgreSQL 时环境依赖测试按显式条件跳过 | -| 后端 race | `cd backend && GOTOOLCHAIN=go1.25.1 go test -race -count=1 -timeout=15m ./...` | 已运行通过;长期 nightly 仍需 Ubuntu 证据 | -| 后端覆盖率 | `GOTOOLCHAIN=go1.25.1 bash scripts/test.sh backend-coverage` | 本地 Go 1.25.1 运行,coverage profile 已生成;当前关键包仍未达到 75% 渐进目标 | -| 前端单元/组件 | `cd frontend && npm run test:unit:coverage` | 11 文件、46 用例通过;覆盖率持续以渐进 ratchet 管理 | -| 开发浏览器 smoke | `cd frontend && npm run test:e2e:smoke` | demo 多 Surface Chromium smoke 通过;关键 API 旅程仅在桌面运行,其余视口按设计 skip | -| 首装浏览器旅程 | `bash scripts/test-setup-browser-journey.sh` | 空 runtime、单源构建、`platform` 首装持久化;桌面 1 通过、其余视口按设计 skip | -| 非 demo 候选路径模拟 | 每个 `enterprise`、`relay_operator`、`personal`、`platform` 启动独立 runtime/数据库;`configure-e2e-profiles.mjs` 验证其单 Profile 状态 | 本机已验证 `enterprise` J01-J05 与 `platform` Surface;候选 archive 的 Linux/PostgreSQL 实跑由 CI 负责 | -| J04 失败矩阵 | `node scripts/gateway-failure-matrix.mjs` | 6 个场景通过:401/429/5xx、timeout、SSE 中断、并发、circuit、cooldown | -| J07 信任链 | `go test -json -count=1 ./internal/plugins -run '^(TestPluginTrustChainCatalogToSidecarFeed|...)$'` | catalog 到真实 sidecar helper、篡改、revocation、checksum、回滚、token scope 通过 | -| 短 soak 回归 | `ASTER_GATEWAY_SOAK=1 ASTER_GATEWAY_SOAK_DURATION=20s ...` | 1,615 普通/流式请求通过;goroutine 增量 3;30 分钟 nightly 仍待执行 | -| 发布包静态验收 | `ASTER_DIST_DIR= bash scripts/build-release.sh 0.5.0 && ...test-release-artifacts.sh 0.5.0` | amd64/arm64 archive、checksum、manifest 与二进制元数据通过;本机不能执行 Linux binary | +| 后端全量 | `cd backend && go test -count=1 ./...` | 干净基线和当前工作树均通过;PostgreSQL 16 CI 通过 | +| 后端 race | `cd backend && GOTOOLCHAIN=go1.25.1 go test -race -count=1 -timeout=15m ./...` | 本地通过;三次 Ubuntu 24.04 Nightly 均通过 | +| 后端覆盖率 | `GOTOOLCHAIN=go1.25.1 bash scripts/test.sh backend-coverage` | profile 已归档;总 statements 56.8%,继续按包级 ratchet 管理 | +| 前端单元/组件 | `cd frontend && npm run test:unit:coverage` | 11 文件、47 用例通过;lines 70.75%、branches 41.17%,新增关键逻辑继续执行 80% 目标 | +| 浏览器 E2E | `cd frontend && npm run test:e2e` | 本地 Chromium 31 通过、26 个按视口设计跳过;三次 Nightly Chromium/Firefox/WebKit 全矩阵通过 | +| 首装与候选旅程 | `bash scripts/test-setup-browser-journey.sh`、`bash scripts/test-release-browser-journeys.sh ` | PostgreSQL 空库首装、四个独立单 Profile 候选 runtime 和 J01-J06/J09 通过 | +| J04 失败矩阵 | `node scripts/gateway-failure-matrix.mjs` | 401/429/5xx、timeout、SSE 中断、并发、circuit、cooldown 通过 | +| J07 信任链 | `go test -json -count=1 ./internal/plugins -run ''` | catalog、sidecar、篡改、revocation、checksum、回滚、token scope 通过 | +| 30 分钟 soak | `ASTER_GATEWAY_SOAK=1 ASTER_GATEWAY_SOAK_DURATION=30m go test ...` | 三次分别完成 17,588、17,522、17,616 请求;goroutine 增量均为 3,产物均为 `PASS` | +| 发布验收 | build/release workflows | Linux amd64/arm64、QEMU、checksum、候选 runtime、安装/升级/回滚、GitHub Release 全部通过 | +| 安全 | CodeQL、govulncheck、npm audit、gitleaks、Trivy | 当前提交全部通过,无未接受高危结果 | -本机为 macOS arm64、Go 1.24.3(可用 `GOTOOLCHAIN=go1.25.1`)、Node 23.4.0。项目 CI 的 Go 1.25、Node 24 与 Ubuntu Linux 结果才是版本和发布门禁的事实来源。 +本机为 macOS arm64、Go 1.24.3(测试使用 `GOTOOLCHAIN=go1.25.1`)、Node 23.4.0。Node 23 不在项目支持矩阵内,本地前端结果仅作辅助证据;GitHub Actions 的 Go 1.25、Node 24、PostgreSQL 16 和 Ubuntu Linux 结果是发布事实来源。 ## 2. Phase 状态 -| Phase | 状态 | 已完成 | 待真实环境验收 | +| Phase | 状态 | 完成证据 | 后续治理 | | --- | --- | --- | --- | -| Phase 0 | 实现完成 | 统一入口、Go/Node/Docker 版本事实、format/vet/coverage、PostgreSQL CI、schema parity | 将 checks 设为仓库 required checks 需管理员权限;PostgreSQL 16 CI 需在本轮 commit 运行 | -| Phase 1 | 实现完成 | fake OpenAI、Repository 持久化/事务/幂等、migration、P0 负路径、race | fake identity/SMTP/S3 仍按各领域 fixture 实现;本轮 PostgreSQL CI 需通过 | -| Phase 2 | 实现完成 | Vitest、Vue Test Utils、API/router/store/component、Chromium desktop/compact/mobile、独立空库 setup | P0 表单与 branch coverage 仍需逐步提高 | -| Phase 3 | 实现完成,待发布环境证据 | J01-J09 自动化、单源构建、container/release acceptance、候选 archive browser journey、恢复与升级 jobs | Linux amd64/arm64、Docker、PostgreSQL recovery、candidate archive browser journey 的本轮 Actions artifact | -| Phase 4 | 实现完成,基线采集中 | race、30 分钟 soak、CodeQL/govulncheck/npm audit/gitleaks/Trivy、axe、全浏览器 nightly、性能 JSON、flaky trend artifact | 3 次固定 Ubuntu 24.04 benchmark 后确认基线;nightly 30 分钟 soak、Firefox/WebKit 与 trend 首次真实报告 | +| Phase 0 | 实现与环境证据完成 | 统一入口、版本事实、format/vet/coverage、PostgreSQL、schema parity | 将 checks 设为 required | +| Phase 1 | 完成 | fake upstream、Repository 契约、migration、P0 负路径、race | 覆盖率按变更包 ratchet | +| Phase 2 | 完成 | Vitest、Vue Test Utils、API/router/store/component、三视口 Chromium、独立 setup | 新增/修改关键前端逻辑执行 80% 目标 | +| Phase 3 | 完成 | J01-J09、单源构建、container/release、候选 archive、恢复与升级证据 | 无环境证据缺口 | +| Phase 4 | 完成,门禁修正待推送 | 三次 race/30 分钟 soak/全浏览器/benchmark/flaky trend;安全矩阵;confirmed baseline | 推送 `pipefail` 防回归检查并在新 SHA 复验 | ## 3. J01-J09 覆盖状态 -| Journey | 当前自动化 | 本地验证 | 剩余发布证据 | -| --- | --- | --- | --- | -| J01 首装到首个网关请求 | 独立空 runtime setup;Playwright Provider/Account/Model/Route/Key;JSON/SSE;usage/trace/audit | 首装通过;`enterprise` 单 Profile source runtime 关键旅程通过 | Linux candidate archive + PostgreSQL 16 | -| J02 会话即时撤销 | 浏览器 logout、角色变更、禁用;HTTP 改密/TOTP/session version | smoke 通过 | candidate archive、PostgreSQL restart 行为 | -| J03 部门/Owner 隔离 | 浏览器部门用户、Key、usage、trace、alert、cost、export;Service/HTTP | 桌面旅程通过 | PostgreSQL 全栈 CI | -| J04 路由/流式/失败切换 | 浏览器 primary 500 fallback;机器可读 6 场景 Go 矩阵 | browser 与 matrix 通过 | Linux CI artifact | -| J05 配额/预算/告警 | 浏览器 80%/100%、去重、升级、429、usage/trace/audit | 桌面旅程通过 | PostgreSQL full-stack CI | -| J06 Operator/Customer | 浏览器 allocation/reclaim/correction、usage、账单、通知、无支付 recharge、export;PostgreSQL 并发兑换 | 桌面旅程和 memory 契约通过 | PostgreSQL 并发 CI | -| J07 插件信任链 | signed catalog -> package -> install -> enable -> scoped token -> real sidecar helper/feed;负路径矩阵 | 窄测通过 | CI JSON artifact | -| J08 生命周期/恢复/升级 | retention、PostgreSQL backup 到空库恢复、plugin/export 抽样、v0.3.0 schema candidate upgrade | 本机无 PostgreSQL;测试为显式 skip | PostgreSQL 16 recovery/upgrade artifact | -| J09 多 Surface | admin/console/operator/portal/customer/account、locale/theme、reload、keyboard、a11y、三视口 | demo 多 Surface smoke 与 `platform` 单 Profile Surface 旅程通过 | Firefox/WebKit nightly artifact | - -## 4. 新增证据与约束 +| Journey | 自动化 | 候选环境证据 | +| --- | --- | --- | +| J01 首装到首个网关请求 | setup、Provider/Account/Model/Route/Key、JSON/SSE、usage/trace/audit | Linux candidate + PostgreSQL 16 通过 | +| J02 会话即时撤销 | logout、角色变化、禁用、改密/TOTP/session version | candidate + PostgreSQL 通过 | +| J03 部门/Owner 隔离 | 用户、Key、usage、trace、alert、cost、同步/异步 export | PostgreSQL 全栈通过 | +| J04 路由/流式/失败切换 | 浏览器 fallback + 六场景机器可读矩阵 | Linux CI 通过 | +| J05 配额/预算/告警 | 80%/100%、去重、升级、429、usage/trace/audit | PostgreSQL 全栈通过 | +| J06 Operator/Customer | allocation/reclaim/correction、账单、通知、充值、export、并发兑换 | PostgreSQL 并发与候选旅程通过 | +| J07 插件信任链 | signed catalog -> package -> install -> enable -> scoped token -> sidecar | CI JSON evidence 通过 | +| J08 生命周期/恢复/升级 | retention、backup/empty-db restore、关键数据抽样、v0.3.0 upgrade | PostgreSQL 16 recovery/upgrade 通过 | +| J09 多 Surface | 六个 Surface、locale/theme/reload/keyboard/a11y/响应式 | Chromium/Firefox/WebKit Nightly 通过 | -- `scripts/test-release-browser-journeys.sh` 先以 archive 内 Linux binary 完成一次空数据库 `platform` 首装,再为 `enterprise`、`relay_operator`、`personal`、`platform` 分别启动候选 runtime 与独立 PostgreSQL 数据库;这避免将业务 Profile 混入同一实例。首装和各 profile 的 Chromium artifact 分别保存。 -- `scripts/test-setup-browser-journey.sh` 支持 source runtime 或 `ASTER_SETUP_JOURNEY_BINARY`;release job 使用后者,避免只验证开发服务器。 -- `scripts/benchmark-report.mjs` 将五次 Go benchmark 转为 JSON。`docs/test/v1/performance-baseline.ubuntu-24.04.json` 处于 `bootstrap_required`:三次成功 nightly 后须在评审中写入中位数并标记 `confirmed`,随后 20% latency、10% allocation 与 0 allocation 增长成为门禁。 -- `scripts/flaky-trend.mjs` 聚合 Go JSON、JUnit 和最近 nightly health artifact。它区分跨运行 observation 与实际 retry;发现同一测试既失败又通过时会写 artifact 并失败,P0 不允许 quarantine。 -- CI 的 JSON evidence pipeline 使用 `pipefail`,测试失败不能被 `tee` 掩盖。 -- 普通 `scripts/test.sh all` 包含浏览器 smoke;`@setup` 只通过独立空 runtime 执行,避免与 demo 数据共享状态。 +## 4. GitHub Actions 证据 -## 5. 未完成的发布阻断项 +- `7eb4c5e`:CI [29311002515](https://github.com/astercloud/asterrouter/actions/runs/29311002515)、Build Artifacts [29311002517](https://github.com/astercloud/asterrouter/actions/runs/29311002517)、Security Scan [29311002477](https://github.com/astercloud/asterrouter/actions/runs/29311002477) 均成功。 +- `v0.6.0`:GitHub Release [29311004993](https://github.com/astercloud/asterrouter/actions/runs/29311004993) 成功,发布资产已生成。 +- 三次 Nightly:[29313835627](https://github.com/astercloud/asterrouter/actions/runs/29313835627)、[29313835542](https://github.com/astercloud/asterrouter/actions/runs/29313835542)、[29313835587](https://github.com/astercloud/asterrouter/actions/runs/29313835587) 均成功。 +- flaky trend 最终汇总覆盖 386 个测试、790 次通过、0 次失败、0 suspected flaky、0 repeated failure;当前 quarantine 为空。 +- `performance-baseline.ubuntu-24.04.json` 使用上述三次运行的 per-run median 再取中位数,状态为 `confirmed`;后续门禁为 latency 1.2x、bytes 1.1x、allocations 1.0x。 -以下不是待实现代码,而是必须取得的外部、不可由当前 macOS 工作站代替的证据: +## 5. 当前未闭环项 -1. 本轮工作树提交后,GitHub Actions `CI`、`Build Artifacts`、`Security Scan` 全绿;历史 `f8378c5c` 成功记录不能证明当前变更。 -2. PostgreSQL 16:全后端、J06 并发兑换、J08 backup/empty-db restore、v0.3.0 upgrade fixture 和 PostgreSQL full-stack browser run。 -3. Ubuntu Linux:container non-root/health/SIGTERM、amd64 candidate runtime、arm64 QEMU、install/upgrade/rollback、archive browser journey。 -4. Nightly:30 分钟 normal/streaming soak、Firefox/WebKit、race、性能 bootstrap artifact、flaky trend artifact。 -5. 仓库管理员将所需 CI/security checks 配置为 required checks;本计划无权替仓库修改分支保护。 -6. 覆盖率目标仍是渐进门槛:核心 Go 包和前端关键分支尚未全部达到文档中的 75%/80% 目标,不能用现有总覆盖率声称达标。 +1. Nightly soak 原为单行 `go test | tee`,缺少 `pipefail`。历史产物证明失败可能被绿色 job 掩盖;本轮已改为显式 `set -o pipefail`,并增加 `check-workflow-pipefail.mjs` 回归门禁,但这些修改尚未提交和推送。 +2. 修正后的提交必须重新通过 CI、Build Artifacts、Security Scan,并至少运行一次 Nightly,证明 soak 失败传播门禁在远端生效且 confirmed baseline 无回归。 +3. `main` 当前没有 branch protection 或 ruleset。需将 `backend`、`frontend`、`e2e`、`recovery`、`container-smoke`、`build`、`secrets`、`codeql`、`dependencies`、`container` 配置为 required checks。 +4. 覆盖率的 75%/80% 是渐进 ratchet 目标,当前总量尚未达到;不得把现有总覆盖率描述为达标。它不替代已完成的 P0 旅程、隔离、恢复、发布和安全证据。 -当前 quarantine:无。当前本地 Docker:不可用。当前本地 PostgreSQL:不可用。 +当前 quarantine:无。当前本地 Docker:不可用。当前本地 PostgreSQL CLI 可用,但无本地 PostgreSQL 服务;环境相关事实以 GitHub Actions 产物为准。 diff --git a/docs/test/v1/README.md b/docs/test/v1/README.md index 28d2e8f..140fae4 100644 --- a/docs/test/v1/README.md +++ b/docs/test/v1/README.md @@ -1,6 +1,6 @@ # AsterRouter 全项目测试计划 v1 -> 状态:实施完成,等待环境证据(Phase 0-4 自动化已落地;发布验收等待本轮 Linux/PostgreSQL/Docker/nightly CI 证据) +> 状态:Phase 0-4 实施与候选环境证据完成;等待将修正后的 Nightly 失败传播门禁推送并固化为 `main` required checks > > 制定日期:2026-07-13 > diff --git a/docs/test/v1/performance-baseline.ubuntu-24.04.json b/docs/test/v1/performance-baseline.ubuntu-24.04.json index a1c8e0e..0f59100 100644 --- a/docs/test/v1/performance-baseline.ubuntu-24.04.json +++ b/docs/test/v1/performance-baseline.ubuntu-24.04.json @@ -1,17 +1,33 @@ { "schema_version": 1, - "status": "bootstrap_required", + "status": "confirmed", "environment": { "runner": "ubuntu-24.04", "os": "linux", "architecture": "amd64", "go": "from backend/go.mod" }, - "source": "Populate from three successful nightly runs on the pinned runner, then change status to confirmed in a reviewed commit.", + "source": "Median of per-run medians from successful Nightly Tests runs 29313835627, 29313835542, and 29313835587 for commit 7eb4c5ece7bb5fa68f22028ee651f72970ca8193 on 2026-07-14.", "thresholds": { "ns_per_op_ratio": 1.2, "bytes_per_op_ratio": 1.1, "allocs_per_op_ratio": 1.0 }, - "benchmarks": {} + "benchmarks": { + "BenchmarkGatewayEstimateTokens": { + "ns_per_op": 1775, + "bytes_per_op": 280, + "allocs_per_op": 7 + }, + "BenchmarkGatewayParseUsage": { + "ns_per_op": 1071, + "bytes_per_op": 296, + "allocs_per_op": 7 + }, + "BenchmarkGatewayRewriteModel": { + "ns_per_op": 5515, + "bytes_per_op": 2296, + "allocs_per_op": 56 + } + } } From 61b46bb15726198159b3aca9f9a3b787ca70dfbf Mon Sep 17 00:00:00 2001 From: coso Date: Thu, 16 Jul 2026 15:28:54 +0800 Subject: [PATCH 3/3] chore: bump version to v0.15.0 --- backend/cmd/asterrouter/VERSION | 2 +- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/cmd/asterrouter/VERSION b/backend/cmd/asterrouter/VERSION index a803cc2..a551051 100644 --- a/backend/cmd/asterrouter/VERSION +++ b/backend/cmd/asterrouter/VERSION @@ -1 +1 @@ -0.14.0 +0.15.0 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4adc9cd..6be7cbf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "asterrouter-frontend", - "version": "0.8.0", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "asterrouter-frontend", - "version": "0.8.0", + "version": "0.15.0", "dependencies": { "@lucide/vue": "^1.24.0", "@vitejs/plugin-vue": "^6.0.7", diff --git a/frontend/package.json b/frontend/package.json index c26122d..d1d5208 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "asterrouter-frontend", "private": true, - "version": "0.14.0", + "version": "0.15.0", "type": "module", "scripts": { "dev": "vite",