merge(claude): 合并订阅渠道能力
This commit is contained in:
34 files changed
+2170
-69
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
Vendored
+5
-4
@@ -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
|
||||
```
|
||||
+9
-5
@@ -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) {
|
||||
|
||||
+699
@@ -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
|
||||
}
|
||||
+30
-1
@@ -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) {
|
||||
|
||||
+12
-4
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
+234
@@ -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)
|
||||
}
|
||||
}
|
||||
+32
-9
@@ -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 {
|
||||
|
||||
Vendored
+1
-1
@@ -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 (
|
||||
|
||||
Vendored
+2
-2
@@ -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=
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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
|
||||
? {}
|
||||
|
||||
@@ -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'):
|
||||
</div>
|
||||
|
||||
<div v-else class="subscription-account__detail-content">
|
||||
<section v-if="accountDetails.length" class="subscription-account__detail-section">
|
||||
<h3>{{ t('group.credentials.subscription.account.title') }}</h3>
|
||||
<div class="subscription-account__diagnostics">
|
||||
<dl v-for="detail in accountDetails" :key="detail.label">
|
||||
<dt>{{ detail.label }}</dt>
|
||||
<dd>{{ detail.value }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="subscription-account__detail-section">
|
||||
<h3>{{ t('group.credentials.subscription.activity') }}</h3>
|
||||
<div class="subscription-account__activity">
|
||||
|
||||
@@ -362,10 +362,10 @@ export default {
|
||||
subscription: {
|
||||
title: 'Accounts',
|
||||
emptyTitle: 'No accounts connected yet',
|
||||
emptyDescription: 'Connect a ChatGPT subscription account to begin routing requests.',
|
||||
emptyDescription: 'Connect a subscription account to begin routing requests.',
|
||||
connect: 'Connect account',
|
||||
connectDescription:
|
||||
'Sign in with ChatGPT; the account joins this group automatically once authorized.',
|
||||
'Sign in with the upstream subscription service; the account joins this group automatically once authorized.',
|
||||
reauthorizeDescription:
|
||||
'Updating the authorization for {account}. The new sign-in must belong to the same account.',
|
||||
confirmConnect: 'Add account',
|
||||
@@ -414,6 +414,24 @@ export default {
|
||||
lastUsed: 'Last used',
|
||||
freshUntil: 'Information valid until',
|
||||
lastError: 'Last sync error',
|
||||
account: {
|
||||
title: 'Account information',
|
||||
displayName: 'Display name',
|
||||
organization: 'Organization',
|
||||
organizationType: 'Organization type',
|
||||
organizationRole: 'Organization role',
|
||||
workspaceRole: 'Workspace role',
|
||||
seatTier: 'Seat tier',
|
||||
organizationRateLimitTier: 'Organization quota tier',
|
||||
userRateLimitTier: 'User quota tier',
|
||||
billingType: 'Billing type',
|
||||
extraUsage: 'Extra usage',
|
||||
extraUsageDisabledReason: 'Extra usage disabled reason',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
createdAt: 'Account created',
|
||||
subscriptionCreatedAt: 'Subscription created',
|
||||
},
|
||||
estimate: {
|
||||
title: 'Usage windows',
|
||||
window: 'Window',
|
||||
@@ -448,8 +466,8 @@ export default {
|
||||
refreshStartFailed: 'The credential refresh could not start. Try again shortly.',
|
||||
},
|
||||
observationError: {
|
||||
upstreamFailed: 'Unable to read Codex entitlement information',
|
||||
payloadInvalid: 'Codex returned unrecognized entitlement information',
|
||||
upstreamFailed: 'Unable to read upstream subscription entitlement information',
|
||||
payloadInvalid: 'The upstream returned unrecognized subscription entitlement information',
|
||||
},
|
||||
status: {
|
||||
available: 'Available',
|
||||
|
||||
@@ -363,10 +363,10 @@ export default {
|
||||
title: 'アカウント',
|
||||
emptyTitle: 'まだアカウントが接続されていません',
|
||||
emptyDescription:
|
||||
'ChatGPT のサブスクリプションアカウントを接続すると、リクエストのルーティングを開始できます。',
|
||||
'サブスクリプションアカウントを接続すると、リクエストのルーティングを開始できます。',
|
||||
connect: 'アカウントを接続',
|
||||
connectDescription:
|
||||
'ChatGPT でログインして認証が完了すると、アカウントは自動的にこのグループへ追加されます。',
|
||||
'上流のサブスクリプションサービスでログインして認証が完了すると、アカウントは自動的にこのグループへ追加されます。',
|
||||
reauthorizeDescription:
|
||||
'{account} の認証を更新します。新しいログインは同じアカウントである必要があります。',
|
||||
confirmConnect: 'アカウントを追加',
|
||||
@@ -417,6 +417,24 @@ export default {
|
||||
lastUsed: '最終利用',
|
||||
freshUntil: '情報の有効期限',
|
||||
lastError: '最終同期エラー',
|
||||
account: {
|
||||
title: 'アカウント情報',
|
||||
displayName: '表示名',
|
||||
organization: '組織',
|
||||
organizationType: '組織タイプ',
|
||||
organizationRole: '組織ロール',
|
||||
workspaceRole: 'ワークスペースロール',
|
||||
seatTier: 'シート種別',
|
||||
organizationRateLimitTier: '組織利用枠ティア',
|
||||
userRateLimitTier: 'ユーザー利用枠ティア',
|
||||
billingType: '請求タイプ',
|
||||
extraUsage: '追加利用量',
|
||||
extraUsageDisabledReason: '追加利用量の無効理由',
|
||||
enabled: '有効',
|
||||
disabled: '無効',
|
||||
createdAt: 'アカウント作成日時',
|
||||
subscriptionCreatedAt: 'サブスクリプション作成日時',
|
||||
},
|
||||
estimate: {
|
||||
title: '利用量ウィンドウ',
|
||||
window: '期間',
|
||||
@@ -453,8 +471,8 @@ export default {
|
||||
'認証情報の更新を開始できません。しばらくしてから再試行してください。',
|
||||
},
|
||||
observationError: {
|
||||
upstreamFailed: 'Codex の利用枠情報を取得できません',
|
||||
payloadInvalid: 'Codex から認識できない利用枠情報が返されました',
|
||||
upstreamFailed: '上流のサブスクリプション利用枠情報を取得できません',
|
||||
payloadInvalid: '上流から認識できないサブスクリプション利用枠情報が返されました',
|
||||
},
|
||||
status: {
|
||||
available: '利用可能',
|
||||
|
||||
@@ -353,9 +353,9 @@ export default {
|
||||
subscription: {
|
||||
title: '账号',
|
||||
emptyTitle: '尚未连接账号',
|
||||
emptyDescription: '连接一个 ChatGPT 订阅账号后即可开始路由请求。',
|
||||
emptyDescription: '连接一个订阅账号后即可开始路由请求。',
|
||||
connect: '连接账号',
|
||||
connectDescription: '登录 ChatGPT 完成授权后,账号会自动加入当前分组。',
|
||||
connectDescription: '登录上游订阅服务完成授权后,账号会自动加入当前分组。',
|
||||
reauthorizeDescription: '正在更新 {account} 的授权,新的登录必须属于同一账号。',
|
||||
confirmConnect: '添加账号',
|
||||
confirmConnectCount: '添加 {count} 个账号',
|
||||
@@ -402,6 +402,24 @@ export default {
|
||||
lastUsed: '最近使用',
|
||||
freshUntil: '信息有效期',
|
||||
lastError: '最近同步错误',
|
||||
account: {
|
||||
title: '账号信息',
|
||||
displayName: '显示名称',
|
||||
organization: '组织',
|
||||
organizationType: '组织类型',
|
||||
organizationRole: '组织角色',
|
||||
workspaceRole: '工作区角色',
|
||||
seatTier: '席位类型',
|
||||
organizationRateLimitTier: '组织额度等级',
|
||||
userRateLimitTier: '用户额度等级',
|
||||
billingType: '计费类型',
|
||||
extraUsage: '额外用量',
|
||||
extraUsageDisabledReason: '额外用量停用原因',
|
||||
enabled: '已启用',
|
||||
disabled: '未启用',
|
||||
createdAt: '账号创建时间',
|
||||
subscriptionCreatedAt: '订阅创建时间',
|
||||
},
|
||||
estimate: {
|
||||
title: '用量窗口',
|
||||
window: '窗口',
|
||||
@@ -431,8 +449,8 @@ export default {
|
||||
refreshStartFailed: '当前无法开始刷新凭据,请稍后重试。',
|
||||
},
|
||||
observationError: {
|
||||
upstreamFailed: '无法读取 Codex 权益信息',
|
||||
payloadInvalid: 'Codex 返回了无法识别的权益信息',
|
||||
upstreamFailed: '无法读取上游订阅权益信息',
|
||||
payloadInvalid: '上游返回了无法识别的订阅权益信息',
|
||||
},
|
||||
status: {
|
||||
available: '可用',
|
||||
|
||||
Reference in new issue
Block a user