fix(subscription): 修复账户观察软失败与授权重试
This commit is contained in:
16 files changed
+360
-32
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+112
-27
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
+49
-2
@@ -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)
|
||||
}
|
||||
|
||||
@@ -276,6 +276,8 @@ const failureLabel = computed(() =>
|
||||
}`,
|
||||
)
|
||||
const observationErrorKeys: Readonly<Record<string, string>> = {
|
||||
observation_authorization_failed:
|
||||
'group.credentials.subscription.observationError.authorizationFailed',
|
||||
observation_upstream_failed: 'group.credentials.subscription.observationError.upstreamFailed',
|
||||
observation_payload_invalid: 'group.credentials.subscription.observationError.payloadInvalid',
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -471,6 +471,7 @@ export default {
|
||||
'認証情報の更新を開始できません。しばらくしてから再試行してください。',
|
||||
},
|
||||
observationError: {
|
||||
authorizationFailed: '上流のサブスクリプション認証が無効です。再ログインしてください',
|
||||
upstreamFailed: '上流のサブスクリプション利用枠情報を取得できません',
|
||||
payloadInvalid: '上流から認識できないサブスクリプション利用枠情報が返されました',
|
||||
},
|
||||
|
||||
@@ -449,6 +449,7 @@ export default {
|
||||
refreshStartFailed: '当前无法开始刷新凭据,请稍后重试。',
|
||||
},
|
||||
observationError: {
|
||||
authorizationFailed: '上游订阅授权已失效,请重新登录账号',
|
||||
upstreamFailed: '无法读取上游订阅权益信息',
|
||||
payloadInvalid: '上游返回了无法识别的订阅权益信息',
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user