From 86dc86985eb32755bc9f21c8dbf73f359a9d694c Mon Sep 17 00:00:00 2001 From: tbphp Date: Mon, 17 Aug 2026 12:07:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(subscription):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=B4=A6=E6=88=B7=E8=A7=82=E5=AF=9F=E8=BD=AF=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E4=B8=8E=E6=8E=88=E6=9D=83=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/claude/claude.go | 12 ++ internal/claude/claude_test.go | 8 + internal/codex/codex.go | 2 +- internal/codex/codex_test.go | 11 ++ internal/control/credential_observations.go | 29 ++++ .../control/credential_observations_test.go | 113 ++++++++++++++ internal/control/discover_subscription.go | 11 +- .../subscription/runtime/claude_driver.go | 4 + internal/subscription/runtime/codex_driver.go | 4 + .../cpaembedded/embedded/claude_account.go | 139 ++++++++++++++---- .../cpaembedded/embedded/claude_oauth.go | 2 +- .../cpaembedded/embedded/claude_test.go | 51 ++++++- .../credentials/SubscriptionAccountCard.vue | 2 + web/src/i18n/locales/en-US/group.ts | 2 + web/src/i18n/locales/ja-JP/group.ts | 1 + web/src/i18n/locales/zh-CN/group.ts | 1 + 16 files changed, 360 insertions(+), 32 deletions(-) diff --git a/internal/claude/claude.go b/internal/claude/claude.go index db86aacb..033e5b47 100644 --- a/internal/claude/claude.go +++ b/internal/claude/claude.go @@ -97,6 +97,14 @@ func (e *TokenEndpointError) Error() string { return fmt.Sprintf("Claude token endpoint returned status %d", e.StatusCode) } +type UpstreamHTTPError struct { + StatusCode int +} + +func (err *UpstreamHTTPError) Error() string { + return fmt.Sprintf("Claude upstream returned status %d", err.StatusCode) +} + func IsDefinitiveRefreshRejection(code string) bool { return cpaembedded.IsDefinitiveRefreshRejection(code) } @@ -176,5 +184,9 @@ func normalizeAuthorizationError(err error) error { if errors.As(err, &tokenErr) { return &TokenEndpointError{StatusCode: tokenErr.StatusCode, Code: strings.TrimSpace(tokenErr.Code)} } + var upstream *cpaembedded.ClaudeUpstreamHTTPError + if errors.As(err, &upstream) { + return &UpstreamHTTPError{StatusCode: upstream.StatusCode} + } return err } diff --git a/internal/claude/claude_test.go b/internal/claude/claude_test.go index d6b79879..41c2b724 100644 --- a/internal/claude/claude_test.go +++ b/internal/claude/claude_test.go @@ -160,4 +160,12 @@ func TestAccountObservationPreservesIncompleteSourceMarkers(t *testing.T) { } } +func TestNormalizeAuthorizationErrorPreservesCompanionHTTPStatus(t *testing.T) { + err := normalizeAuthorizationError(&cpaembedded.ClaudeUpstreamHTTPError{StatusCode: http.StatusUnauthorized}) + var upstream *UpstreamHTTPError + if !errors.As(err, &upstream) || upstream.StatusCode != http.StatusUnauthorized { + t.Fatalf("normalized error = %#v / %v", upstream, err) + } +} + var _ cpaembedded.ClaudeHTTPExecutor = (*fakeClaudeBridgeExecutor)(nil) diff --git a/internal/codex/codex.go b/internal/codex/codex.go index 7b5747d9..316caf0d 100644 --- a/internal/codex/codex.go +++ b/internal/codex/codex.go @@ -182,7 +182,7 @@ func ListModels(ctx context.Context, credential Credential) ([]Model, error) { func ObserveAccount(ctx context.Context, credential Credential) (AccountObservation, error) { value, err := cpaembedded.ObserveCodexAccount(ctx, credentialToBridge(credential), "") if err != nil { - return AccountObservation{}, err + return AccountObservation{}, normalizeUpstreamError(err) } return AccountObservation{Payload: append([]byte(nil), value.Payload...), Header: value.Header.Clone()}, nil } diff --git a/internal/codex/codex_test.go b/internal/codex/codex_test.go index 8850bf36..3340a1fc 100644 --- a/internal/codex/codex_test.go +++ b/internal/codex/codex_test.go @@ -2,8 +2,11 @@ package codex import ( "encoding/json" + "errors" "reflect" "testing" + + cpaembedded "github.com/router-for-me/CLIProxyAPI/v7/gptload-embedded/embedded" ) func TestCredentialRoundTripKeepsCPACompatibleSchema(t *testing.T) { @@ -36,3 +39,11 @@ func TestCredentialRoundTripKeepsCPACompatibleSchema(t *testing.T) { t.Fatalf("secret values = %#v, want %#v", got, want) } } + +func TestNormalizeUpstreamErrorPreservesObservationStatus(t *testing.T) { + err := normalizeUpstreamError(&cpaembedded.UpstreamHTTPError{Operation: "usage", StatusCode: 401}) + var upstream *UpstreamHTTPError + if !errors.As(err, &upstream) || upstream.StatusCode != 401 || upstream.Operation != "usage" { + t.Fatalf("normalized error = %#v / %v", upstream, err) + } +} diff --git a/internal/control/credential_observations.go b/internal/control/credential_observations.go index f69d81f8..1118651d 100644 --- a/internal/control/credential_observations.go +++ b/internal/control/credential_observations.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "math" + "net/http" "strconv" "time" @@ -203,6 +204,19 @@ func (s *Service) refreshCredentialObservationOnce( defer cancelObserve() channelID := channel.ID(group.ChannelID) observation, observeErr := s.observeSubscriptionAccount(observeContext, channelID, preparedCredential) + if subscriptionObservationHTTPStatus(observeErr) == http.StatusUnauthorized && + s.prepareSubscriptionCredential != nil { + preparedCredential, err = s.prepareStoredSubscriptionCredentialWithForce( + observeContext, + group, + credential, + true, + ) + if err != nil { + return CredentialObservationResponse{}, err + } + observation, observeErr = s.observeSubscriptionAccount(observeContext, channelID, preparedCredential) + } if observeErr != nil { if errors.Is(observeErr, subscriptionruntime.ErrObservationPayloadInvalid) { return s.recordCredentialObservationFailure( @@ -210,6 +224,13 @@ func (s *Service) refreshCredentialObservationOnce( "observation_payload_invalid", "normalize subscription information", ) } + if status := subscriptionObservationHTTPStatus(observeErr); status == http.StatusUnauthorized || + status == http.StatusForbidden { + return s.recordCredentialObservationFailure( + ctx, credential, previous, attemptMS, nextAllowedMS, + "observation_authorization_failed", "authorize subscription information", + ) + } return s.recordCredentialObservationFailure( ctx, credential, previous, attemptMS, nextAllowedMS, "observation_upstream_failed", "refresh subscription information", @@ -256,6 +277,14 @@ func (s *Service) refreshCredentialObservationOnce( return response, nil } +func subscriptionObservationHTTPStatus(err error) int { + var upstream *subscriptionruntime.UpstreamHTTPError + if errors.As(err, &upstream) && upstream != nil { + return upstream.StatusCode + } + return 0 +} + func (s *Service) recordCredentialObservationFailure( ctx context.Context, credential models.Credential, diff --git a/internal/control/credential_observations_test.go b/internal/control/credential_observations_test.go index c0fcacf2..e418e5f2 100644 --- a/internal/control/credential_observations_test.go +++ b/internal/control/credential_observations_test.go @@ -14,6 +14,7 @@ import ( "gpt-load/internal/channel" "gpt-load/internal/claude" "gpt-load/internal/codex" + "gpt-load/internal/execution" app_errors "gpt-load/internal/platform/errors" "gpt-load/internal/requestlog" "gpt-load/internal/storage/models" @@ -178,6 +179,118 @@ func TestRefreshCredentialObservationPersistsLKGAndAllowsImmediateManualRefresh( } } +func TestRefreshCredentialObservationRetriesOnceAfterUnauthorized(t *testing.T) { + fixture, groupID, credentialID := newSubscriptionCredentialFixture(t) + originalPrepare := fixture.service.prepareSubscriptionCredential + forcedRefreshes := 0 + fixture.service.prepareSubscriptionCredential = func( + ctx context.Context, + channelID channel.ID, + snapshot execution.CredentialSnapshot, + force bool, + ) (subscriptionruntime.Credential, *execution.ErrorEvidence) { + if force { + forcedRefreshes++ + } + return originalPrepare(ctx, channelID, snapshot, false) + } + observationCalls := 0 + fixture.service.observeSubscriptionAccount = func( + context.Context, + channel.ID, + subscriptionruntime.Credential, + ) (subscriptionruntime.Observation, error) { + observationCalls++ + if observationCalls == 1 { + return subscriptionruntime.Observation{}, &subscriptionruntime.UpstreamHTTPError{StatusCode: 401} + } + return subscriptionruntime.Observation{Payload: []byte(`{"quota_windows":[]}`)}, nil + } + + result, err := fixture.service.RefreshCredentialObservation(t.Context(), groupID, credentialID) + if err != nil || result.State != string(models.CredentialObservationFresh) || + forcedRefreshes != 1 || observationCalls != 2 { + t.Fatalf("result/error/refreshes/calls = %#v / %v / %d / %d", result, err, forcedRefreshes, observationCalls) + } +} + +func TestRefreshCredentialObservationClassifiesRepeatedAuthorizationFailure(t *testing.T) { + fixture, groupID, credentialID := newSubscriptionCredentialFixture(t) + originalPrepare := fixture.service.prepareSubscriptionCredential + forcedRefreshes := 0 + fixture.service.prepareSubscriptionCredential = func( + ctx context.Context, + channelID channel.ID, + snapshot execution.CredentialSnapshot, + force bool, + ) (subscriptionruntime.Credential, *execution.ErrorEvidence) { + if force { + forcedRefreshes++ + } + return originalPrepare(ctx, channelID, snapshot, false) + } + observationCalls := 0 + fixture.service.observeSubscriptionAccount = func( + context.Context, + channel.ID, + subscriptionruntime.Credential, + ) (subscriptionruntime.Observation, error) { + observationCalls++ + return subscriptionruntime.Observation{}, &subscriptionruntime.UpstreamHTTPError{StatusCode: 401} + } + + result, err := fixture.service.RefreshCredentialObservation(t.Context(), groupID, credentialID) + if err == nil || result.State != string(models.CredentialObservationError) || + result.LastErrorCode != "observation_authorization_failed" || + forcedRefreshes != 1 || observationCalls != 2 { + t.Fatalf("result/error/refreshes/calls = %#v / %v / %d / %d", result, err, forcedRefreshes, observationCalls) + } +} + +func TestRefreshCredentialObservationClassifiesNonRefreshableHTTPFailure(t *testing.T) { + tests := []struct { + name string + statusCode int + errorCode string + }{ + {name: "forbidden", statusCode: 403, errorCode: "observation_authorization_failed"}, + {name: "server error", statusCode: 500, errorCode: "observation_upstream_failed"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture, groupID, credentialID := newSubscriptionCredentialFixture(t) + originalPrepare := fixture.service.prepareSubscriptionCredential + forcedRefreshes := 0 + fixture.service.prepareSubscriptionCredential = func( + ctx context.Context, + channelID channel.ID, + snapshot execution.CredentialSnapshot, + force bool, + ) (subscriptionruntime.Credential, *execution.ErrorEvidence) { + if force { + forcedRefreshes++ + } + return originalPrepare(ctx, channelID, snapshot, false) + } + observationCalls := 0 + fixture.service.observeSubscriptionAccount = func( + context.Context, + channel.ID, + subscriptionruntime.Credential, + ) (subscriptionruntime.Observation, error) { + observationCalls++ + return subscriptionruntime.Observation{}, &subscriptionruntime.UpstreamHTTPError{StatusCode: test.statusCode} + } + + result, err := fixture.service.RefreshCredentialObservation(t.Context(), groupID, credentialID) + if err == nil || result.LastErrorCode != test.errorCode || + forcedRefreshes != 0 || observationCalls != 1 { + t.Fatalf("result/error/refreshes/calls = %#v / %v / %d / %d", result, err, forcedRefreshes, observationCalls) + } + }) + } +} + func TestRefreshCredentialObservationPersistsPartialSnapshotAsStale(t *testing.T) { fixture, groupID, credentialID := newSubscriptionCredentialFixture(t) fixture.service.observeSubscriptionAccount = func( diff --git a/internal/control/discover_subscription.go b/internal/control/discover_subscription.go index a2e030f7..4ef0b4f6 100644 --- a/internal/control/discover_subscription.go +++ b/internal/control/discover_subscription.go @@ -86,6 +86,15 @@ func (s *Service) prepareStoredSubscriptionCredential( ctx context.Context, group models.Group, row models.Credential, +) (subscriptionruntime.Credential, error) { + return s.prepareStoredSubscriptionCredentialWithForce(ctx, group, row, false) +} + +func (s *Service) prepareStoredSubscriptionCredentialWithForce( + ctx context.Context, + group models.Group, + row models.Credential, + forceRefresh bool, ) (subscriptionruntime.Credential, error) { switch row.AuthState { case "", models.CredentialAuthStateReady: @@ -120,7 +129,7 @@ func (s *Service) prepareStoredSubscriptionCredential( groupCollectionCredentialVersion(row.SecretVersion), groupCollectionCredentialIdentity(row.IdentityFingerprint, group), canonical, - ), false) + ), forceRefresh) if evidence != nil { return subscriptionruntime.Credential{}, subscriptionPreparationAPIError(evidence) } diff --git a/internal/subscription/runtime/claude_driver.go b/internal/subscription/runtime/claude_driver.go index 01985f6a..8f74764d 100644 --- a/internal/subscription/runtime/claude_driver.go +++ b/internal/subscription/runtime/claude_driver.go @@ -148,6 +148,10 @@ func (*claudeDriver) Observe(ctx context.Context, credential Credential) (Observ } observed, err := claude.ObserveAccount(ctx, value) if err != nil { + var upstream *claude.UpstreamHTTPError + if errors.As(err, &upstream) { + return Observation{}, &UpstreamHTTPError{StatusCode: upstream.StatusCode} + } return Observation{}, err } normalized, err := NormalizeClaudeObservation(observed) diff --git a/internal/subscription/runtime/codex_driver.go b/internal/subscription/runtime/codex_driver.go index d811e079..00c2b431 100644 --- a/internal/subscription/runtime/codex_driver.go +++ b/internal/subscription/runtime/codex_driver.go @@ -148,6 +148,10 @@ func (*codexDriver) Observe(ctx context.Context, credential Credential) (Observa } observed, err := codex.ObserveAccount(ctx, value) if err != nil { + var upstream *codex.UpstreamHTTPError + if errors.As(err, &upstream) { + return Observation{}, &UpstreamHTTPError{StatusCode: upstream.StatusCode} + } return Observation{}, err } var detailsPayload []byte diff --git a/third_party/cpaembedded/embedded/claude_account.go b/third_party/cpaembedded/embedded/claude_account.go index 10e5e10c..c06c56fd 100644 --- a/third_party/cpaembedded/embedded/claude_account.go +++ b/third_party/cpaembedded/embedded/claude_account.go @@ -29,6 +29,16 @@ const ( // returned a usable observation. Callers retain the last-known-good snapshot. var ErrClaudeAccountObservationUnavailable = errors.New("Claude account observation is unavailable") +// ClaudeUpstreamHTTPError retains only the status needed to classify Claude +// companion endpoint failures. Provider response bodies never cross this boundary. +type ClaudeUpstreamHTTPError struct { + StatusCode int +} + +func (err *ClaudeUpstreamHTTPError) Error() string { + return fmt.Sprintf("Claude OAuth companion endpoint returned status %d", err.StatusCode) +} + // ClaudeModel is the safe model metadata returned by Claude account discovery. type ClaudeModel struct { ID string @@ -136,6 +146,15 @@ type claudeUsageWindowPayload struct { ResetsAt *string `json:"resets_at"` } +type claudeExtraUsagePayload struct { + Enabled *bool `json:"is_enabled"` + MonthlyLimit *float64 `json:"monthly_limit"` + UsedCredits *float64 `json:"used_credits"` + Utilization *float64 `json:"utilization"` + Currency *string `json:"currency"` + DisabledReason *string `json:"disabled_reason"` +} + type claudeUsagePayload struct { FiveHour *claudeUsageWindowPayload `json:"five_hour"` SevenDay *claudeUsageWindowPayload `json:"seven_day"` @@ -143,15 +162,8 @@ type claudeUsagePayload struct { SevenDayOpus *claudeUsageWindowPayload `json:"seven_day_opus"` SevenDaySonnet *claudeUsageWindowPayload `json:"seven_day_sonnet"` CinderCove *claudeUsageWindowPayload `json:"cinder_cove"` - ExtraUsage *struct { - Enabled bool `json:"is_enabled"` - MonthlyLimit *float64 `json:"monthly_limit"` - UsedCredits *float64 `json:"used_credits"` - Utilization *float64 `json:"utilization"` - Currency *string `json:"currency"` - DisabledReason *string `json:"disabled_reason"` - } `json:"extra_usage"` - Limits []struct { + ExtraUsage *claudeExtraUsagePayload `json:"extra_usage"` + Limits []struct { Kind string `json:"kind"` Group string `json:"group"` Percent float64 `json:"percent"` @@ -298,6 +310,21 @@ func ObserveClaudeAccount( } incomplete := make([]string, 0, 4) successfulSources := 0 + failedSources, commonHTTPStatus := 0, 0 + onlyHTTPFailures := true + recordFailure := func(sourceErr error) { + failedSources++ + var upstream *ClaudeUpstreamHTTPError + if sourceErr == nil || !errors.As(sourceErr, &upstream) { + onlyHTTPFailures = false + return + } + if commonHTTPStatus == 0 { + commonHTTPStatus = upstream.StatusCode + } else if commonHTTPStatus != upstream.StatusCode { + onlyHTTPFailures = false + } + } if profilePayload, profileErr := fetchClaudeOAuthProfile(ctx, options, credential.AccessToken); profileErr == nil { if err := validateClaudeProfileIdentity(credential, profilePayload); err != nil { return ClaudeAccountObservation{}, err @@ -305,6 +332,7 @@ func ObserveClaudeAccount( observedProfile, mapErr := mapClaudeAccountProfile(profilePayload) if mapErr != nil { incomplete = append(incomplete, "profile") + recordFailure(nil) } else { profile = mergeClaudeAccountProfile(profile, observedProfile) successfulSources++ @@ -313,48 +341,67 @@ func ObserveClaudeAccount( return ClaudeAccountObservation{}, profileErr } else { incomplete = append(incomplete, "profile") + recordFailure(profileErr) } if roles, rolesErr := fetchAndDecodeClaudeRoles(ctx, credential, options); rolesErr == nil { - successfulSources++ - rolesComplete := true + rolesUsable, rolesComplete := false, true if value, valueErr := claudeSafeScalar("organization role", roles.OrganizationRole, false); valueErr == nil { - profile.OrganizationRole = value + if value != "" { + profile.OrganizationRole = value + rolesUsable = true + } } else { rolesComplete = false } if value, valueErr := claudeSafeScalar("workspace role", roles.WorkspaceRole, false); valueErr == nil { - profile.WorkspaceRole = value + if value != "" { + profile.WorkspaceRole = value + rolesUsable = true + } } else { rolesComplete = false } if value, valueErr := claudeSafeScalar("organization name", roles.OrganizationName, false); valueErr == nil && value != "" { profile.OrganizationName = value + rolesUsable = true } else if valueErr != nil { rolesComplete = false } - if !rolesComplete { + if rolesUsable { + successfulSources++ + } + if !rolesUsable || !rolesComplete { incomplete = append(incomplete, "roles") } + if !rolesUsable { + recordFailure(nil) + } } else if isClaudeObservationContextError(rolesErr) { return ClaudeAccountObservation{}, rolesErr } else { incomplete = append(incomplete, "roles") + recordFailure(rolesErr) } if bootstrap, bootstrapErr := fetchClaudeBootstrap(ctx, credential, options); bootstrapErr == nil { if err := validateClaudeBootstrapIdentity(credential, bootstrap); err != nil { return ClaudeAccountObservation{}, err } - successfulSources++ - if bootstrap.OAuthAccount != nil { - if !applyClaudeBootstrapAccount(&profile, bootstrap.OAuthAccount) { - incomplete = append(incomplete, "bootstrap") - } + bootstrapUsable, bootstrapComplete := applyClaudeBootstrapAccount(&profile, bootstrap.OAuthAccount) + if bootstrapUsable { + successfulSources++ + } + if !bootstrapUsable || !bootstrapComplete { + incomplete = append(incomplete, "bootstrap") + } + if !bootstrapUsable { + recordFailure(nil) } } else if isClaudeObservationContextError(bootstrapErr) { return ClaudeAccountObservation{}, bootstrapErr } else { incomplete = append(incomplete, "bootstrap") + recordFailure(bootstrapErr) } usage, header, err := fetchAndDecodeClaudeUsage(ctx, credential, options) @@ -363,10 +410,17 @@ func ObserveClaudeAccount( return ClaudeAccountObservation{}, err } incomplete = append(incomplete, "usage") - } else { + recordFailure(err) + } else if claudeUsageUsable(usage) { successfulSources++ + } else { + incomplete = append(incomplete, "usage") + recordFailure(nil) } if successfulSources == 0 { + if failedSources == 4 && onlyHTTPFailures && commonHTTPStatus != 0 { + return ClaudeAccountObservation{}, &ClaudeUpstreamHTTPError{StatusCode: commonHTTPStatus} + } return ClaudeAccountObservation{}, ErrClaudeAccountObservationUnavailable } return ClaudeAccountObservation{ @@ -545,11 +599,18 @@ func applyClaudeBootstrapAccount( UserRateLimitTier string `json:"user_rate_limit_tier"` SeatTier string `json:"seat_tier"` }, -) bool { +) (bool, bool) { if profile == nil || account == nil { - return true + return false, false + } + usable, complete := false, true + for _, value := range []string{account.AccountUUID, account.OrganizationUUID} { + if cleaned, err := claudeSafeScalar("bootstrap account identity", value, false); err != nil { + complete = false + } else if cleaned != "" { + usable = true + } } - complete := true for target, value := range map[*string]string{ &profile.Email: account.AccountEmail, &profile.OrganizationName: account.OrganizationName, @@ -560,11 +621,29 @@ func applyClaudeBootstrapAccount( } { if cleaned, err := claudeSafeScalar("bootstrap account field", value, false); err == nil && cleaned != "" { *target = cleaned + usable = true } else if err != nil { complete = false } } - return complete + return usable, complete +} + +func claudeUsageUsable(usage ClaudeUsage) bool { + for _, window := range []*ClaudeUsageWindow{ + usage.FiveHour, + usage.SevenDay, + usage.SevenDayOAuthApps, + usage.SevenDayOpus, + usage.SevenDaySonnet, + usage.CinderCove, + } { + if window != nil && (window.Utilization != nil || + window.ResetsAt != nil && strings.TrimSpace(*window.ResetsAt) != "") { + return true + } + } + return usage.ExtraUsage != nil || len(usage.Limits) > 0 } func validateClaudeProfileIdentity(credential ClaudeCredential, profile claudeOAuthProfile) error { @@ -613,7 +692,7 @@ func mapClaudeUsage(payload claudeUsagePayload) (ClaudeUsage, error) { if result.CinderCove, err = mapClaudeUsageWindow("cinder_cove", payload.CinderCove); err != nil { return ClaudeUsage{}, err } - if payload.ExtraUsage != nil { + if claudeExtraUsagePayloadUsable(payload.ExtraUsage) { for field, value := range map[string]*float64{ "monthly_limit": payload.ExtraUsage.MonthlyLimit, "used_credits": payload.ExtraUsage.UsedCredits, @@ -632,7 +711,7 @@ func mapClaudeUsage(payload claudeUsagePayload) (ClaudeUsage, error) { return ClaudeUsage{}, err } result.ExtraUsage = &ClaudeExtraUsage{ - Enabled: payload.ExtraUsage.Enabled, + Enabled: payload.ExtraUsage.Enabled != nil && *payload.ExtraUsage.Enabled, MonthlyLimit: cloneFloat64(payload.ExtraUsage.MonthlyLimit), UsedCredits: cloneFloat64(payload.ExtraUsage.UsedCredits), Utilization: cloneFloat64(payload.ExtraUsage.Utilization), @@ -684,6 +763,12 @@ func mapClaudeUsage(payload claudeUsagePayload) (ClaudeUsage, error) { return result, nil } +func claudeExtraUsagePayloadUsable(payload *claudeExtraUsagePayload) bool { + return payload != nil && (payload.Enabled != nil || payload.MonthlyLimit != nil || + payload.UsedCredits != nil || payload.Utilization != nil || payload.Currency != nil || + payload.DisabledReason != nil) +} + func mapClaudeUsageWindow(name string, payload *claudeUsageWindowPayload) (*ClaudeUsageWindow, error) { if payload == nil { return nil, nil @@ -735,7 +820,7 @@ func fetchClaudeOAuthJSONRequest( } if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { clear(body) - return nil, nil, fmt.Errorf("Claude OAuth companion endpoint returned status %d", response.StatusCode) + return nil, nil, &ClaudeUpstreamHTTPError{StatusCode: response.StatusCode} } return body, response.Header.Clone(), nil } diff --git a/third_party/cpaembedded/embedded/claude_oauth.go b/third_party/cpaembedded/embedded/claude_oauth.go index 8a505439..c330df32 100644 --- a/third_party/cpaembedded/embedded/claude_oauth.go +++ b/third_party/cpaembedded/embedded/claude_oauth.go @@ -288,7 +288,7 @@ func fetchClaudeOAuthJSON( } if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { clear(body) - return nil, fmt.Errorf("Claude OAuth companion endpoint returned status %d", response.StatusCode) + return nil, &ClaudeUpstreamHTTPError{StatusCode: response.StatusCode} } return body, nil } diff --git a/third_party/cpaembedded/embedded/claude_test.go b/third_party/cpaembedded/embedded/claude_test.go index b11500bf..038c9260 100644 --- a/third_party/cpaembedded/embedded/claude_test.go +++ b/third_party/cpaembedded/embedded/claude_test.go @@ -929,6 +929,10 @@ func TestObserveClaudeAccountFailsWhenEverySourceIsUnavailable(t *testing.T) { if calls != 4 { t.Fatalf("observation calls = %d, want 4", calls) } + var upstream *ClaudeUpstreamHTTPError + if !errors.As(err, &upstream) || upstream.StatusCode != status { + t.Fatalf("observation error = %v, want status %d", err, status) + } if strings.Contains(err.Error(), "sensitive upstream detail") { t.Fatalf("observation error leaked upstream response: %v", err) } @@ -936,6 +940,45 @@ func TestObserveClaudeAccountFailsWhenEverySourceIsUnavailable(t *testing.T) { } } +func TestObserveClaudeAccountRejectsSourceWithoutUsableData(t *testing.T) { + credential := testClaudeExecutionCredential() + tests := []struct { + name string + path string + payload string + }{ + {name: "empty roles", path: "/roles", payload: `[]`}, + {name: "empty bootstrap", path: "/bootstrap", payload: `{}`}, + {name: "empty usage", path: "/usage", payload: `{}`}, + {name: "empty usage window", path: "/usage", payload: `{"five_hour":{}}`}, + {name: "empty extra usage", path: "/usage", payload: `{"extra_usage":{}}`}, + {name: "unknown usage schema", path: "/usage", payload: `{"new_unknown_field":"value"}`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + if request.URL.Path == test.path { + _, _ = writer.Write([]byte(test.payload)) + return + } + writer.WriteHeader(http.StatusServiceUnavailable) + _, _ = writer.Write([]byte(`{"error":"temporarily unavailable"}`)) + })) + defer server.Close() + + _, err := ObserveClaudeAccount(t.Context(), credential, ClaudeOptions{ + ProfileURL: server.URL + "/profile", RolesURL: server.URL + "/roles", + BootstrapURL: server.URL + "/bootstrap", UsageURL: server.URL + "/usage", + HTTPClient: server.Client(), + }) + if !errors.Is(err, ErrClaudeAccountObservationUnavailable) { + t.Fatalf("observation error = %v, want unavailable", err) + } + }) + } +} + func TestObserveClaudeAccountKeepsPartialProfileWhenCompanionEndpointsFail(t *testing.T) { credential := testClaudeExecutionCredential() server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { @@ -979,8 +1022,12 @@ func TestObserveClaudeAccountTreatsMissingProfileIdentityAsPartial(t *testing.T) switch request.URL.Path { case "/profile": _, _ = writer.Write([]byte(`{"account":{"email":"owner@example.com"},"organization":{"uuid":"organization-one"}}`)) - case "/roles", "/bootstrap", "/usage": - _, _ = writer.Write([]byte(`{}`)) + case "/roles": + _, _ = writer.Write([]byte(`{"organization_role":"member"}`)) + case "/bootstrap": + _, _ = writer.Write([]byte(`{"oauth_account":{"account_uuid":"account-one","organization_uuid":"organization-one"}}`)) + case "/usage": + _, _ = writer.Write([]byte(`{"five_hour":{"utilization":10}}`)) default: http.NotFound(writer, request) } diff --git a/web/src/features/groups/credentials/SubscriptionAccountCard.vue b/web/src/features/groups/credentials/SubscriptionAccountCard.vue index db65bf88..b0a5c4db 100644 --- a/web/src/features/groups/credentials/SubscriptionAccountCard.vue +++ b/web/src/features/groups/credentials/SubscriptionAccountCard.vue @@ -276,6 +276,8 @@ const failureLabel = computed(() => }`, ) const observationErrorKeys: Readonly> = { + observation_authorization_failed: + 'group.credentials.subscription.observationError.authorizationFailed', observation_upstream_failed: 'group.credentials.subscription.observationError.upstreamFailed', observation_payload_invalid: 'group.credentials.subscription.observationError.payloadInvalid', } diff --git a/web/src/i18n/locales/en-US/group.ts b/web/src/i18n/locales/en-US/group.ts index f47b072c..e87905cb 100644 --- a/web/src/i18n/locales/en-US/group.ts +++ b/web/src/i18n/locales/en-US/group.ts @@ -466,6 +466,8 @@ export default { refreshStartFailed: 'The credential refresh could not start. Try again shortly.', }, observationError: { + authorizationFailed: + 'The upstream subscription authorization is no longer valid. Sign in again.', upstreamFailed: 'Unable to read upstream subscription entitlement information', payloadInvalid: 'The upstream returned unrecognized subscription entitlement information', }, diff --git a/web/src/i18n/locales/ja-JP/group.ts b/web/src/i18n/locales/ja-JP/group.ts index 85291ef4..bff878bb 100644 --- a/web/src/i18n/locales/ja-JP/group.ts +++ b/web/src/i18n/locales/ja-JP/group.ts @@ -471,6 +471,7 @@ export default { '認証情報の更新を開始できません。しばらくしてから再試行してください。', }, observationError: { + authorizationFailed: '上流のサブスクリプション認証が無効です。再ログインしてください', upstreamFailed: '上流のサブスクリプション利用枠情報を取得できません', payloadInvalid: '上流から認識できないサブスクリプション利用枠情報が返されました', }, diff --git a/web/src/i18n/locales/zh-CN/group.ts b/web/src/i18n/locales/zh-CN/group.ts index effbf504..a63f207c 100644 --- a/web/src/i18n/locales/zh-CN/group.ts +++ b/web/src/i18n/locales/zh-CN/group.ts @@ -449,6 +449,7 @@ export default { refreshStartFailed: '当前无法开始刷新凭据,请稍后重试。', }, observationError: { + authorizationFailed: '上游订阅授权已失效,请重新登录账号', upstreamFailed: '无法读取上游订阅权益信息', payloadInvalid: '上游返回了无法识别的订阅权益信息', },