From a36cf1be5371a0c5a275988eed82520cc6e43101 Mon Sep 17 00:00:00 2001 From: tbphp Date: Sun, 16 Aug 2026 18:52:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(claude):=20=E8=A1=A5=E9=BD=90=E8=AE=A2?= =?UTF-8?q?=E9=98=85=E6=B8=A0=E9=81=93=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- THIRD_PARTY_NOTICES.md | 2 +- go.mod | 2 +- go.sum | 4 +- internal/channel/claude_module_test.go | 14 + internal/channel/modules/claude.go | 10 +- internal/claude/account.go | 175 +++++ internal/control/credential_observations.go | 26 +- .../control/credential_observations_test.go | 87 +++ internal/control/discover_group_test.go | 56 ++ internal/execution/cpa/claude_provider.go | 6 +- .../execution/cpa/claude_provider_test.go | 22 +- internal/execution/cpa/provider.go | 16 + internal/execution/cpa/provider_test.go | 23 + .../subscription/runtime/claude_driver.go | 60 +- .../runtime/claude_driver_test.go | 7 +- internal/subscription/runtime/claude_quota.go | 295 ++++++++ .../subscription/runtime/claude_quota_test.go | 141 ++++ internal/subscription/runtime/codex_quota.go | 26 +- internal/subscription/runtime/runtime_test.go | 8 +- third_party/cpaembedded/README.md | 9 +- third_party/cpaembedded/embedded/claude.go | 14 +- .../cpaembedded/embedded/claude_account.go | 699 ++++++++++++++++++ .../cpaembedded/embedded/claude_executor.go | 31 +- .../cpaembedded/embedded/claude_oauth.go | 16 +- .../cpaembedded/embedded/claude_test.go | 234 ++++++ third_party/cpaembedded/embedded/live_test.go | 41 +- third_party/cpaembedded/go.mod | 2 +- third_party/cpaembedded/go.sum | 4 +- web/src/api/control/types.ts | 18 + web/src/app/resources/credentials.ts | 54 ++ .../credentials/SubscriptionAccountCard.vue | 59 ++ web/src/i18n/locales/en-US/group.ts | 26 +- web/src/i18n/locales/ja-JP/group.ts | 26 +- web/src/i18n/locales/zh-CN/group.ts | 26 +- 34 files changed, 2170 insertions(+), 69 deletions(-) create mode 100644 internal/claude/account.go create mode 100644 internal/subscription/runtime/claude_quota.go create mode 100644 internal/subscription/runtime/claude_quota_test.go create mode 100644 third_party/cpaembedded/embedded/claude_account.go diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 82c1d1d3..2ad7c1fe 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -21,7 +21,7 @@ The complete Apache License 2.0 text is distributed in ## CLIProxyAPI - Module: `github.com/router-for-me/CLIProxyAPI/v7` -- Version: `v7.2.130` +- Version: `v7.2.133` - Copyright: 2025-2005.9 Luis Pater; 2025.9-present Router-For.ME - License: MIT License diff --git a/go.mod b/go.mod index fe5260d5..5ccbffc2 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,7 @@ require ( github.com/pierrec/xxHash v0.1.5 // indirect github.com/redis/go-redis/v9 v9.19.0 // indirect github.com/refraction-networking/utls v1.8.2 // indirect - github.com/router-for-me/CLIProxyAPI/v7 v7.2.130 // indirect + github.com/router-for-me/CLIProxyAPI/v7 v7.2.133 // indirect github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect github.com/tiktoken-go/tokenizer v0.8.1 // indirect go.uber.org/atomic v1.11.0 // indirect diff --git a/go.sum b/go.sum index 791f7a13..3643e9ee 100644 --- a/go.sum +++ b/go.sum @@ -207,8 +207,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= -github.com/router-for-me/CLIProxyAPI/v7 v7.2.130 h1:5xgjDxnluOHvvFRI7YDuPK4Fjql+I3GbrWVPZODDMG0= -github.com/router-for-me/CLIProxyAPI/v7 v7.2.130/go.mod h1:lTHwMAGajc1wKGQiRtDvYbwV0FWsM7sy+N0ZU5/gxJQ= +github.com/router-for-me/CLIProxyAPI/v7 v7.2.133 h1:TeFrEctzhSuPRNdYkiAFpFj4W1IICcvN+ot9HVnaEgw= +github.com/router-for-me/CLIProxyAPI/v7 v7.2.133/go.mod h1:lTHwMAGajc1wKGQiRtDvYbwV0FWsM7sy+N0ZU5/gxJQ= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= diff --git a/internal/channel/claude_module_test.go b/internal/channel/claude_module_test.go index b6eaaace..b1762de6 100644 --- a/internal/channel/claude_module_test.go +++ b/internal/channel/claude_module_test.go @@ -44,6 +44,20 @@ func TestClaudeModuleDeclaresSubscriptionContract(t *testing.T) { if target.ProviderKind != ProviderClaude || target.CatalogProviderID != "anthropic" { t.Fatalf("Claude target = %#v", target) } + bindings, ok := registry.CapabilityBindings(Claude) + if !ok { + t.Fatal("Claude capability bindings are missing") + } + if bindings.SubscriptionDriver != modules.ClaudeSubscriptionDriver || + bindings.ModelDiscovery != modules.ClaudeModelDiscovery || + bindings.QuotaObservation != modules.ClaudeQuotaObservation || + bindings.ResetCreditAction != "" { + t.Fatalf("Claude capabilities = %#v", bindings) + } + policy, ok := registry.SchedulingPolicy(Claude) + if !ok || !policy.QuotaPriority { + t.Fatal("Claude quota-priority scheduling is disabled") + } wantRoutes := map[protocol.Protocol]RouteMode{ protocol.Anthropic: RouteNative, protocol.OpenAICompletions: RouteConverted, diff --git a/internal/channel/modules/claude.go b/internal/channel/modules/claude.go index df24c7b4..ae5f255f 100644 --- a/internal/channel/modules/claude.go +++ b/internal/channel/modules/claude.go @@ -6,7 +6,11 @@ import ( "gpt-load/internal/protocol" ) -const ClaudeSubscriptionDriver spec.SubscriptionDriverID = "claude" +const ( + ClaudeSubscriptionDriver spec.SubscriptionDriverID = "claude" + ClaudeModelDiscovery spec.UtilityID = "claude_models" + ClaudeQuotaObservation spec.UtilityID = "claude_quota" +) // Claude declares the subscription-backed Anthropic OAuth channel. func Claude() spec.Module { @@ -45,8 +49,10 @@ func Claude() spec.Module { }, Capabilities: spec.CapabilityBindings{ SubscriptionDriver: ClaudeSubscriptionDriver, + ModelDiscovery: ClaudeModelDiscovery, + QuotaObservation: ClaudeQuotaObservation, }, - Scheduling: spec.SchedulingPolicy{QuotaPriority: false}, + Scheduling: spec.SchedulingPolicy{QuotaPriority: true}, }, } } diff --git a/internal/claude/account.go b/internal/claude/account.go new file mode 100644 index 00000000..ca4130bc --- /dev/null +++ b/internal/claude/account.go @@ -0,0 +1,175 @@ +package claude + +import ( + "context" + "net/http" + + cpaembedded "github.com/router-for-me/CLIProxyAPI/v7/gptload-embedded/embedded" +) + +type Model struct { + ID string + DisplayName string + Description string +} + +type AccountProfile struct { + DisplayName string + Email string + AccountUUID string + AccountCreatedAt string + OrganizationUUID string + OrganizationName string + OrganizationType string + OrganizationRole string + WorkspaceRole string + OrganizationRateLimitTier string + UserRateLimitTier string + SeatTier string + BillingType string + SubscriptionCreatedAt string + ExtraUsageEnabled *bool +} + +type UsageWindow struct { + Utilization *float64 + ResetsAt *string +} + +type ExtraUsage struct { + Enabled bool + MonthlyLimit *float64 + UsedCredits *float64 + Utilization *float64 + Currency *string + DisabledReason *string +} + +type ScopedLimit struct { + Kind string + Group string + Percent float64 + ResetsAt *string + ModelDisplayName string + SurfaceDisplayName string +} + +type Usage struct { + FiveHour *UsageWindow + SevenDay *UsageWindow + SevenDayOAuthApps *UsageWindow + SevenDayOpus *UsageWindow + SevenDaySonnet *UsageWindow + CinderCove *UsageWindow + ExtraUsage *ExtraUsage + Limits []ScopedLimit +} + +type AccountObservation struct { + Profile AccountProfile + Usage Usage + Header http.Header +} + +func ListModels(ctx context.Context, credential Credential) ([]Model, error) { + models, err := cpaembedded.DiscoverClaudeModels(ctx, credentialToBridge(credential), cpaembedded.ClaudeOptions{}) + if err != nil { + return nil, normalizeAuthorizationError(err) + } + result := make([]Model, 0, len(models)) + for _, model := range models { + result = append(result, Model{ + ID: model.ID, DisplayName: model.DisplayName, Description: model.Description, + }) + } + return result, nil +} + +func ObserveAccount(ctx context.Context, credential Credential) (AccountObservation, error) { + observed, err := cpaembedded.ObserveClaudeAccount(ctx, credentialToBridge(credential), cpaembedded.ClaudeOptions{}) + if err != nil { + return AccountObservation{}, normalizeAuthorizationError(err) + } + return accountObservationFromBridge(observed), nil +} + +func accountObservationFromBridge(value cpaembedded.ClaudeAccountObservation) AccountObservation { + profile := value.Profile + result := AccountObservation{ + Profile: AccountProfile{ + DisplayName: profile.DisplayName, Email: profile.Email, + AccountUUID: profile.AccountUUID, AccountCreatedAt: profile.AccountCreatedAt, + OrganizationUUID: profile.OrganizationUUID, OrganizationName: profile.OrganizationName, + OrganizationType: profile.OrganizationType, OrganizationRole: profile.OrganizationRole, + WorkspaceRole: profile.WorkspaceRole, + OrganizationRateLimitTier: profile.OrganizationRateLimitTier, + UserRateLimitTier: profile.UserRateLimitTier, SeatTier: profile.SeatTier, + BillingType: profile.BillingType, SubscriptionCreatedAt: profile.SubscriptionCreatedAt, + ExtraUsageEnabled: cloneBool(profile.ExtraUsageEnabled), + }, + Header: value.Header.Clone(), + } + result.Usage = usageFromBridge(value.Usage) + return result +} + +func usageFromBridge(value cpaembedded.ClaudeUsage) Usage { + result := Usage{ + FiveHour: usageWindowFromBridge(value.FiveHour), + SevenDay: usageWindowFromBridge(value.SevenDay), + SevenDayOAuthApps: usageWindowFromBridge(value.SevenDayOAuthApps), + SevenDayOpus: usageWindowFromBridge(value.SevenDayOpus), + SevenDaySonnet: usageWindowFromBridge(value.SevenDaySonnet), + CinderCove: usageWindowFromBridge(value.CinderCove), + } + if value.ExtraUsage != nil { + result.ExtraUsage = &ExtraUsage{ + Enabled: value.ExtraUsage.Enabled, + MonthlyLimit: cloneFloat(value.ExtraUsage.MonthlyLimit), + UsedCredits: cloneFloat(value.ExtraUsage.UsedCredits), + Utilization: cloneFloat(value.ExtraUsage.Utilization), + Currency: cloneString(value.ExtraUsage.Currency), + DisabledReason: cloneString(value.ExtraUsage.DisabledReason), + } + } + result.Limits = make([]ScopedLimit, 0, len(value.Limits)) + for _, limit := range value.Limits { + result.Limits = append(result.Limits, ScopedLimit{ + Kind: limit.Kind, Group: limit.Group, Percent: limit.Percent, + ResetsAt: cloneString(limit.ResetsAt), ModelDisplayName: limit.ModelDisplayName, + SurfaceDisplayName: limit.SurfaceDisplayName, + }) + } + return result +} + +func usageWindowFromBridge(value *cpaembedded.ClaudeUsageWindow) *UsageWindow { + if value == nil { + return nil + } + return &UsageWindow{Utilization: cloneFloat(value.Utilization), ResetsAt: cloneString(value.ResetsAt)} +} + +func cloneFloat(value *float64) *float64 { + if value == nil { + return nil + } + result := *value + return &result +} + +func cloneString(value *string) *string { + if value == nil { + return nil + } + result := *value + return &result +} + +func cloneBool(value *bool) *bool { + if value == nil { + return nil + } + result := *value + return &result +} diff --git a/internal/control/credential_observations.go b/internal/control/credential_observations.go index 1428bca2..6302d6fe 100644 --- a/internal/control/credential_observations.go +++ b/internal/control/credential_observations.go @@ -28,6 +28,23 @@ type ObservationPlanSummary struct { Name string `json:"name,omitempty"` } +type ObservationAccountSummary struct { + DisplayName string `json:"display_name,omitempty"` + Email string `json:"email,omitempty"` + OrganizationName string `json:"organization_name,omitempty"` + OrganizationType string `json:"organization_type,omitempty"` + OrganizationRole string `json:"organization_role,omitempty"` + WorkspaceRole string `json:"workspace_role,omitempty"` + OrganizationRateLimitTier string `json:"organization_rate_limit_tier,omitempty"` + UserRateLimitTier string `json:"user_rate_limit_tier,omitempty"` + SeatTier string `json:"seat_tier,omitempty"` + BillingType string `json:"billing_type,omitempty"` + ExtraUsageEnabled *bool `json:"extra_usage_enabled,omitempty"` + ExtraUsageDisabledReason string `json:"extra_usage_disabled_reason,omitempty"` + AccountCreatedAtMS *int64 `json:"account_created_at_ms,omitempty"` + SubscriptionCreatedAtMS *int64 `json:"subscription_created_at_ms,omitempty"` +} + type ObservationQuotaWindow struct { ID string `json:"id"` Label string `json:"label"` @@ -61,10 +78,11 @@ type ObservationWindowUsage struct { } type CredentialObservationSnapshot struct { - Plan ObservationPlanSummary `json:"plan_summary"` - QuotaWindows []ObservationQuotaWindow `json:"quota_windows"` - ResetCreditsAvailable *int64 `json:"reset_credits_available,omitempty"` - ResetCredits []ObservationResetCredit `json:"reset_credits,omitempty"` + Plan ObservationPlanSummary `json:"plan_summary"` + Account *ObservationAccountSummary `json:"account_summary,omitempty"` + QuotaWindows []ObservationQuotaWindow `json:"quota_windows"` + ResetCreditsAvailable *int64 `json:"reset_credits_available,omitempty"` + ResetCredits []ObservationResetCredit `json:"reset_credits,omitempty"` } type CredentialObservationResponse struct { diff --git a/internal/control/credential_observations_test.go b/internal/control/credential_observations_test.go index cc27eeb0..1084d864 100644 --- a/internal/control/credential_observations_test.go +++ b/internal/control/credential_observations_test.go @@ -12,10 +12,12 @@ import ( "time" "gpt-load/internal/channel" + "gpt-load/internal/claude" "gpt-load/internal/codex" app_errors "gpt-load/internal/platform/errors" "gpt-load/internal/requestlog" "gpt-load/internal/storage/models" + subscriptionruntime "gpt-load/internal/subscription/runtime" ) func TestNormalizeCodexObservationKeepsDynamicWindowsAndStableOrder(t *testing.T) { @@ -45,6 +47,28 @@ func TestNormalizeCodexObservationKeepsDynamicWindowsAndStableOrder(t *testing.T } } +func TestMapCredentialObservationKeepsSafeAccountSummary(t *testing.T) { + createdAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC).UnixMilli() + row := models.CredentialObservation{ + CredentialID: 1, ObservationVersion: 1, State: models.CredentialObservationFresh, + SnapshotJSON: models.JSON(fmt.Sprintf(`{ + "plan_summary":{"name":"Team"}, + "account_summary":{"display_name":"Owner","organization_name":"Example Org","seat_tier":"team_standard","account_created_at_ms":%d}, + "quota_windows":[] + }`, createdAt)), + } + + result := mapCredentialObservation(row) + if result.Snapshot == nil || result.Snapshot.Account == nil || + result.Snapshot.Account.DisplayName != "Owner" || + result.Snapshot.Account.OrganizationName != "Example Org" || + result.Snapshot.Account.SeatTier != "team_standard" || + result.Snapshot.Account.AccountCreatedAtMS == nil || + *result.Snapshot.Account.AccountCreatedAtMS != createdAt { + t.Fatalf("observation = %#v", result) + } +} + func TestNormalizeCodexResetCreditDetailsKeepsOnlyAvailableCodexCredits(t *testing.T) { t.Parallel() @@ -203,6 +227,69 @@ func TestRefreshCredentialObservationKeepsUsageWhenResetCreditDetailsFail(t *tes } } +func TestRefreshClaudeCredentialObservationPublishesAccountAndQuota(t *testing.T) { + fixture := newServiceFixture(t) + now := time.Date(2026, time.August, 16, 8, 0, 0, 0, time.UTC) + fixture.service.now = func() time.Time { return now } + stage, err := fixture.service.ImportCredentialStage(t.Context(), channel.Claude, []byte( + `{"type":"claude","access_token":"claude-access","refresh_token":"claude-refresh","account_uuid":"claude-account","email":"owner@example.com"}`, + )) + if err != nil { + t.Fatal(err) + } + created, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{ + Name: stringPointer("Claude observation"), ChannelID: channel.Claude, + ConnectionType: models.ConnectionTypeSubscription, + Models: optionalGroupModels{Set: true, Values: []GroupModel{{ID: "claude-sonnet-4-6"}}}, + StagedCredentialIDs: []string{stage.StageID}, + }) + if err != nil { + t.Fatal(err) + } + var credential models.Credential + if err := fixture.db.Take(&credential, "group_id = ?", created.GroupID).Error; err != nil { + t.Fatal(err) + } + fixture.service.observeSubscriptionAccount = func( + _ context.Context, + channelID channel.ID, + credential subscriptionruntime.Credential, + ) (subscriptionruntime.Observation, error) { + if channelID != channel.Claude { + return subscriptionruntime.Observation{}, errors.New("unexpected subscription channel") + } + parsed, parseErr := claude.ParseCredentialJSON(credential.Canonical()) + if parseErr != nil || parsed.AccountUUID != "claude-account" { + t.Fatalf("Claude credential = %#v, %v", parsed, parseErr) + } + utilization := 25.0 + reset := now.Add(5 * time.Hour).Format(time.RFC3339) + extraUsage := true + payload, normalizeErr := subscriptionruntime.NormalizeClaudeObservation(claude.AccountObservation{ + Profile: claude.AccountProfile{ + DisplayName: "Owner", Email: "owner@example.com", OrganizationName: "Example Org", + OrganizationType: "claude_team", SeatTier: "team_standard", ExtraUsageEnabled: &extraUsage, + }, + Usage: claude.Usage{FiveHour: &claude.UsageWindow{Utilization: &utilization, ResetsAt: &reset}}, + }) + return subscriptionruntime.Observation{Payload: payload}, normalizeErr + } + + result, err := fixture.service.RefreshCredentialObservation(t.Context(), created.GroupID, credential.ID) + if err != nil || result.Snapshot == nil || result.Snapshot.Account == nil || + result.Snapshot.Account.DisplayName != "Owner" || + result.Snapshot.Account.OrganizationName != "Example Org" || + result.Snapshot.Plan.Name != "Claude Team" || len(result.Snapshot.QuotaWindows) != 1 || + result.Snapshot.QuotaWindows[0].Utilization == nil || + *result.Snapshot.QuotaWindows[0].Utilization != 0.25 { + t.Fatalf("Claude observation = %#v, %v", result, err) + } + views := fixture.registry.Snapshot() + if len(views) != 1 || views[0].QuotaRemaining == nil || *views[0].QuotaRemaining != 0.75 { + t.Fatalf("Claude quota runtime = %#v", views) + } +} + func TestEnrichCredentialObservationUsageUsesHourlyStatsForEveryWindow(t *testing.T) { fixture := newServiceFixture(t) now := time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC) diff --git a/internal/control/discover_group_test.go b/internal/control/discover_group_test.go index a7232231..43f42b0b 100644 --- a/internal/control/discover_group_test.go +++ b/internal/control/discover_group_test.go @@ -13,6 +13,7 @@ import ( "gorm.io/gorm" "gpt-load/internal/channel" + "gpt-load/internal/claude" "gpt-load/internal/codex" "gpt-load/internal/execution" app_errors "gpt-load/internal/platform/errors" @@ -20,6 +21,7 @@ import ( "gpt-load/internal/state" "gpt-load/internal/storage" "gpt-load/internal/storage/models" + subscriptionruntime "gpt-load/internal/subscription/runtime" ) func TestDiscoveryUsesSingleReadSnapshot(t *testing.T) { @@ -340,6 +342,60 @@ func TestDiscoverGroupModelsUsesSubscriptionCredential(t *testing.T) { } } +func TestClaudeGroupDiscoversModelsAndBecomesAvailableAfterSelection(t *testing.T) { + fixture := newServiceFixture(t) + stage, err := fixture.service.ImportCredentialStage(t.Context(), channel.Claude, []byte( + `{"type":"claude","access_token":"claude-access","refresh_token":"claude-refresh","account_uuid":"claude-account","email":"claude@example.com"}`, + )) + if err != nil { + t.Fatal(err) + } + created, err := fixture.service.CreateGroup(t.Context(), GroupCreateRequest{ + Name: stringPointer("Claude subscription discovery"), ChannelID: channel.Claude, + ConnectionType: models.ConnectionTypeSubscription, + Models: optionalGroupModels{Set: true, Values: []GroupModel{}}, + StagedCredentialIDs: []string{stage.StageID}, + }) + if err != nil { + t.Fatal(err) + } + before, err := fixture.service.GetGroupSummary(t.Context(), created.GroupID) + if err != nil || before.ServiceStatus != GroupCollectionStatusUnavailable || before.ModelCount != 0 { + t.Fatalf("summary before discovery = %#v, %v", before, err) + } + + fixture.service.discoverSubscriptionModels = func( + _ context.Context, + channelID channel.ID, + credential subscriptionruntime.Credential, + ) ([]string, error) { + if channelID != channel.Claude { + return nil, errors.New("unexpected subscription channel") + } + parsed, parseErr := claude.ParseCredentialJSON(credential.Canonical()) + if parseErr != nil || parsed.AccountUUID != "claude-account" { + t.Fatalf("Claude credential = %#v, %v", parsed, parseErr) + } + return []string{"claude-sonnet-4-6", "claude-opus-4-6"}, nil + } + discovered, err := fixture.service.DiscoverGroupModels(t.Context(), created.GroupID) + if err != nil || len(discovered.Models) != 2 || discovered.Models[0].ID != "claude-sonnet-4-6" { + t.Fatalf("discovered models = %#v, %v", discovered, err) + } + updated, err := fixture.service.UpdateGroupModels(t.Context(), created.GroupID, GroupModelsUpdateRequest{ + Models: optionalGroupModels{Set: true, Values: []GroupModel{ + {ID: discovered.Models[0].ID}, {ID: discovered.Models[1].ID}, + }}, + }) + if err != nil || updated.Total != 2 { + t.Fatalf("updated models = %#v, %v", updated, err) + } + after, err := fixture.service.GetGroupSummary(t.Context(), created.GroupID) + if err != nil || after.ServiceStatus != GroupCollectionStatusAvailable || after.ModelCount != 2 || after.CredentialCount != 1 { + t.Fatalf("summary after selection = %#v, %v", after, err) + } +} + func TestDiscoverGroupModelsPreparesSubscriptionCredential(t *testing.T) { fixture := newServiceFixture(t) stage := mustImportSubscriptionStage(t, fixture, "account-prepared-models", "prepared@example.com") diff --git a/internal/execution/cpa/claude_provider.go b/internal/execution/cpa/claude_provider.go index 5dfdcfe2..08e9e7dc 100644 --- a/internal/execution/cpa/claude_provider.go +++ b/internal/execution/cpa/claude_provider.go @@ -168,7 +168,11 @@ func (*claudeProviderBridge) ClassifyError( evidence.ReplaySafety = execution.ReplaySafetyRejectedBeforeProcessing } case status == http.StatusTooManyRequests: - evidence.Hint = execution.FailureHintRateLimited + if credentialScoped, known := credentialScopedFailure(err); known && !credentialScoped { + evidence.Hint = execution.FailureHintRequestRejected + } else { + evidence.Hint = execution.FailureHintRateLimited + } case status >= http.StatusInternalServerError: evidence.Hint = execution.FailureHintHostError } diff --git a/internal/execution/cpa/claude_provider_test.go b/internal/execution/cpa/claude_provider_test.go index 7df8f0eb..026ed2fb 100644 --- a/internal/execution/cpa/claude_provider_test.go +++ b/internal/execution/cpa/claude_provider_test.go @@ -41,12 +41,13 @@ func (executor *fakeClaudeExecutor) ExecuteStream( } type classifiedClaudeError struct { - status int - typeValue string - codeValue string - retryAfter time.Duration - requestScoped bool - summary string + status int + typeValue string + codeValue string + retryAfter time.Duration + requestScoped bool + credentialScoped bool + summary string } func (err *classifiedClaudeError) Error() string { return err.summary } @@ -54,6 +55,7 @@ func (err *classifiedClaudeError) StatusCode() int { return err.statu func (err *classifiedClaudeError) ErrorType() string { return err.typeValue } func (err *classifiedClaudeError) ErrorCode() string { return err.codeValue } func (err *classifiedClaudeError) IsRequestScoped() bool { return err.requestScoped } +func (err *classifiedClaudeError) IsCredentialScoped() bool { return err.credentialScoped } func (err *classifiedClaudeError) RetryAfter() *time.Duration { return &err.retryAfter } func claudeProviderCredentialForTest(t *testing.T) claudeProviderCredential { @@ -122,8 +124,14 @@ func TestClaudeProviderMapsExecutionAndRequestScopedErrors(t *testing.T) { requestRejected.requestScoped = false _, evidence = bridge.ClassifyError(t.Context(), requestRejected, credential) + if evidence == nil || evidence.Hint != execution.FailureHintRequestRejected { + t.Fatalf("model-scoped rate-limit evidence = %#v", evidence) + } + + requestRejected.credentialScoped = true + _, evidence = bridge.ClassifyError(t.Context(), requestRejected, credential) if evidence == nil || evidence.Hint != execution.FailureHintRateLimited { - t.Fatalf("real rate-limit evidence = %#v", evidence) + t.Fatalf("credential-scoped rate-limit evidence = %#v", evidence) } } diff --git a/internal/execution/cpa/provider.go b/internal/execution/cpa/provider.go index 7a99dee9..edc77e33 100644 --- a/internal/execution/cpa/provider.go +++ b/internal/execution/cpa/provider.go @@ -29,6 +29,22 @@ func requestScopedFailure(err error) bool { return errors.As(err, &scoped) && scoped != nil && scoped.IsRequestScoped() } +// credentialScopedFailure reports whether a provider explicitly classified a +// failure as belonging to the selected credential. The second result is false +// when the provider did not supply this stronger scope signal. +func credentialScopedFailure(err error) (bool, bool) { + if err == nil { + return false, false + } + var scoped interface { + IsCredentialScoped() bool + } + if !errors.As(err, &scoped) || scoped == nil { + return false, false + } + return scoped.IsCredentialScoped(), true +} + type providerRequest struct { Model string Payload []byte diff --git a/internal/execution/cpa/provider_test.go b/internal/execution/cpa/provider_test.go index bb2a4f77..da73c046 100644 --- a/internal/execution/cpa/provider_test.go +++ b/internal/execution/cpa/provider_test.go @@ -15,6 +15,11 @@ type requestScopedTestError struct{} func (requestScopedTestError) Error() string { return "request rejected" } func (requestScopedTestError) IsRequestScoped() bool { return true } +type credentialScopedTestError bool + +func (credentialScopedTestError) Error() string { return "credential-scoped test error" } +func (err credentialScopedTestError) IsCredentialScoped() bool { return bool(err) } + type recordingProviderBridge struct { kind channel.ProviderKind validatedRoute channel.RouteDescriptor @@ -70,4 +75,22 @@ func TestRequestScopedFailureSurvivesErrorWrapping(t *testing.T) { } } +func TestCredentialScopedFailureSurvivesErrorWrapping(t *testing.T) { + for _, test := range []struct { + value credentialScopedTestError + want bool + }{ + {value: true, want: true}, + {value: false, want: false}, + } { + got, known := credentialScopedFailure(errors.Join(errors.New("outer"), test.value)) + if !known || got != test.want { + t.Fatalf("credentialScopedFailure() = %t, %t; want %t, true", got, known, test.want) + } + } + if got, known := credentialScopedFailure(errors.New("plain")); got || known { + t.Fatalf("unclassified failure = %t, %t", got, known) + } +} + var _ providerBridge = (*recordingProviderBridge)(nil) diff --git a/internal/subscription/runtime/claude_driver.go b/internal/subscription/runtime/claude_driver.go index 1af6aa35..57cef8bc 100644 --- a/internal/subscription/runtime/claude_driver.go +++ b/internal/subscription/runtime/claude_driver.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "strings" "time" @@ -16,10 +17,15 @@ type claudeDriver struct{} func newClaudeDriver() *claudeDriver { return &claudeDriver{} } -// ClaudeImplementations returns the Claude subscription lifecycle without -// model discovery, quota observation, or credential actions. +// ClaudeImplementations returns the concrete Claude subscription behavior +// assembled by the application composition root. func ClaudeImplementations() Implementations { - return Implementations{Drivers: []Driver{newClaudeDriver()}} + driver := newClaudeDriver() + return Implementations{ + Drivers: []Driver{driver}, + ModelDiscoveries: []ModelDiscovery{driver.modelDiscovery()}, + QuotaObservations: []QuotaObservation{driver.quotaObservation()}, + } } func (*claudeDriver) ID() spec.SubscriptionDriverID { return modules.ClaudeSubscriptionDriver } @@ -119,6 +125,54 @@ func (*claudeDriver) AuthorizationFailureDefinitive(err error) bool { } } +func (*claudeDriver) DiscoverModels(ctx context.Context, credential Credential) ([]string, error) { + value, err := claude.ParseCredentialJSON(credential.canonical) + if err != nil { + return nil, err + } + models, err := claude.ListModels(ctx, value) + if err != nil { + return nil, err + } + result := make([]string, 0, len(models)) + for _, model := range models { + result = append(result, model.ID) + } + return result, nil +} + +func (*claudeDriver) Observe(ctx context.Context, credential Credential) (Observation, error) { + value, err := claude.ParseCredentialJSON(credential.canonical) + if err != nil { + return Observation{}, err + } + observed, err := claude.ObserveAccount(ctx, value) + if err != nil { + return Observation{}, err + } + normalized, err := NormalizeClaudeObservation(observed) + if err != nil { + return Observation{}, fmt.Errorf("%w: %v", ErrObservationPayloadInvalid, err) + } + return Observation{Payload: normalized, Header: observed.Header.Clone()}, nil +} + +// Go cannot overload ID across the narrow capability interfaces, so wrappers +// expose each typed ID while sharing the implementation below. +type claudeModelDiscovery struct{ *claudeDriver } +type claudeQuotaObservation struct{ *claudeDriver } + +func (driver *claudeDriver) modelDiscovery() ModelDiscovery { + return claudeModelDiscovery{driver} +} + +func (driver *claudeDriver) quotaObservation() QuotaObservation { + return claudeQuotaObservation{driver} +} + +func (claudeModelDiscovery) ID() spec.UtilityID { return modules.ClaudeModelDiscovery } +func (claudeQuotaObservation) ID() spec.UtilityID { return modules.ClaudeQuotaObservation } + func claudeRuntimeCredential(value claude.Credential, canonical []byte) Credential { expiresAt, expires := claude.CredentialExpiresAt(value) account := Account{Email: strings.TrimSpace(value.Email), ExpiresAt: expiresAt, ExpiresAtKnown: expires} diff --git a/internal/subscription/runtime/claude_driver_test.go b/internal/subscription/runtime/claude_driver_test.go index 4d429b0f..d34af33d 100644 --- a/internal/subscription/runtime/claude_driver_test.go +++ b/internal/subscription/runtime/claude_driver_test.go @@ -65,10 +65,13 @@ func TestClaudeDriverClassifiesRefreshFailures(t *testing.T) { } } -func TestClaudeImplementationsExposeOnlyCredentialLifecycle(t *testing.T) { +func TestClaudeImplementationsExposeCompleteReadOnlyCapabilities(t *testing.T) { implementations := ClaudeImplementations() if len(implementations.Drivers) != 1 || implementations.Drivers[0].ID() != modules.ClaudeSubscriptionDriver || - len(implementations.ModelDiscoveries) != 0 || len(implementations.QuotaObservations) != 0 || + len(implementations.ModelDiscoveries) != 1 || + implementations.ModelDiscoveries[0].ID() != modules.ClaudeModelDiscovery || + len(implementations.QuotaObservations) != 1 || + implementations.QuotaObservations[0].ID() != modules.ClaudeQuotaObservation || len(implementations.ResetCreditActions) != 0 { t.Fatalf("implementations = %#v", implementations) } diff --git a/internal/subscription/runtime/claude_quota.go b/internal/subscription/runtime/claude_quota.go new file mode 100644 index 00000000..d7ff75d9 --- /dev/null +++ b/internal/subscription/runtime/claude_quota.go @@ -0,0 +1,295 @@ +package subscriptionruntime + +import ( + "encoding/json" + "fmt" + "math" + "sort" + "strings" + "time" + + "gpt-load/internal/claude" +) + +func NormalizeClaudeObservation(observation claude.AccountObservation) ([]byte, error) { + account, err := normalizeClaudeAccount(observation.Profile, observation.Usage.ExtraUsage) + if err != nil { + return nil, err + } + result := quotaSnapshot{ + Plan: quotaPlanSummary{Name: claudePlanName(observation.Profile)}, + Account: account, + QuotaWindows: make([]quotaWindow, 0, 7+len(observation.Usage.Limits)), + } + seenWindowIDs := make(map[string]int, cap(result.QuotaWindows)) + + for _, candidate := range []struct { + id string + label string + windowSeconds int64 + primary bool + value *claude.UsageWindow + }{ + {id: "five_hour", label: "5h", windowSeconds: 5 * 60 * 60, primary: true, value: observation.Usage.FiveHour}, + {id: "seven_day", label: "7d", windowSeconds: 7 * 24 * 60 * 60, value: observation.Usage.SevenDay}, + {id: "seven_day_oauth_apps", label: "7d · OAuth apps", windowSeconds: 7 * 24 * 60 * 60, value: observation.Usage.SevenDayOAuthApps}, + {id: "seven_day_opus", label: "7d · Opus", windowSeconds: 7 * 24 * 60 * 60, value: observation.Usage.SevenDayOpus}, + {id: "seven_day_sonnet", label: "7d · Sonnet", windowSeconds: 7 * 24 * 60 * 60, value: observation.Usage.SevenDaySonnet}, + {id: "cinder_cove", label: "Cinder Cove", value: observation.Usage.CinderCove}, + } { + if candidate.value == nil { + continue + } + window, err := normalizeClaudePercentageWindow( + candidate.id, + candidate.label, + claudeWindowScope(candidate.id), + candidate.windowSeconds, + candidate.primary, + candidate.value.Utilization, + candidate.value.ResetsAt, + ) + if err != nil { + return nil, err + } + appendClaudeQuotaWindow(&result.QuotaWindows, seenWindowIDs, window) + } + + if extra := observation.Usage.ExtraUsage; extra != nil { + window, err := normalizeClaudeExtraUsage(extra) + if err != nil { + return nil, err + } + appendClaudeQuotaWindow(&result.QuotaWindows, seenWindowIDs, window) + } + for _, limit := range observation.Usage.Limits { + window, err := normalizeClaudeScopedLimit(limit) + if err != nil { + return nil, err + } + appendClaudeQuotaWindow(&result.QuotaWindows, seenWindowIDs, window) + } + + sort.SliceStable(result.QuotaWindows, func(i, j int) bool { + left, right := result.QuotaWindows[i], result.QuotaWindows[j] + if left.IsPrimary != right.IsPrimary { + return left.IsPrimary + } + leftSeconds, rightSeconds := int64(math.MaxInt64), int64(math.MaxInt64) + if left.WindowSeconds != nil { + leftSeconds = *left.WindowSeconds + } + if right.WindowSeconds != nil { + rightSeconds = *right.WindowSeconds + } + if leftSeconds != rightSeconds { + return leftSeconds < rightSeconds + } + return left.ID < right.ID + }) + return json.Marshal(result) +} + +func appendClaudeQuotaWindow(windows *[]quotaWindow, seen map[string]int, window quotaWindow) { + base, candidate := window.ID, window.ID + for suffix := 2; seen[candidate] > 0; suffix++ { + candidate = fmt.Sprintf("%s_%d", base, suffix) + } + seen[candidate] = 1 + window.ID = candidate + *windows = append(*windows, window) +} + +func normalizeClaudeAccount(profile claude.AccountProfile, extraUsage *claude.ExtraUsage) (*quotaAccountSummary, error) { + result := "aAccountSummary{ + DisplayName: strings.TrimSpace(profile.DisplayName), Email: strings.TrimSpace(profile.Email), + OrganizationName: strings.TrimSpace(profile.OrganizationName), + OrganizationType: strings.TrimSpace(profile.OrganizationType), + OrganizationRole: strings.TrimSpace(profile.OrganizationRole), + WorkspaceRole: strings.TrimSpace(profile.WorkspaceRole), + OrganizationRateLimitTier: strings.TrimSpace(profile.OrganizationRateLimitTier), + UserRateLimitTier: strings.TrimSpace(profile.UserRateLimitTier), + SeatTier: strings.TrimSpace(profile.SeatTier), BillingType: strings.TrimSpace(profile.BillingType), + ExtraUsageEnabled: cloneBoolPointer(profile.ExtraUsageEnabled), + } + if extraUsage != nil { + result.ExtraUsageEnabled = boolPointerValue(extraUsage.Enabled) + if extraUsage.DisabledReason != nil { + result.ExtraUsageDisabledReason = strings.TrimSpace(*extraUsage.DisabledReason) + } + } + var err error + if result.AccountCreatedAtMS, err = claudeTimeMilliseconds("account creation", profile.AccountCreatedAt); err != nil { + return nil, err + } + if result.SubscriptionCreatedAtMS, err = claudeTimeMilliseconds("subscription creation", profile.SubscriptionCreatedAt); err != nil { + return nil, err + } + return result, nil +} + +func claudePlanName(profile claude.AccountProfile) string { + for _, value := range []string{profile.OrganizationType, profile.SeatTier} { + normalized := strings.NewReplacer("-", "_", " ", "_").Replace(strings.ToLower(strings.TrimSpace(value))) + switch { + case strings.Contains(normalized, "enterprise"): + return "Claude Enterprise" + case strings.Contains(normalized, "team"): + return "Claude Team" + case strings.Contains(normalized, "max"): + return "Claude Max" + case strings.Contains(normalized, "pro"): + return "Claude Pro" + case strings.Contains(normalized, "free"): + return "Claude Free" + } + } + return "" +} + +func claudeWindowScope(id string) string { + switch id { + case "five_hour", "seven_day": + return "account" + case "seven_day_opus", "seven_day_sonnet": + return "model" + default: + return "surface" + } +} + +func normalizeClaudePercentageWindow( + id, label, scope string, + windowSeconds int64, + primary bool, + percent *float64, + reset *string, +) (quotaWindow, error) { + result := quotaWindow{ + ID: id, Label: label, Scope: scope, Unit: "percent", State: "unknown", IsPrimary: primary, + } + if windowSeconds > 0 { + result.WindowSeconds = floatlessIntPointer(windowSeconds) + } + if percent != nil { + if math.IsNaN(*percent) || math.IsInf(*percent, 0) || *percent < 0 || *percent > 100 { + return quotaWindow{}, fmt.Errorf("Claude %s utilization is invalid", id) + } + used, limit, remaining, utilization := *percent, 100.0, math.Max(0, 100-*percent), *percent/100 + result.Used, result.Limit, result.Remaining, result.Utilization = &used, &limit, &remaining, &utilization + result.State = "available" + if *percent >= 100 { + result.State = "exhausted" + } + } + resetAtMS, err := claudeOptionalResetMilliseconds(id, reset) + if err != nil { + return quotaWindow{}, err + } + result.ResetAtMS = resetAtMS + return result, nil +} + +func normalizeClaudeExtraUsage(extra *claude.ExtraUsage) (quotaWindow, error) { + result := quotaWindow{ID: "extra_usage", Label: "Extra usage", Scope: "extra_usage", Unit: "credits", State: "unknown"} + if extra.Currency != nil && strings.TrimSpace(*extra.Currency) != "" { + result.Unit = strings.ToUpper(strings.TrimSpace(*extra.Currency)) + } + if extra.MonthlyLimit != nil { + limit := *extra.MonthlyLimit + if math.IsNaN(limit) || math.IsInf(limit, 0) || limit < 0 { + return quotaWindow{}, fmt.Errorf("Claude extra usage limit is invalid") + } + result.Limit = &limit + } + if extra.UsedCredits != nil { + used := *extra.UsedCredits + if math.IsNaN(used) || math.IsInf(used, 0) || used < 0 { + return quotaWindow{}, fmt.Errorf("Claude extra usage used credits are invalid") + } + result.Used = &used + } + if result.Limit != nil && result.Used != nil { + remaining := math.Max(0, *result.Limit-*result.Used) + result.Remaining = &remaining + } + if extra.Utilization != nil { + if math.IsNaN(*extra.Utilization) || math.IsInf(*extra.Utilization, 0) || *extra.Utilization < 0 || *extra.Utilization > 100 { + return quotaWindow{}, fmt.Errorf("Claude extra usage utilization is invalid") + } + utilization := *extra.Utilization / 100 + result.Utilization = &utilization + } else if result.Limit != nil && *result.Limit > 0 && result.Used != nil { + utilization := math.Min(1, *result.Used / *result.Limit) + result.Utilization = &utilization + } + if extra.Enabled && (result.Limit != nil || result.Utilization != nil) { + result.State = "available" + if result.Remaining != nil && *result.Remaining <= 0 || result.Utilization != nil && *result.Utilization >= 1 { + result.State = "exhausted" + } + } + return result, nil +} + +func normalizeClaudeScopedLimit(limit claude.ScopedLimit) (quotaWindow, error) { + kind, group := safeID(limit.Kind), safeID(limit.Group) + if kind == "" || group == "" { + return quotaWindow{}, fmt.Errorf("Claude scoped limit identifier is invalid") + } + id := kind + "_" + group + label := strings.TrimSpace(limit.ModelDisplayName) + scope := "model" + if label == "" { + label = strings.TrimSpace(limit.SurfaceDisplayName) + scope = "surface" + } + if label == "" { + label = strings.TrimSpace(limit.Group) + } + if label == "" { + label = id + } + window, err := normalizeClaudePercentageWindow(id, label, scope, 0, false, &limit.Percent, limit.ResetsAt) + if err != nil { + return quotaWindow{}, err + } + return window, nil +} + +func claudeOptionalResetMilliseconds(field string, value *string) (*int64, error) { + if value == nil || strings.TrimSpace(*value) == "" { + return nil, nil + } + parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(*value)) + if err != nil { + return nil, fmt.Errorf("Claude %s reset time is invalid", field) + } + result := parsed.UnixMilli() + return &result, nil +} + +func claudeTimeMilliseconds(field, value string) (*int64, error) { + value = strings.TrimSpace(value) + if value == "" { + return nil, nil + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return nil, fmt.Errorf("Claude %s time is invalid", field) + } + result := parsed.UnixMilli() + return &result, nil +} + +func cloneBoolPointer(value *bool) *bool { + if value == nil { + return nil + } + result := *value + return &result +} + +func boolPointerValue(value bool) *bool { return &value } + +func floatlessIntPointer(value int64) *int64 { return &value } diff --git a/internal/subscription/runtime/claude_quota_test.go b/internal/subscription/runtime/claude_quota_test.go new file mode 100644 index 00000000..289e1a8b --- /dev/null +++ b/internal/subscription/runtime/claude_quota_test.go @@ -0,0 +1,141 @@ +package subscriptionruntime + +import ( + "encoding/json" + "testing" + + "gpt-load/internal/claude" +) + +func floatPointer(value float64) *float64 { return &value } +func stringPointer(value string) *string { return &value } +func boolPointer(value bool) *bool { return &value } + +func TestNormalizeClaudeObservationIncludesAccountAndQuotaWindows(t *testing.T) { + raw, err := NormalizeClaudeObservation(claude.AccountObservation{ + Profile: claude.AccountProfile{ + DisplayName: "Owner", Email: "owner@example.com", OrganizationName: "Example Org", + OrganizationType: "claude_team", OrganizationRole: "admin", WorkspaceRole: "member", + OrganizationRateLimitTier: "org-tier", UserRateLimitTier: "user-tier", + SeatTier: "team_standard", BillingType: "stripe_subscription", + AccountCreatedAt: "2025-01-02T03:04:05Z", SubscriptionCreatedAt: "2025-02-03T04:05:06Z", + ExtraUsageEnabled: boolPointer(true), + }, + Usage: claude.Usage{ + FiveHour: &claude.UsageWindow{ + Utilization: floatPointer(25), ResetsAt: stringPointer("2026-08-16T10:00:00Z"), + }, + SevenDay: &claude.UsageWindow{ + Utilization: floatPointer(40), ResetsAt: stringPointer("2026-08-23T08:00:00Z"), + }, + ExtraUsage: &claude.ExtraUsage{ + Enabled: true, MonthlyLimit: floatPointer(100), UsedCredits: floatPointer(12.5), + Utilization: floatPointer(12.5), Currency: stringPointer("USD"), + }, + Limits: []claude.ScopedLimit{{ + Kind: "weekly", Group: "opus", Percent: 80, + ResetsAt: stringPointer("2026-08-23T08:00:00Z"), ModelDisplayName: "Claude Opus", + }}, + }, + }) + if err != nil { + t.Fatal(err) + } + var snapshot struct { + Account struct { + DisplayName string `json:"display_name"` + OrganizationName string `json:"organization_name"` + SeatTier string `json:"seat_tier"` + } `json:"account_summary"` + Plan struct { + Name string `json:"name"` + } `json:"plan_summary"` + Windows []quotaWindow `json:"quota_windows"` + } + if err := json.Unmarshal(raw, &snapshot); err != nil { + t.Fatal(err) + } + if snapshot.Account.DisplayName != "Owner" || snapshot.Account.OrganizationName != "Example Org" || + snapshot.Account.SeatTier != "team_standard" || snapshot.Plan.Name != "Claude Team" || + len(snapshot.Windows) != 4 { + t.Fatalf("snapshot = %s", raw) + } + byID := make(map[string]quotaWindow, len(snapshot.Windows)) + for _, window := range snapshot.Windows { + byID[window.ID] = window + } + if byID["five_hour"].Utilization == nil || *byID["five_hour"].Utilization != 0.25 || + byID["five_hour"].ResetAtMS == nil || !byID["five_hour"].IsPrimary || + byID["extra_usage"].Remaining == nil || *byID["extra_usage"].Remaining != 87.5 || + byID["weekly_opus"].Utilization == nil || *byID["weekly_opus"].Utilization != 0.8 { + t.Fatalf("quota windows = %#v", byID) + } +} + +func TestNormalizeClaudeObservationRejectsMalformedResetTime(t *testing.T) { + _, err := NormalizeClaudeObservation(claude.AccountObservation{ + Usage: claude.Usage{FiveHour: &claude.UsageWindow{ + Utilization: floatPointer(25), ResetsAt: stringPointer("not-a-time"), + }}, + }) + if err == nil { + t.Fatal("NormalizeClaudeObservation() accepted malformed reset time") + } +} + +func TestNormalizeClaudeObservationUsesUsageExtraStatus(t *testing.T) { + enabled := true + reason := "org_level_disabled" + raw, err := NormalizeClaudeObservation(claude.AccountObservation{ + Profile: claude.AccountProfile{ExtraUsageEnabled: &enabled}, + Usage: claude.Usage{ExtraUsage: &claude.ExtraUsage{ + Enabled: false, DisabledReason: &reason, + }}, + }) + if err != nil { + t.Fatal(err) + } + var snapshot struct { + Account struct { + ExtraUsageEnabled *bool `json:"extra_usage_enabled"` + ExtraUsageDisabledReason string `json:"extra_usage_disabled_reason"` + } `json:"account_summary"` + } + if err := json.Unmarshal(raw, &snapshot); err != nil { + t.Fatal(err) + } + if snapshot.Account.ExtraUsageEnabled == nil || *snapshot.Account.ExtraUsageEnabled || + snapshot.Account.ExtraUsageDisabledReason != reason { + t.Fatalf("account summary = %s", raw) + } +} + +func TestNormalizeClaudeObservationKeepsQuotaWindowIDsUnique(t *testing.T) { + raw, err := NormalizeClaudeObservation(claude.AccountObservation{Usage: claude.Usage{ + FiveHour: &claude.UsageWindow{Utilization: floatPointer(10)}, + Limits: []claude.ScopedLimit{ + {Kind: "five", Group: "hour", Percent: 20}, + {Kind: "weekly", Group: "opus", Percent: 30, ModelDisplayName: "Opus A"}, + {Kind: "weekly", Group: "opus", Percent: 40, ModelDisplayName: "Opus B"}, + }, + }}) + if err != nil { + t.Fatal(err) + } + var snapshot struct { + Windows []quotaWindow `json:"quota_windows"` + } + if err := json.Unmarshal(raw, &snapshot); err != nil { + t.Fatal(err) + } + seen := make(map[string]struct{}, len(snapshot.Windows)) + for _, window := range snapshot.Windows { + if _, duplicate := seen[window.ID]; duplicate { + t.Fatalf("duplicate quota window ID %q: %s", window.ID, raw) + } + seen[window.ID] = struct{}{} + } + if len(seen) != 4 { + t.Fatalf("quota windows = %s", raw) + } +} diff --git a/internal/subscription/runtime/codex_quota.go b/internal/subscription/runtime/codex_quota.go index 74ef3bd5..74b70500 100644 --- a/internal/subscription/runtime/codex_quota.go +++ b/internal/subscription/runtime/codex_quota.go @@ -15,6 +15,23 @@ type quotaPlanSummary struct { Name string `json:"name,omitempty"` } +type quotaAccountSummary struct { + DisplayName string `json:"display_name,omitempty"` + Email string `json:"email,omitempty"` + OrganizationName string `json:"organization_name,omitempty"` + OrganizationType string `json:"organization_type,omitempty"` + OrganizationRole string `json:"organization_role,omitempty"` + WorkspaceRole string `json:"workspace_role,omitempty"` + OrganizationRateLimitTier string `json:"organization_rate_limit_tier,omitempty"` + UserRateLimitTier string `json:"user_rate_limit_tier,omitempty"` + SeatTier string `json:"seat_tier,omitempty"` + BillingType string `json:"billing_type,omitempty"` + ExtraUsageEnabled *bool `json:"extra_usage_enabled,omitempty"` + ExtraUsageDisabledReason string `json:"extra_usage_disabled_reason,omitempty"` + AccountCreatedAtMS *int64 `json:"account_created_at_ms,omitempty"` + SubscriptionCreatedAtMS *int64 `json:"subscription_created_at_ms,omitempty"` +} + type quotaWindow struct { ID string `json:"id"` Label string `json:"label"` @@ -36,10 +53,11 @@ type resetCredit struct { } type quotaSnapshot struct { - Plan quotaPlanSummary `json:"plan_summary"` - QuotaWindows []quotaWindow `json:"quota_windows"` - ResetCreditsAvailable *int64 `json:"reset_credits_available,omitempty"` - ResetCredits []resetCredit `json:"reset_credits,omitempty"` + Plan quotaPlanSummary `json:"plan_summary"` + Account *quotaAccountSummary `json:"account_summary,omitempty"` + QuotaWindows []quotaWindow `json:"quota_windows"` + ResetCreditsAvailable *int64 `json:"reset_credits_available,omitempty"` + ResetCredits []resetCredit `json:"reset_credits,omitempty"` } // NormalizeCodexQuota converts Codex provider payloads into the canonical, diff --git a/internal/subscription/runtime/runtime_test.go b/internal/subscription/runtime/runtime_test.go index 0e3faf03..c4c8ca4d 100644 --- a/internal/subscription/runtime/runtime_test.go +++ b/internal/subscription/runtime/runtime_test.go @@ -29,11 +29,11 @@ func TestRuntimeCompilesSubscriptionCapabilitiesFromChannelBindings(t *testing.T if !ok || claudeDriver.ID() != modules.ClaudeSubscriptionDriver { t.Fatalf("Driver(claude) = %#v, %t", claudeDriver, ok) } - if _, ok := runtime.ModelDiscovery(channel.Claude); ok { - t.Fatal("ModelDiscovery(claude) unexpectedly resolved") + if discovery, ok := runtime.ModelDiscovery(channel.Claude); !ok || discovery.ID() != modules.ClaudeModelDiscovery { + t.Fatalf("ModelDiscovery(claude) = %#v, %t", discovery, ok) } - if _, ok := runtime.QuotaObservation(channel.Claude); ok { - t.Fatal("QuotaObservation(claude) unexpectedly resolved") + if observation, ok := runtime.QuotaObservation(channel.Claude); !ok || observation.ID() != modules.ClaudeQuotaObservation { + t.Fatalf("QuotaObservation(claude) = %#v, %t", observation, ok) } if _, ok := runtime.ResetCreditAction(channel.Claude); ok { t.Fatal("ResetCreditAction(claude) unexpectedly resolved") diff --git a/third_party/cpaembedded/README.md b/third_party/cpaembedded/README.md index e3e050ff..08246091 100644 --- a/third_party/cpaembedded/README.md +++ b/third_party/cpaembedded/README.md @@ -18,6 +18,7 @@ quota policy. This bridge only exposes: - Claude browser OAuth challenge creation and one-shot code exchange; - strict CPA Claude JSON parsing and stable device identity normalization; - one-shot, context-aware Claude token refresh; +- Claude Code account profile, model entitlement, and usage observation; - the stateless Claude HTTP executor and supported protocol translators. It intentionally excludes CPA Manager, selector, pool, file store, server, @@ -26,7 +27,7 @@ watcher, WebSocket/Auto executors, fallback, and internal retry loops. ## Pinned upstream - Module: `github.com/router-for-me/CLIProxyAPI/v7` -- Version: `v7.2.130` +- Version: `v7.2.133` The root module consumes this bridge through a local `replace`; releases still resolve CPA itself at the exact version recorded in both `go.mod` files and @@ -62,11 +63,11 @@ The file contents are never logged. Do not use a credential whose refresh token is concurrently managed by another service when explicitly testing refresh; the live contract test intentionally does not refresh it. -The Claude contract also requires an explicitly selected model because account -entitlements are not inferred from an API model catalog: +The Claude contract discovers account entitlements before inference. A model +override is optional: ```bash CPA_LIVE_CLAUDE_CREDENTIAL_FILE=/absolute/path/to/claude.json \ -CPA_LIVE_CLAUDE_MODEL=claude-model-id \ +CPA_LIVE_CLAUDE_MODEL=optional-claude-model-id \ go test -count=1 -run '^TestLiveClaudeContract$' ./embedded ``` diff --git a/third_party/cpaembedded/embedded/claude.go b/third_party/cpaembedded/embedded/claude.go index 6f938626..8ae1ef35 100644 --- a/third_party/cpaembedded/embedded/claude.go +++ b/third_party/cpaembedded/embedded/claude.go @@ -19,6 +19,8 @@ import ( const ( ProviderClaude = "claude" ClaudeRedirectURI = claudeauth.RedirectURI + ClaudeBootstrapURL = "https://api.anthropic.com/api/claude_cli/bootstrap" + ClaudeUsageURL = "https://api.anthropic.com/api/oauth/usage" claudeLoginTimeout = 5 * time.Minute ) @@ -46,11 +48,13 @@ type ClaudeCredential struct { // ClaudeOptions supplies testable OAuth transport boundaries without changing // the production OAuth identity or redirect URI. type ClaudeOptions struct { - TokenURL string - ProfileURL string - RolesURL string - HTTPClient *http.Client - Now func() time.Time + TokenURL string + ProfileURL string + RolesURL string + BootstrapURL string + UsageURL string + HTTPClient *http.Client + Now func() time.Time } func ParseClaudeCredentialJSON(raw []byte) (ClaudeCredential, error) { diff --git a/third_party/cpaembedded/embedded/claude_account.go b/third_party/cpaembedded/embedded/claude_account.go new file mode 100644 index 00000000..1b1717ae --- /dev/null +++ b/third_party/cpaembedded/embedded/claude_account.go @@ -0,0 +1,699 @@ +package embedded + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "net/url" + "sort" + "strings" + + claudeauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/claude" + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" +) + +const ( + claudeBootstrapBeta = "oauth-2025-04-20" + claudeBootstrapUserAgent = "claude-code/2.1.220" + maxClaudeModels = 256 + maxClaudeScopedLimits = 256 + maxClaudeSafeScalarBytes = 512 +) + +// ClaudeModel is the safe model metadata returned by Claude account discovery. +type ClaudeModel struct { + ID string + DisplayName string + Description string +} + +// ClaudeAccountProfile contains only non-secret account presentation fields. +type ClaudeAccountProfile struct { + DisplayName string + Email string + AccountUUID string + AccountCreatedAt string + OrganizationUUID string + OrganizationName string + OrganizationType string + OrganizationRole string + WorkspaceRole string + OrganizationRateLimitTier string + UserRateLimitTier string + SeatTier string + BillingType string + SubscriptionCreatedAt string + ExtraUsageEnabled *bool +} + +// ClaudeUsageWindow is one optional subscription utilization window. +type ClaudeUsageWindow struct { + Utilization *float64 + ResetsAt *string +} + +// ClaudeExtraUsage describes optional paid usage credits. +type ClaudeExtraUsage struct { + Enabled bool + MonthlyLimit *float64 + UsedCredits *float64 + Utilization *float64 + Currency *string + DisabledReason *string +} + +// ClaudeScopedLimit is one account, model, or surface-specific quota window. +type ClaudeScopedLimit struct { + Kind string + Group string + Percent float64 + ResetsAt *string + ModelDisplayName string + SurfaceDisplayName string +} + +// ClaudeUsage is the typed Claude OAuth usage response. +type ClaudeUsage struct { + FiveHour *ClaudeUsageWindow + SevenDay *ClaudeUsageWindow + SevenDayOAuthApps *ClaudeUsageWindow + SevenDayOpus *ClaudeUsageWindow + SevenDaySonnet *ClaudeUsageWindow + CinderCove *ClaudeUsageWindow + ExtraUsage *ClaudeExtraUsage + Limits []ClaudeScopedLimit +} + +// ClaudeAccountObservation combines account metadata and quota evidence. +type ClaudeAccountObservation struct { + Profile ClaudeAccountProfile + Usage ClaudeUsage + Header http.Header +} + +type claudeBootstrapResponse struct { + AdditionalModelOptions []struct { + Model string `json:"model"` + Name string `json:"name"` + Description string `json:"description"` + DisabledReason *string `json:"disabled_reason"` + } `json:"additional_model_options"` + ModelAccess []struct { + APIName string `json:"api_name"` + Entitled bool `json:"entitled"` + } `json:"model_access"` + OAuthAccount *struct { + AccountUUID string `json:"account_uuid"` + AccountEmail string `json:"account_email"` + OrganizationUUID string `json:"organization_uuid"` + OrganizationName string `json:"organization_name"` + OrganizationType string `json:"organization_type"` + OrganizationRateLimitTier string `json:"organization_rate_limit_tier"` + UserRateLimitTier string `json:"user_rate_limit_tier"` + SeatTier string `json:"seat_tier"` + } `json:"oauth_account"` +} + +type claudeOAuthRoles struct { + OrganizationRole string `json:"organization_role"` + WorkspaceRole string `json:"workspace_role"` + OrganizationName string `json:"organization_name"` +} + +type claudeUsageWindowPayload struct { + Utilization *float64 `json:"utilization"` + ResetsAt *string `json:"resets_at"` +} + +type claudeUsagePayload struct { + FiveHour *claudeUsageWindowPayload `json:"five_hour"` + SevenDay *claudeUsageWindowPayload `json:"seven_day"` + SevenDayOAuthApps *claudeUsageWindowPayload `json:"seven_day_oauth_apps"` + 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 { + Kind string `json:"kind"` + Group string `json:"group"` + Percent float64 `json:"percent"` + ResetsAt *string `json:"resets_at"` + Scope *struct { + Model *struct { + DisplayName string `json:"display_name"` + } `json:"model"` + Surface *struct { + DisplayName string `json:"display_name"` + } `json:"surface"` + } `json:"scope"` + } `json:"limits"` +} + +// DiscoverClaudeModels merges CPA's version-pinned Claude catalog with the +// account-specific bootstrap entitlement overlay. +func DiscoverClaudeModels( + ctx context.Context, + credential ClaudeCredential, + options ClaudeOptions, +) ([]ClaudeModel, error) { + credential, err := canonicalClaudeCredential(credential) + if err != nil { + return nil, err + } + bootstrap, err := fetchClaudeBootstrap(ctx, credential, options) + if err != nil { + return nil, err + } + if err := validateClaudeBootstrapIdentity(credential, bootstrap); err != nil { + return nil, err + } + if len(bootstrap.AdditionalModelOptions) > maxClaudeModels || + len(bootstrap.ModelAccess) > maxClaudeModels { + return nil, fmt.Errorf("Claude bootstrap model list is too large") + } + + models := make(map[string]ClaudeModel) + for _, model := range registry.GetClaudeModels() { + if model == nil { + continue + } + id, idErr := claudeSafeScalar("model id", model.ID, true) + if idErr != nil { + continue + } + displayName, _ := claudeSafeScalar("model display name", model.DisplayName, false) + description, _ := claudeSafeScalar("model description", model.Description, false) + models[id] = ClaudeModel{ID: id, DisplayName: displayName, Description: description} + } + for _, option := range bootstrap.AdditionalModelOptions { + id, idErr := claudeSafeScalar("additional model id", option.Model, true) + if idErr != nil { + return nil, idErr + } + if option.DisabledReason != nil && strings.TrimSpace(*option.DisabledReason) != "" { + delete(models, id) + continue + } + name, nameErr := claudeSafeScalar("additional model name", option.Name, false) + if nameErr != nil { + return nil, nameErr + } + description, descriptionErr := claudeSafeScalar("additional model description", option.Description, false) + if descriptionErr != nil { + return nil, descriptionErr + } + models[id] = ClaudeModel{ID: id, DisplayName: name, Description: description} + } + for _, access := range bootstrap.ModelAccess { + id, idErr := claudeSafeScalar("model access id", access.APIName, true) + if idErr != nil { + return nil, idErr + } + if !access.Entitled { + delete(models, id) + continue + } + if _, exists := models[id]; !exists { + models[id] = ClaudeModel{ID: id, DisplayName: id} + } + } + + ids := make([]string, 0, len(models)) + for id := range models { + ids = append(ids, id) + } + sort.Strings(ids) + result := make([]ClaudeModel, 0, len(ids)) + for _, id := range ids { + result = append(result, models[id]) + } + return result, nil +} + +// ObserveClaudeAccount fetches the non-mutating Claude Code account endpoints. +func ObserveClaudeAccount( + ctx context.Context, + credential ClaudeCredential, + options ClaudeOptions, +) (ClaudeAccountObservation, error) { + credential, err := canonicalClaudeCredential(credential) + if err != nil { + return ClaudeAccountObservation{}, err + } + profile := ClaudeAccountProfile{ + Email: credential.Email, AccountUUID: credential.AccountUUID, + OrganizationUUID: credential.OrganizationUUID, OrganizationName: credential.OrganizationName, + } + if profilePayload, profileErr := fetchClaudeOAuthProfile(ctx, options, credential.AccessToken); profileErr == nil { + if err := validateClaudeProfileIdentity(credential, profilePayload); err != nil { + return ClaudeAccountObservation{}, err + } + observedProfile, mapErr := mapClaudeAccountProfile(profilePayload) + if mapErr != nil { + return ClaudeAccountObservation{}, mapErr + } + profile = mergeClaudeAccountProfile(profile, observedProfile) + } + + if roles, rolesErr := fetchAndDecodeClaudeRoles(ctx, credential, options); rolesErr == nil { + if value, valueErr := claudeSafeScalar("organization role", roles.OrganizationRole, false); valueErr == nil { + profile.OrganizationRole = value + } + if value, valueErr := claudeSafeScalar("workspace role", roles.WorkspaceRole, false); valueErr == nil { + profile.WorkspaceRole = value + } + if value, valueErr := claudeSafeScalar("organization name", roles.OrganizationName, false); valueErr == nil && value != "" { + profile.OrganizationName = value + } + } + if bootstrap, bootstrapErr := fetchClaudeBootstrap(ctx, credential, options); bootstrapErr == nil { + if err := validateClaudeBootstrapIdentity(credential, bootstrap); err != nil { + return ClaudeAccountObservation{}, err + } + if bootstrap.OAuthAccount != nil { + applyClaudeBootstrapAccount(&profile, bootstrap.OAuthAccount) + } + } + + usage, header, err := fetchAndDecodeClaudeUsage(ctx, credential, options) + if err != nil { + return ClaudeAccountObservation{}, fmt.Errorf("fetch Claude account usage: %w", err) + } + return ClaudeAccountObservation{Profile: profile, Usage: usage, Header: header}, nil +} + +func mergeClaudeAccountProfile(base, observed ClaudeAccountProfile) ClaudeAccountProfile { + for target, value := range map[*string]string{ + &base.DisplayName: observed.DisplayName, &base.Email: observed.Email, + &base.AccountUUID: observed.AccountUUID, &base.AccountCreatedAt: observed.AccountCreatedAt, + &base.OrganizationUUID: observed.OrganizationUUID, &base.OrganizationName: observed.OrganizationName, + &base.OrganizationType: observed.OrganizationType, &base.OrganizationRole: observed.OrganizationRole, + &base.WorkspaceRole: observed.WorkspaceRole, + &base.OrganizationRateLimitTier: observed.OrganizationRateLimitTier, + &base.UserRateLimitTier: observed.UserRateLimitTier, &base.SeatTier: observed.SeatTier, + &base.BillingType: observed.BillingType, &base.SubscriptionCreatedAt: observed.SubscriptionCreatedAt, + } { + if value != "" { + *target = value + } + } + if observed.ExtraUsageEnabled != nil { + value := *observed.ExtraUsageEnabled + base.ExtraUsageEnabled = &value + } + return base +} + +func canonicalClaudeCredential(credential ClaudeCredential) (ClaudeCredential, error) { + raw, err := json.Marshal(credential) + if err != nil { + return ClaudeCredential{}, err + } + defer clear(raw) + return ParseClaudeCredentialJSON(raw) +} + +func fetchClaudeBootstrap( + ctx context.Context, + credential ClaudeCredential, + options ClaudeOptions, +) (claudeBootstrapResponse, error) { + endpoint := strings.TrimSpace(options.BootstrapURL) + if endpoint == "" { + endpoint = ClaudeBootstrapURL + } + body, _, err := fetchClaudeOAuthJSONRequest( + ctx, + options, + endpoint, + credential.AccessToken, + func(request *http.Request) { + request.Header.Set("Anthropic-Beta", claudeBootstrapBeta) + request.Header.Set("User-Agent", claudeBootstrapUserAgent) + query := request.URL.Query() + query.Set("entrypoint", "cli") + request.URL.RawQuery = query.Encode() + }, + ) + if err != nil { + return claudeBootstrapResponse{}, fmt.Errorf("fetch Claude bootstrap: %w", err) + } + defer clear(body) + var bootstrap claudeBootstrapResponse + if err := decodeClaudeJSONObject(body, &bootstrap); err != nil { + return claudeBootstrapResponse{}, fmt.Errorf("decode Claude bootstrap: %w", err) + } + return bootstrap, nil +} + +func fetchAndDecodeClaudeRoles( + ctx context.Context, + credential ClaudeCredential, + options ClaudeOptions, +) (claudeOAuthRoles, error) { + endpoint := strings.TrimSpace(options.RolesURL) + if endpoint == "" { + endpoint = claudeauth.RolesURL + } + body, _, err := fetchClaudeOAuthJSONRequest(ctx, options, endpoint, credential.AccessToken, nil) + if err != nil { + return claudeOAuthRoles{}, err + } + defer clear(body) + var roles claudeOAuthRoles + trimmed := bytes.TrimSpace(body) + if len(trimmed) > 0 && trimmed[0] == '[' { + var values []claudeOAuthRoles + if err := json.Unmarshal(trimmed, &values); err != nil || len(values) > 16 { + return claudeOAuthRoles{}, fmt.Errorf("invalid Claude roles response") + } + if len(values) > 0 { + roles = values[0] + } + return roles, nil + } + if err := decodeClaudeJSONObject(trimmed, &roles); err != nil { + return claudeOAuthRoles{}, err + } + return roles, nil +} + +func fetchAndDecodeClaudeUsage( + ctx context.Context, + credential ClaudeCredential, + options ClaudeOptions, +) (ClaudeUsage, http.Header, error) { + endpoint := strings.TrimSpace(options.UsageURL) + if endpoint == "" { + endpoint = ClaudeUsageURL + } + body, header, err := fetchClaudeOAuthJSONRequest(ctx, options, endpoint, credential.AccessToken, nil) + if err != nil { + return ClaudeUsage{}, nil, err + } + defer clear(body) + var payload claudeUsagePayload + if err := decodeClaudeJSONObject(body, &payload); err != nil { + return ClaudeUsage{}, nil, err + } + usage, err := mapClaudeUsage(payload) + if err != nil { + return ClaudeUsage{}, nil, err + } + return usage, header, nil +} + +func mapClaudeAccountProfile(payload claudeOAuthProfile) (ClaudeAccountProfile, error) { + values := map[string]string{ + "display name": payload.Account.DisplayName, + "email": payload.Account.Email, + "account UUID": payload.Account.UUID, + "account created at": payload.Account.CreatedAt, + "organization UUID": payload.Organization.UUID, + "organization name": payload.Organization.Name, + "organization type": payload.Organization.Type, + "rate limit tier": payload.Organization.RateLimitTier, + "seat tier": payload.Organization.SeatTier, + "billing type": payload.Organization.BillingType, + "subscription created at": payload.Organization.SubscriptionCreatedAt, + } + cleaned := make(map[string]string, len(values)) + for field, value := range values { + candidate, err := claudeSafeScalar(field, value, field == "account UUID") + if err != nil { + return ClaudeAccountProfile{}, err + } + cleaned[field] = candidate + } + return ClaudeAccountProfile{ + DisplayName: cleaned["display name"], Email: cleaned["email"], + AccountUUID: cleaned["account UUID"], AccountCreatedAt: cleaned["account created at"], + OrganizationUUID: cleaned["organization UUID"], OrganizationName: cleaned["organization name"], + OrganizationType: cleaned["organization type"], + OrganizationRateLimitTier: cleaned["rate limit tier"], SeatTier: cleaned["seat tier"], + BillingType: cleaned["billing type"], SubscriptionCreatedAt: cleaned["subscription created at"], + ExtraUsageEnabled: payload.Organization.ExtraUsageEnabled, + }, nil +} + +func applyClaudeBootstrapAccount( + profile *ClaudeAccountProfile, + account *struct { + AccountUUID string `json:"account_uuid"` + AccountEmail string `json:"account_email"` + OrganizationUUID string `json:"organization_uuid"` + OrganizationName string `json:"organization_name"` + OrganizationType string `json:"organization_type"` + OrganizationRateLimitTier string `json:"organization_rate_limit_tier"` + UserRateLimitTier string `json:"user_rate_limit_tier"` + SeatTier string `json:"seat_tier"` + }, +) { + if profile == nil || account == nil { + return + } + for target, value := range map[*string]string{ + &profile.Email: account.AccountEmail, + &profile.OrganizationName: account.OrganizationName, + &profile.OrganizationType: account.OrganizationType, + &profile.OrganizationRateLimitTier: account.OrganizationRateLimitTier, + &profile.UserRateLimitTier: account.UserRateLimitTier, + &profile.SeatTier: account.SeatTier, + } { + if cleaned, err := claudeSafeScalar("bootstrap account field", value, false); err == nil && cleaned != "" { + *target = cleaned + } + } +} + +func validateClaudeProfileIdentity(credential ClaudeCredential, profile claudeOAuthProfile) error { + if strings.TrimSpace(profile.Account.UUID) != credential.AccountUUID { + return ErrClaudeCredentialIdentityChanged + } + if credential.OrganizationUUID != "" && strings.TrimSpace(profile.Organization.UUID) != "" && + strings.TrimSpace(profile.Organization.UUID) != credential.OrganizationUUID { + return ErrClaudeOrganizationIdentityChanged + } + return nil +} + +func validateClaudeBootstrapIdentity(credential ClaudeCredential, bootstrap claudeBootstrapResponse) error { + if bootstrap.OAuthAccount == nil { + return nil + } + if value := strings.TrimSpace(bootstrap.OAuthAccount.AccountUUID); value != "" && value != credential.AccountUUID { + return ErrClaudeCredentialIdentityChanged + } + if value := strings.TrimSpace(bootstrap.OAuthAccount.OrganizationUUID); credential.OrganizationUUID != "" && + value != "" && value != credential.OrganizationUUID { + return ErrClaudeOrganizationIdentityChanged + } + return nil +} + +func mapClaudeUsage(payload claudeUsagePayload) (ClaudeUsage, error) { + result := ClaudeUsage{} + var err error + if result.FiveHour, err = mapClaudeUsageWindow("five_hour", payload.FiveHour); err != nil { + return ClaudeUsage{}, err + } + if result.SevenDay, err = mapClaudeUsageWindow("seven_day", payload.SevenDay); err != nil { + return ClaudeUsage{}, err + } + if result.SevenDayOAuthApps, err = mapClaudeUsageWindow("seven_day_oauth_apps", payload.SevenDayOAuthApps); err != nil { + return ClaudeUsage{}, err + } + if result.SevenDayOpus, err = mapClaudeUsageWindow("seven_day_opus", payload.SevenDayOpus); err != nil { + return ClaudeUsage{}, err + } + if result.SevenDaySonnet, err = mapClaudeUsageWindow("seven_day_sonnet", payload.SevenDaySonnet); err != nil { + return ClaudeUsage{}, err + } + if result.CinderCove, err = mapClaudeUsageWindow("cinder_cove", payload.CinderCove); err != nil { + return ClaudeUsage{}, err + } + if payload.ExtraUsage != nil { + for field, value := range map[string]*float64{ + "monthly_limit": payload.ExtraUsage.MonthlyLimit, + "used_credits": payload.ExtraUsage.UsedCredits, + "utilization": payload.ExtraUsage.Utilization, + } { + if value != nil && (math.IsNaN(*value) || math.IsInf(*value, 0) || *value < 0) { + return ClaudeUsage{}, fmt.Errorf("Claude extra usage %s is invalid", field) + } + } + currency, err := claudeOptionalSafeScalar("currency", payload.ExtraUsage.Currency) + if err != nil { + return ClaudeUsage{}, err + } + reason, err := claudeOptionalSafeScalar("disabled reason", payload.ExtraUsage.DisabledReason) + if err != nil { + return ClaudeUsage{}, err + } + result.ExtraUsage = &ClaudeExtraUsage{ + Enabled: payload.ExtraUsage.Enabled, + MonthlyLimit: cloneFloat64(payload.ExtraUsage.MonthlyLimit), + UsedCredits: cloneFloat64(payload.ExtraUsage.UsedCredits), + Utilization: cloneFloat64(payload.ExtraUsage.Utilization), + Currency: currency, DisabledReason: reason, + } + } + if len(payload.Limits) > maxClaudeScopedLimits { + return ClaudeUsage{}, fmt.Errorf("Claude scoped limit list is too large") + } + result.Limits = make([]ClaudeScopedLimit, 0, len(payload.Limits)) + for _, limit := range payload.Limits { + if math.IsNaN(limit.Percent) || math.IsInf(limit.Percent, 0) || + limit.Percent < 0 || limit.Percent > 100 { + return ClaudeUsage{}, fmt.Errorf("Claude scoped limit percent is invalid") + } + kind, err := claudeSafeScalar("limit kind", limit.Kind, true) + if err != nil { + return ClaudeUsage{}, err + } + group, err := claudeSafeScalar("limit group", limit.Group, true) + if err != nil { + return ClaudeUsage{}, err + } + reset, err := claudeOptionalSafeScalar("limit reset", limit.ResetsAt) + if err != nil { + return ClaudeUsage{}, err + } + item := ClaudeScopedLimit{Kind: kind, Group: group, Percent: limit.Percent, ResetsAt: reset} + if limit.Scope != nil { + if limit.Scope.Model != nil { + item.ModelDisplayName, err = claudeSafeScalar( + "limit model display name", limit.Scope.Model.DisplayName, false, + ) + if err != nil { + return ClaudeUsage{}, err + } + } + if limit.Scope.Surface != nil { + item.SurfaceDisplayName, err = claudeSafeScalar( + "limit surface display name", limit.Scope.Surface.DisplayName, false, + ) + if err != nil { + return ClaudeUsage{}, err + } + } + } + result.Limits = append(result.Limits, item) + } + return result, nil +} + +func mapClaudeUsageWindow(name string, payload *claudeUsageWindowPayload) (*ClaudeUsageWindow, error) { + if payload == nil { + return nil, nil + } + if payload.Utilization != nil && + (math.IsNaN(*payload.Utilization) || math.IsInf(*payload.Utilization, 0) || + *payload.Utilization < 0 || *payload.Utilization > 100) { + return nil, fmt.Errorf("Claude %s utilization is invalid", name) + } + reset, err := claudeOptionalSafeScalar(name+" reset", payload.ResetsAt) + if err != nil { + return nil, err + } + return &ClaudeUsageWindow{Utilization: cloneFloat64(payload.Utilization), ResetsAt: reset}, nil +} + +func fetchClaudeOAuthJSONRequest( + ctx context.Context, + options ClaudeOptions, + endpoint string, + accessToken string, + configure func(*http.Request), +) ([]byte, http.Header, error) { + if ctx == nil { + ctx = context.Background() + } + parsed, err := url.Parse(strings.TrimSpace(endpoint)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return nil, nil, fmt.Errorf("Claude companion endpoint is invalid") + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) + if err != nil { + return nil, nil, err + } + applyClaudeOAuthHeaders(request) + request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(accessToken)) + request.Header.Set("Cache-Control", "no-cache") + if configure != nil { + configure(request) + } + response, err := claudeOAuthClient(options).Do(request) + if err != nil { + return nil, nil, err + } + defer func() { _ = response.Body.Close() }() + body, err := readClaudeOAuthBody(response) + if err != nil { + return nil, nil, err + } + 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 body, response.Header.Clone(), nil +} + +func decodeClaudeJSONObject(raw []byte, target any) error { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) < 2 || trimmed[0] != '{' { + return fmt.Errorf("response must be one JSON object") + } + decoder := json.NewDecoder(bytes.NewReader(trimmed)) + if err := decoder.Decode(target); err != nil { + return err + } + var extra any + if err := decoder.Decode(&extra); err == nil { + return fmt.Errorf("response contains multiple JSON values") + } else if err != io.EOF { + return fmt.Errorf("response has invalid trailing content") + } + return nil +} + +func claudeSafeScalar(field, value string, required bool) (string, error) { + value = strings.TrimSpace(value) + if required && value == "" { + return "", fmt.Errorf("Claude %s is required", field) + } + if len(value) > maxClaudeSafeScalarBytes || strings.ContainsAny(value, "\r\n\x00") { + return "", fmt.Errorf("Claude %s is invalid", field) + } + return value, nil +} + +func claudeOptionalSafeScalar(field string, value *string) (*string, error) { + if value == nil { + return nil, nil + } + cleaned, err := claudeSafeScalar(field, *value, false) + if err != nil { + return nil, err + } + return &cleaned, nil +} + +func cloneFloat64(value *float64) *float64 { + if value == nil { + return nil + } + cloned := *value + return &cloned +} diff --git a/third_party/cpaembedded/embedded/claude_executor.go b/third_party/cpaembedded/embedded/claude_executor.go index 1f9bf5ce..d00f25bd 100644 --- a/third_party/cpaembedded/embedded/claude_executor.go +++ b/third_party/cpaembedded/embedded/claude_executor.go @@ -29,6 +29,11 @@ type ClaudeExecutionError struct { requestScoped bool } +type claudeCredentialScopedExecutionError struct { + *ClaudeExecutionError + credentialScoped bool +} + func (e *ClaudeExecutionError) Error() string { if e == nil || e.summary == "" { return "Claude upstream request failed" @@ -69,6 +74,17 @@ func (e *ClaudeExecutionError) IsRequestScoped() bool { return e != nil && e.requestScoped } +func (e *claudeCredentialScopedExecutionError) IsCredentialScoped() bool { + return e != nil && e.credentialScoped +} + +func (e *claudeCredentialScopedExecutionError) Unwrap() error { + if e == nil { + return nil + } + return e.ClaudeExecutionError +} + // ClaudeHTTPExecutor is the stateless Claude execution surface exposed to // GPT-Load. CPA manager, retry, refresh, storage, and selection are excluded. type ClaudeHTTPExecutor interface { @@ -238,6 +254,12 @@ func normalizeClaudeExecutionError(err error) error { if errors.As(err, &scoped) && scoped != nil { requestScoped = scoped.IsRequestScoped() } + credentialScoped, credentialScopeKnown := false, false + var credentialScope interface{ IsCredentialScoped() bool } + if errors.As(err, &credentialScope) && credentialScope != nil { + credentialScopeKnown = true + credentialScoped = credentialScope.IsCredentialScoped() + } body := []byte(err.Error()) var headers http.Header var direct interface { @@ -250,11 +272,18 @@ func normalizeClaudeExecutionError(err error) error { } defer clear(body) typeValue, codeValue, providerMessage := claudeExecutionErrorFields(body) - return &ClaudeExecutionError{ + bounded := &ClaudeExecutionError{ status: status, typeValue: typeValue, codeValue: codeValue, summary: claudeExecutionSummary(status, typeValue, providerMessage), retryAfter: claudeRetryAfter(headers), requestScoped: requestScoped, } + if credentialScopeKnown { + return &claudeCredentialScopedExecutionError{ + ClaudeExecutionError: bounded, + credentialScoped: credentialScoped, + } + } + return bounded } func claudeExecutionErrorFields(body []byte) (string, string, string) { diff --git a/third_party/cpaembedded/embedded/claude_oauth.go b/third_party/cpaembedded/embedded/claude_oauth.go index ca75f356..8a505439 100644 --- a/third_party/cpaembedded/embedded/claude_oauth.go +++ b/third_party/cpaembedded/embedded/claude_oauth.go @@ -35,12 +35,20 @@ type claudeTokenResponse struct { type claudeOAuthProfile struct { Account struct { - UUID string `json:"uuid"` - Email string `json:"email"` + UUID string `json:"uuid"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + CreatedAt string `json:"created_at"` } `json:"account"` Organization struct { - UUID string `json:"uuid"` - Name string `json:"name"` + UUID string `json:"uuid"` + Name string `json:"name"` + Type string `json:"organization_type"` + RateLimitTier string `json:"rate_limit_tier"` + SeatTier string `json:"seat_tier"` + ExtraUsageEnabled *bool `json:"has_extra_usage_enabled"` + BillingType string `json:"billing_type"` + SubscriptionCreatedAt string `json:"subscription_created_at"` } `json:"organization"` } diff --git a/third_party/cpaembedded/embedded/claude_test.go b/third_party/cpaembedded/embedded/claude_test.go index 5b56d80a..1b79769c 100644 --- a/third_party/cpaembedded/embedded/claude_test.go +++ b/third_party/cpaembedded/embedded/claude_test.go @@ -21,6 +21,11 @@ func (fn claudeRoundTripperFunc) RoundTrip(request *http.Request) (*http.Respons return fn(request) } +type unscopedClaudeExecutionError struct{} + +func (unscopedClaudeExecutionError) Error() string { return "unclassified rate limit" } +func (unscopedClaudeExecutionError) StatusCode() int { return http.StatusTooManyRequests } + func testClaudeExecutionCredential() ClaudeCredential { return ClaudeCredential{ Type: ProviderClaude, AccessToken: "sk-ant-oat-access", RefreshToken: "refresh-secret", @@ -338,6 +343,60 @@ func TestClaudeHTTPExecutorSanitizesAndClassifiesFastModeEntitlement429(t *testi } } +func TestClaudeHTTPExecutorPreservesCredentialScopeForRateLimits(t *testing.T) { + for _, test := range []struct { + name string + headers http.Header + credentialScoped bool + }{ + { + name: "unified account limit", + headers: http.Header{ + "Content-Type": {"application/json"}, + "Anthropic-Ratelimit-Unified-Status": {"rejected"}, + }, + credentialScoped: true, + }, + { + name: "ordinary model limit", + headers: http.Header{ + "Content-Type": {"application/json"}, + }, + credentialScoped: false, + }, + } { + t.Run(test.name, func(t *testing.T) { + transport := claudeRoundTripperFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusTooManyRequests, + Header: test.headers.Clone(), + Body: io.NopCloser(strings.NewReader( + `{"type":"error","error":{"type":"rate_limit_error","message":"rate limited"}}`, + )), + Request: request, + }, nil + }) + ctx := context.WithValue(t.Context(), "cliproxy.roundtripper", http.RoundTripper(transport)) + _, err := NewClaudeHTTPExecutor().ExecuteCanonical(ctx, "credential-one", testClaudeExecutionCredential(), ExecuteRequest{ + Model: "claude-sonnet-4-5", Format: "claude", + Payload: []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`), + }) + var scoped interface{ IsCredentialScoped() bool } + if !errors.As(err, &scoped) || scoped == nil || scoped.IsCredentialScoped() != test.credentialScoped { + t.Fatalf("credential scope = %#v / %v; want %t", scoped, err, test.credentialScoped) + } + }) + } +} + +func TestNormalizeClaudeExecutionErrorDoesNotInventCredentialScope(t *testing.T) { + err := normalizeClaudeExecutionError(unscopedClaudeExecutionError{}) + var scoped interface{ IsCredentialScoped() bool } + if errors.As(err, &scoped) { + t.Fatalf("unclassified error gained credential scope: %#v", err) + } +} + func TestParseClaudeCredentialJSONCreatesAndPreservesOneDeviceIdentity(t *testing.T) { raw := []byte(`{ "type":"claude", @@ -603,3 +662,178 @@ func TestRefreshClaudeCredentialOnceHonorsCallerCancellation(t *testing.T) { t.Fatalf("canceled refresh = %v after %s", err, time.Since(started)) } } + +func TestDiscoverClaudeModelsMergesBootstrapEntitlementsWithRegistry(t *testing.T) { + credential := testClaudeExecutionCredential() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/bootstrap" { + http.NotFound(writer, request) + return + } + if request.Header.Get("Authorization") != "Bearer "+credential.AccessToken || + request.Header.Get("Anthropic-Beta") != "oauth-2025-04-20" || + !strings.HasPrefix(request.Header.Get("User-Agent"), "claude-code/") || + request.URL.Query().Get("entrypoint") == "" { + t.Fatalf("bootstrap request = %s headers=%v", request.URL, request.Header) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{ + "additional_model_options":[ + {"model":"claude-fable-5","name":"Claude Fable 5","description":"account preview"}, + {"model":"claude-disabled-preview","name":"Disabled","description":"","disabled_reason":"not enabled"} + ], + "model_access":[ + {"api_name":"claude-sonnet-4-5-20250929","entitled":false}, + {"api_name":"claude-account-only","entitled":true} + ] + }`)) + })) + defer server.Close() + + models, err := DiscoverClaudeModels(t.Context(), credential, ClaudeOptions{ + BootstrapURL: server.URL + "/bootstrap", + HTTPClient: server.Client(), + }) + if err != nil { + t.Fatal(err) + } + byID := make(map[string]ClaudeModel, len(models)) + for _, model := range models { + byID[model.ID] = model + } + for _, expected := range []string{"claude-opus-4-6", "claude-fable-5", "claude-account-only"} { + if _, ok := byID[expected]; !ok { + t.Fatalf("discovered models missing %q: %#v", expected, models) + } + } + for _, denied := range []string{"claude-sonnet-4-5-20250929", "claude-disabled-preview"} { + if _, ok := byID[denied]; ok { + t.Fatalf("discovered models retained denied %q: %#v", denied, models) + } + } +} + +func TestDecodeClaudeJSONObjectRejectsTrailingGarbage(t *testing.T) { + var payload map[string]any + if err := decodeClaudeJSONObject([]byte(`{} trailing`), &payload); err == nil { + t.Fatal("decodeClaudeJSONObject() accepted trailing garbage") + } +} + +func TestObserveClaudeAccountCombinesProfileRolesBootstrapAndUsage(t *testing.T) { + credential := testClaudeExecutionCredential() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.Header.Get("Authorization") != "Bearer "+credential.AccessToken { + t.Fatalf("%s authorization = %q", request.URL.Path, request.Header.Get("Authorization")) + } + writer.Header().Set("Content-Type", "application/json") + switch request.URL.Path { + case "/profile": + _, _ = writer.Write([]byte(`{ + "account":{"uuid":"account-one","email":"owner@example.com","display_name":"Owner","created_at":"2025-01-02T03:04:05Z"}, + "organization":{"uuid":"organization-one","name":"Example Org","organization_type":"claude_team","rate_limit_tier":"org-tier","seat_tier":"team_standard","has_extra_usage_enabled":true,"billing_type":"stripe_subscription","subscription_created_at":"2025-02-03T04:05:06Z"} + }`)) + case "/roles": + _, _ = writer.Write([]byte(`{"organization_role":"admin","workspace_role":"member","organization_name":"Example Org"}`)) + case "/bootstrap": + _, _ = writer.Write([]byte(`{"oauth_account":{"account_uuid":"account-one","account_email":"owner@example.com","organization_uuid":"organization-one","organization_name":"Example Org","organization_type":"claude_team","organization_rate_limit_tier":"org-bootstrap-tier","user_rate_limit_tier":"user-tier","seat_tier":"team_standard"}}`)) + case "/usage": + writer.Header().Set("Request-Id", "usage-request-one") + _, _ = writer.Write([]byte(`{ + "five_hour":{"utilization":25,"resets_at":"2026-08-16T10:00:00Z"}, + "seven_day":{"utilization":40,"resets_at":"2026-08-23T08:00:00Z"}, + "seven_day_sonnet":{"utilization":50,"resets_at":"2026-08-23T08:00:00Z"}, + "extra_usage":{"is_enabled":true,"monthly_limit":100,"used_credits":12.5,"utilization":12.5,"currency":"USD"}, + "limits":[{"kind":"weekly","group":"opus","percent":80,"resets_at":"2026-08-23T08:00:00Z","scope":{"model":{"display_name":"Claude Opus"}}}] + }`)) + default: + http.NotFound(writer, request) + } + })) + defer server.Close() + + observation, 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 err != nil { + t.Fatal(err) + } + profile := observation.Profile + if profile.DisplayName != "Owner" || profile.Email != "owner@example.com" || + profile.OrganizationName != "Example Org" || profile.OrganizationRole != "admin" || + profile.WorkspaceRole != "member" || profile.OrganizationType != "claude_team" || + profile.UserRateLimitTier != "user-tier" || + profile.OrganizationRateLimitTier != "org-bootstrap-tier" || + profile.ExtraUsageEnabled == nil || !*profile.ExtraUsageEnabled { + t.Fatalf("profile = %#v", profile) + } + if observation.Usage.FiveHour == nil || observation.Usage.FiveHour.Utilization == nil || + *observation.Usage.FiveHour.Utilization != 25 || + observation.Usage.ExtraUsage == nil || observation.Usage.ExtraUsage.MonthlyLimit == nil || + len(observation.Usage.Limits) != 1 || observation.Header.Get("Request-Id") != "usage-request-one" { + t.Fatalf("usage/header = %#v / %v", observation.Usage, observation.Header) + } +} + +func TestObserveClaudeAccountRejectsIdentityMismatch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/profile": + _, _ = writer.Write([]byte(`{"account":{"uuid":"different-account"},"organization":{"uuid":"organization-one"}}`)) + case "/roles": + _, _ = writer.Write([]byte(`{}`)) + case "/bootstrap": + _, _ = writer.Write([]byte(`{}`)) + case "/usage": + _, _ = writer.Write([]byte(`{}`)) + } + })) + defer server.Close() + _, err := ObserveClaudeAccount(t.Context(), testClaudeExecutionCredential(), ClaudeOptions{ + ProfileURL: server.URL + "/profile", RolesURL: server.URL + "/roles", + BootstrapURL: server.URL + "/bootstrap", UsageURL: server.URL + "/usage", + HTTPClient: server.Client(), + }) + if !errors.Is(err, ErrClaudeCredentialIdentityChanged) { + t.Fatalf("identity mismatch error = %v", err) + } +} + +func TestObserveClaudeAccountKeepsUsageWhenProfileIsUnavailable(t *testing.T) { + credential := testClaudeExecutionCredential() + credential.Email = "owner@example.com" + credential.OrganizationName = "Stored Org" + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + switch request.URL.Path { + case "/profile": + http.Error(writer, "unavailable", http.StatusServiceUnavailable) + case "/roles": + _, _ = writer.Write([]byte(`{}`)) + case "/bootstrap": + _, _ = writer.Write([]byte(`{"oauth_account":{"account_uuid":"account-one","organization_uuid":"organization-one","organization_name":"Bootstrap Org","seat_tier":"team_standard"}}`)) + case "/usage": + _, _ = writer.Write([]byte(`{"five_hour":{"utilization":10,"resets_at":"2026-08-16T10:00:00Z"}}`)) + default: + http.NotFound(writer, request) + } + })) + defer server.Close() + + observation, 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 err != nil { + t.Fatal(err) + } + if observation.Profile.Email != credential.Email || + observation.Profile.OrganizationName != "Bootstrap Org" || + observation.Profile.SeatTier != "team_standard" || + observation.Usage.FiveHour == nil { + t.Fatalf("observation = %#v", observation) + } +} diff --git a/third_party/cpaembedded/embedded/live_test.go b/third_party/cpaembedded/embedded/live_test.go index 2bd20040..bb1c0314 100644 --- a/third_party/cpaembedded/embedded/live_test.go +++ b/third_party/cpaembedded/embedded/live_test.go @@ -96,14 +96,8 @@ func TestLiveCodexContract(t *testing.T) { func TestLiveClaudeContract(t *testing.T) { credentialFile := strings.TrimSpace(os.Getenv("CPA_LIVE_CLAUDE_CREDENTIAL_FILE")) model := strings.TrimSpace(os.Getenv("CPA_LIVE_CLAUDE_MODEL")) - if credentialFile == "" && model == "" { - t.Skip("CPA_LIVE_CLAUDE_CREDENTIAL_FILE and CPA_LIVE_CLAUDE_MODEL are not set") - } if credentialFile == "" { - t.Fatal("CPA_LIVE_CLAUDE_CREDENTIAL_FILE is required") - } - if model == "" { - t.Fatal("CPA_LIVE_CLAUDE_MODEL is required") + t.Skip("CPA_LIVE_CLAUDE_CREDENTIAL_FILE is not set") } raw, err := os.ReadFile(credentialFile) @@ -116,6 +110,26 @@ func TestLiveClaudeContract(t *testing.T) { t.Fatalf("parse live Claude credential: %v", err) } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + models, err := DiscoverClaudeModels(ctx, credential, ClaudeOptions{}) + if err != nil { + t.Fatalf("discover live Claude models: %v", err) + } + if len(models) == 0 { + t.Fatal("live Claude model list is empty") + } + if model == "" { + model = selectLiveClaudeModel(models) + } + observation, err := ObserveClaudeAccount(ctx, credential, ClaudeOptions{}) + if err != nil { + t.Fatalf("observe live Claude account: %v", err) + } + if observation.Profile.AccountUUID != credential.AccountUUID { + t.Fatal("live Claude observation returned another account") + } + nativePayload, err := json.Marshal(map[string]any{ "model": model, "max_tokens": 16, @@ -135,8 +149,6 @@ func TestLiveClaudeContract(t *testing.T) { t.Fatalf("build live Claude Responses request: %v", err) } - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() executor := NewClaudeHTTPExecutor() nativeResponse, err := executor.ExecuteCanonical(ctx, "live-claude-contract", credential, ExecuteRequest{ Model: model, Format: "claude", Payload: nativePayload, OriginalRequest: nativePayload, @@ -187,6 +199,17 @@ func TestLiveClaudeContract(t *testing.T) { } } +func selectLiveClaudeModel(models []ClaudeModel) string { + for _, preferred := range []string{"claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5-20251001"} { + for _, model := range models { + if model.ID == preferred { + return model.ID + } + } + } + return models[0].ID +} + func selectLiveModel(models []Model) string { for _, preferred := range []string{"gpt-5.2", "gpt-5.1-codex-max", "gpt-5.1-codex"} { for _, model := range models { diff --git a/third_party/cpaembedded/go.mod b/third_party/cpaembedded/go.mod index 24710e87..33ca1727 100644 --- a/third_party/cpaembedded/go.mod +++ b/third_party/cpaembedded/go.mod @@ -4,7 +4,7 @@ go 1.26.0 require ( github.com/andybalholm/brotli v1.0.6 - github.com/router-for-me/CLIProxyAPI/v7 v7.2.130 + github.com/router-for-me/CLIProxyAPI/v7 v7.2.133 ) require ( diff --git a/third_party/cpaembedded/go.sum b/third_party/cpaembedded/go.sum index 5a7c3765..783e7d94 100644 --- a/third_party/cpaembedded/go.sum +++ b/third_party/cpaembedded/go.sum @@ -71,8 +71,8 @@ github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthO github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= -github.com/router-for-me/CLIProxyAPI/v7 v7.2.130 h1:5xgjDxnluOHvvFRI7YDuPK4Fjql+I3GbrWVPZODDMG0= -github.com/router-for-me/CLIProxyAPI/v7 v7.2.130/go.mod h1:lTHwMAGajc1wKGQiRtDvYbwV0FWsM7sy+N0ZU5/gxJQ= +github.com/router-for-me/CLIProxyAPI/v7 v7.2.133 h1:TeFrEctzhSuPRNdYkiAFpFj4W1IICcvN+ot9HVnaEgw= +github.com/router-for-me/CLIProxyAPI/v7 v7.2.133/go.mod h1:lTHwMAGajc1wKGQiRtDvYbwV0FWsM7sy+N0ZU5/gxJQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= diff --git a/web/src/api/control/types.ts b/web/src/api/control/types.ts index e4c37c98..6519874f 100644 --- a/web/src/api/control/types.ts +++ b/web/src/api/control/types.ts @@ -165,8 +165,26 @@ export interface CredentialObservedWindowUsageDto { last_used_at_ms?: number } +export interface CredentialObservationAccountSummaryDto { + display_name?: string + email?: string + organization_name?: string + organization_type?: string + organization_role?: string + workspace_role?: string + organization_rate_limit_tier?: string + user_rate_limit_tier?: string + seat_tier?: string + billing_type?: string + extra_usage_enabled?: boolean + extra_usage_disabled_reason?: string + account_created_at_ms?: number + subscription_created_at_ms?: number +} + export interface CredentialObservationSnapshotDto { plan_summary: { name?: string } + account_summary?: CredentialObservationAccountSummaryDto quota_windows: CredentialQuotaWindowDto[] reset_credits_available?: number reset_credits?: CredentialResetCreditDto[] diff --git a/web/src/app/resources/credentials.ts b/web/src/app/resources/credentials.ts index a85b4584..a5a67544 100644 --- a/web/src/app/resources/credentials.ts +++ b/web/src/app/resources/credentials.ts @@ -146,11 +146,28 @@ const observationFields = [ ] as const const observationSnapshotFields = [ 'plan_summary', + 'account_summary', 'quota_windows', 'reset_credits_available', 'reset_credits', ] as const const planFields = ['name'] as const +const observationAccountFields = [ + 'display_name', + 'email', + 'organization_name', + 'organization_type', + 'organization_role', + 'workspace_role', + 'organization_rate_limit_tier', + 'user_rate_limit_tier', + 'seat_tier', + 'billing_type', + 'extra_usage_enabled', + 'extra_usage_disabled_reason', + 'account_created_at_ms', + 'subscription_created_at_ms', +] as const const resetCreditFields = ['expires_at_ms'] as const const resetCreditConsumeFields = [ 'status', @@ -343,8 +360,45 @@ function projectObservationSnapshot(value: unknown): CredentialObservationSnapsh ) { invalidResponse() } + let accountSummary: CredentialObservationSnapshotDto['account_summary'] + if (record.account_summary !== undefined) { + const accountRecord = projectRecord(record.account_summary) + assertNoSecretLikeFields(accountRecord, observationAccountFields) + accountSummary = {} + for (const field of [ + 'display_name', + 'email', + 'organization_name', + 'organization_type', + 'organization_role', + 'workspace_role', + 'organization_rate_limit_tier', + 'user_rate_limit_tier', + 'seat_tier', + 'billing_type', + 'extra_usage_disabled_reason', + ] as const) { + if (accountRecord[field] !== undefined) { + accountSummary[field] = projectString(accountRecord[field], { allowEmpty: false }) + } + } + if (accountRecord.extra_usage_enabled !== undefined) { + accountSummary.extra_usage_enabled = projectBoolean(accountRecord.extra_usage_enabled) + } + if (accountRecord.account_created_at_ms !== undefined) { + accountSummary.account_created_at_ms = projectEpochMilliseconds( + accountRecord.account_created_at_ms, + ) + } + if (accountRecord.subscription_created_at_ms !== undefined) { + accountSummary.subscription_created_at_ms = projectEpochMilliseconds( + accountRecord.subscription_created_at_ms, + ) + } + } return { plan_summary: planName === undefined ? {} : { name: planName }, + ...(accountSummary === undefined ? {} : { account_summary: accountSummary }), quota_windows: quotaWindows, ...(record.reset_credits_available === undefined ? {} diff --git a/web/src/features/groups/credentials/SubscriptionAccountCard.vue b/web/src/features/groups/credentials/SubscriptionAccountCard.vue index 71337e83..db65bf88 100644 --- a/web/src/features/groups/credentials/SubscriptionAccountCard.vue +++ b/web/src/features/groups/credentials/SubscriptionAccountCard.vue @@ -82,6 +82,7 @@ const supportsResetCredit = computed(() => ) const canReauthorize = computed(() => props.authorizationMethods.length > 0) const snapshot = computed(() => observation.value?.snapshot) +const accountSummary = computed(() => snapshot.value?.account_summary) // 最快恢复的额度窗口排在前面;缺少时长的上游窗口保持在最后。 const quotaWindows = computed(() => [...(snapshot.value?.quota_windows ?? [])].sort((left, right) => { @@ -115,6 +116,54 @@ const planLabel = computed(() => { } return labels[normalized] ?? plan }) +const accountDetails = computed(() => { + const account = accountSummary.value + if (!account) return [] + const rows: Array<{ label: string; value: string }> = [] + const add = (label: string, value: string | undefined): void => { + const normalized = value?.trim() + if (normalized) rows.push({ label: t(label), value: normalized }) + } + add('group.credentials.subscription.account.displayName', account.display_name) + add('group.credentials.subscription.account.organization', account.organization_name) + add('group.credentials.subscription.account.organizationType', account.organization_type) + add('group.credentials.subscription.account.organizationRole', account.organization_role) + add('group.credentials.subscription.account.workspaceRole', account.workspace_role) + add('group.credentials.subscription.account.seatTier', account.seat_tier) + add( + 'group.credentials.subscription.account.organizationRateLimitTier', + account.organization_rate_limit_tier, + ) + add('group.credentials.subscription.account.userRateLimitTier', account.user_rate_limit_tier) + add('group.credentials.subscription.account.billingType', account.billing_type) + if (account.extra_usage_enabled !== undefined) { + rows.push({ + label: t('group.credentials.subscription.account.extraUsage'), + value: t( + account.extra_usage_enabled + ? 'group.credentials.subscription.account.enabled' + : 'group.credentials.subscription.account.disabled', + ), + }) + } + add( + 'group.credentials.subscription.account.extraUsageDisabledReason', + account.extra_usage_disabled_reason, + ) + if (account.account_created_at_ms !== undefined) { + rows.push({ + label: t('group.credentials.subscription.account.createdAt'), + value: formatLocalInstant(account.account_created_at_ms, locale.value), + }) + } + if (account.subscription_created_at_ms !== undefined) { + rows.push({ + label: t('group.credentials.subscription.account.subscriptionCreatedAt'), + value: formatLocalInstant(account.subscription_created_at_ms, locale.value), + }) + } + return rows +}) const credentialExpiryTooltip = computed(() => { const expiresAtMS = props.item.account.expires_at_ms if (expiresAtMS === undefined) return undefined @@ -625,6 +674,16 @@ function runMenuAction(action: 'reauthorize' | 'toggle' | 'restore' | 'remove'):
+ +