Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions autoresearch.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail

cd "$(dirname "$0")/backend"
go test ./internal/service -run TestAutoresearchProfileConflictWorkload -count=1 -v
2 changes: 1 addition & 1 deletion backend/cmd/server/wire_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -726,7 +726,7 @@ type GatewayConfig struct {
OpenAIHTTP2 GatewayOpenAIHTTP2Config `mapstructure:"openai_http2"`
// ImageConcurrency: 图片生成独立并发限制配置(默认关闭)
ImageConcurrency ImageConcurrencyConfig `mapstructure:"image_concurrency"`
// UAAutoFetch: Claude/Codex 客户端身份版本后台拉取配置(默认关闭
// UAAutoFetch: Claude/Codex 客户端身份版本后台拉取配置(默认开启,拉取结果持久化到 setting 表,重启不丢
UAAutoFetch UAAutoFetchConfig `mapstructure:"ua_auto_fetch"`

// HTTP 上游连接池配置(性能优化:支持高并发场景调优)
Expand Down Expand Up @@ -1889,7 +1889,7 @@ func setDefaults() {
viper.SetDefault("gateway.image_concurrency.overflow_mode", ImageConcurrencyOverflowModeReject)
viper.SetDefault("gateway.image_concurrency.wait_timeout_seconds", 30)
viper.SetDefault("gateway.image_concurrency.max_waiting_requests", 100)
viper.SetDefault("gateway.ua_auto_fetch.enabled", false)
viper.SetDefault("gateway.ua_auto_fetch.enabled", true)
viper.SetDefault("gateway.ua_auto_fetch.interval", time.Hour)
viper.SetDefault("gateway.antigravity_fallback_cooldown_minutes", 1)
viper.SetDefault("gateway.antigravity_extra_retries", 10)
Expand Down
14 changes: 14 additions & 0 deletions backend/internal/handler/admin/account_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,20 @@ func (h *AccountHandler) ResetClaudeEnvironmentProfile(c *gin.Context) {
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
}

func (h *AccountHandler) MigrateClaudeEnvironmentProfileToV2(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
account, err := h.adminService.MigrateClaudeEnvironmentProfileToV2(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
}

func (h *AccountHandler) UpdateCodexEnvironmentProfileSettings(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
Expand Down
4 changes: 4 additions & 0 deletions backend/internal/handler/admin/admin_service_stub_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,10 @@ func (s *stubAdminService) ResetClaudeEnvironmentProfile(ctx context.Context, id
return &service.Account{ID: id, Platform: service.PlatformAnthropic, Type: service.AccountTypeOAuth, Extra: map[string]any{}}, nil
}

func (s *stubAdminService) MigrateClaudeEnvironmentProfileToV2(ctx context.Context, id int64) (*service.Account, error) {
return &service.Account{ID: id, Platform: service.PlatformAnthropic, Type: service.AccountTypeOAuth, Extra: map[string]any{}}, nil
}

func (s *stubAdminService) UpdateCodexEnvironmentProfileSettings(ctx context.Context, id int64, updates map[string]any) (*service.Account, error) {
return &service.Account{ID: id, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Extra: updates}, nil
}
Expand Down
22 changes: 11 additions & 11 deletions backend/internal/handler/auth_current_user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,19 @@ func TestAuthHandlerGetCurrentUserReturnsProfileCompatibilityFields(t *testing.T
AvatarURL: "https://cdn.example.com/linuxdo.png",
AvatarSource: "remote_url",
},
identities: []service.UserAuthIdentityRecord{
{
ProviderType: "linuxdo",
ProviderKey: "linuxdo",
ProviderSubject: "linuxdo-subject-31",
VerifiedAt: &verifiedAt,
Metadata: map[string]any{
"username": "linuxdo-handle",
"avatar_url": "https://cdn.example.com/linuxdo.png",
},
identities: []service.UserAuthIdentityRecord{
{
ProviderType: "linuxdo",
ProviderKey: "linuxdo",
ProviderSubject: "linuxdo-subject-31",
VerifiedAt: &verifiedAt,
Metadata: map[string]any{
"username": "linuxdo-handle",
"avatar_url": "https://cdn.example.com/linuxdo.png",
},
},
}
},
}

handler := &AuthHandler{
userService: service.NewUserService(repo, nil, nil, nil),
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/handler/openai_gateway_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ import (
"testing"
"time"

coderws "github.com/coder/websocket"
"github.com/dofastted/claude2api/internal/config"
pkghttputil "github.com/dofastted/claude2api/internal/pkg/httputil"
"github.com/dofastted/claude2api/internal/pkg/pagination"
"github.com/dofastted/claude2api/internal/server/middleware"
"github.com/dofastted/claude2api/internal/service"
coderws "github.com/coder/websocket"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down
2 changes: 2 additions & 0 deletions backend/internal/handler/openai_images_failover_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ func TestOpenAIGatewayHandlerImages_ServerErrorFailsOverAndReturnsClearErrorWhen
Concurrency: 0,
Priority: 0,
Credentials: map[string]any{"access_token": "token-1"},
Extra: map[string]any{"codex_single_environment": false},
},
{
ID: 2,
Expand All @@ -109,6 +110,7 @@ func TestOpenAIGatewayHandlerImages_ServerErrorFailsOverAndReturnsClearErrorWhen
Concurrency: 0,
Priority: 1,
Credentials: map[string]any{"access_token": "token-2"},
Extra: map[string]any{"codex_single_environment": false},
},
}
accountRepo := openAIImagesFailoverAccountRepo{accounts: accounts}
Expand Down
9 changes: 9 additions & 0 deletions backend/internal/repository/migrations_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,15 @@ var migrationChecksumCompatibilityRules = map[string]migrationChecksumCompatibil
"119_enforce_payment_orders_out_trade_no_unique.sql": newMigrationChecksumCompatibilityRule("0bbe809ae48a9d811dabda1ba1c74955bd71c4a9cc610f9128816818dfa6c11e", "ebd2c67cce0116393fb4f1b5d5116a67c6aceb73820dfb5133d1ff6f36d72d34"),
"120_enforce_payment_orders_out_trade_no_unique_notx.sql": newMigrationChecksumCompatibilityRule("34aadc0db59a4e390f92a12b73bd74642d9724f33124f73638ae00089ea5e074", "e77921f79d539bc24575cb9c16cbe566d2b23ce816190343d0a7568f6a3fcf61", "707431450603e70a43ce9fbd61e0c12fa67da4875158ccefabacea069587ab22", "04b082b5a239c525154fe9185d324ee2b05ff90da9297e10dba19f9be79aa59a"),
"123_fix_legacy_auth_source_grant_on_signup_defaults.sql": newMigrationChecksumCompatibilityRule("2ce43c2cd89e9f9e1febd34a407ed9e84d177386c5544b6f02c1f58a21129f57", "6cd33422f215dcd1f486ab6f35c0ea5805d9ca69bb25906d94bc649156657145"),
// 仓库命名清理(Sub2API -> claude2api)仅改动迁移文件首行/注释文字,DDL 与 schema 完全不变;
// 旧库记录的是清理前 checksum,放行以兼容历史部署,避免要求人工修 checksum 或重建数据库。
"001_init.sql": newMigrationChecksumCompatibilityRule("dc7c7282d64c54248d21a39673c651f50529b712643f140e1c6150adccceb986", "9ba0369779484625edcea7a7d1d4582397e31546db9149b05004990a3f16c630"),
"002_account_type_migration.sql": newMigrationChecksumCompatibilityRule("02fa8b90d345b288a0a1e3f2d5a8a3a569827d394f5ddae7bcadc1e7e95d89ea", "aad3816e44f58ff007ea4df8092aae580f3f85180314c1deb1b1054b20892bbf"),
"003_subscription.sql": newMigrationChecksumCompatibilityRule("518aac1e666fb0358f9ac0252117795dfd08f98e4c68f2ae5c8513636a2d2a9f", "4642fcb1ccd7954b1d3eef8f795cfba2ce21431257346cc5a7568cde61a60b13"),
"052_migrate_upstream_to_apikey.sql": newMigrationChecksumCompatibilityRule("7662a2b3ace749ae153d9033fc4482fff40df198977a0fef38762afe85e0233f", "d2ea657ec24995664a8ddc1bfb9c3fe317646c7bcd12517dee8478bc6c36244a"),
// 038 与上述四个文件同属 9afaa312 命名清理:其首行注释及一处 owner 归一化 WHERE 条件由 sub2api 改为 claude2api,
// 改变了 trimmed-content checksum,但该迁移在历史库早已应用、不会重跑,故 schema/数据不受影响,仅需放行 checksum。
"038_ops_errors_resolution_retry_results_and_standardize_classification.sql": newMigrationChecksumCompatibilityRule("3281353a2e475fa22932b101160fc25bdaaaf4a6051ca5f4f974c21ec39b85a2", "4cc121d97c7f59e9def9397b7d0314d4dfbfe4cd831698359456dd49bf995ece"),
}

// ApplyMigrations 将嵌入的 SQL 迁移文件应用到指定的数据库。
Expand Down
3 changes: 2 additions & 1 deletion backend/internal/server/api_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,7 @@ func TestAPIContracts(t *testing.T) {
"image_size_source": null,
"image_size_breakdown": null,
"media_type": null,
"local_intercept": false,
"cache_ttl_overridden": false,
"created_at": "2025-01-02T03:04:05Z",
"user_agent": null
Expand Down Expand Up @@ -1033,7 +1034,7 @@ func TestAPIContracts(t *testing.T) {
"google_oauth_frontend_redirect_url": "/auth/oauth/callback",
"site_name": "claude2api",
"site_logo": "",
"site_subtitle": "Subscription to API Conversion Platform",
"site_subtitle": "Local-first AI API gateway",
"api_base_url": "",
"api_key_acl_trust_forwarded_ip": false,
"contact_info": "",
Expand Down
1 change: 1 addition & 0 deletions backend/internal/server/routes/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
accounts.PUT("/:id/claude-environment-profile", h.Admin.Account.UpdateClaudeEnvironmentProfile)
accounts.PUT("/:id/claude-environment-profile/slot", h.Admin.Account.UpdateClaudeEnvironmentProfileSlot)
accounts.POST("/:id/claude-environment-profile/reset", h.Admin.Account.ResetClaudeEnvironmentProfile)
accounts.POST("/:id/claude-environment-profile/migrate-v2", h.Admin.Account.MigrateClaudeEnvironmentProfileToV2)
accounts.PUT("/:id/codex-environment-profile/settings", h.Admin.Account.UpdateCodexEnvironmentProfileSettings)
accounts.PUT("/:id/codex-environment-profile", h.Admin.Account.UpdateCodexEnvironmentProfile)
accounts.PUT("/:id/codex-environment-profile/slot", h.Admin.Account.UpdateCodexEnvironmentProfileSlot)
Expand Down
38 changes: 38 additions & 0 deletions backend/internal/service/admin_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ type AdminService interface {
UpdateClaudeEnvironmentProfile(ctx context.Context, id int64, profile *ClaudeEnvironmentProfile) (*Account, error)
UpdateClaudeEnvironmentProfileSlot(ctx context.Context, id int64, slot EnvironmentClass, overrides *ClaudeEnvironmentProfile) (*Account, error)
ResetClaudeEnvironmentProfile(ctx context.Context, id int64) (*Account, error)
MigrateClaudeEnvironmentProfileToV2(ctx context.Context, id int64) (*Account, error)
UpdateCodexEnvironmentProfileSettings(ctx context.Context, id int64, updates map[string]any) (*Account, error)
UpdateCodexEnvironmentProfile(ctx context.Context, id int64, profile *CodexEnvironmentProfile) (*Account, error)
UpdateCodexEnvironmentProfileSlot(ctx context.Context, id int64, slot EnvironmentClass, overrides *CodexEnvironmentProfile) (*Account, error)
Expand Down Expand Up @@ -3051,6 +3052,43 @@ func (s *adminServiceImpl) ResetClaudeEnvironmentProfile(ctx context.Context, id
return s.accountRepo.GetByID(ctx, id)
}

// MigrateClaudeEnvironmentProfileToV2 将账号的 legacy 环境 profile pool 原地升级为 schema v2,
// 保留已有设备身份(client_id/device_id/session_seed)以维持上游指纹连续。已是 v2 则报错。
func (s *adminServiceImpl) MigrateClaudeEnvironmentProfileToV2(ctx context.Context, id int64) (*Account, error) {
account, err := s.accountRepo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if account == nil || !account.IsAnthropicOAuthOrSetupToken() {
return nil, errors.New("claude environment profile is only supported for Anthropic OAuth/SetupToken accounts")
}
pool, err := DecodeClaudeEnvironmentProfilePool(account.Extra[claudeEnvironmentProfilePoolKey])
if err != nil {
return nil, err
}
if pool == nil {
return nil, errors.New("claude environment profile pool not found; nothing to migrate")
}
if pool.IsV2() {
return nil, errors.New("claude environment profile pool is already v2")
}
upgraded := upgradeLegacyClaudePoolToV2(pool, claude.CLICurrentVersion)
if err := upgraded.Normalize(); err != nil {
return nil, err
}
// 清理可能存在的遗留单 profile 键,避免迁移后 legacy fallback 误触发。
if deleter, ok := s.accountRepo.(accountExtraKeyDeleter); ok {
if err := deleter.DeleteExtraKeys(ctx, id, []string{claudeEnvironmentProfileKey}); err != nil {
return nil, err
}
}
if err := s.accountRepo.UpdateExtra(ctx, id, map[string]any{claudeEnvironmentProfilePoolKey: upgraded}); err != nil {
return nil, err
}
slog.Info("claude_environment_profile_pool_migrated_v2", "account_id", id)
return s.accountRepo.GetByID(ctx, id)
}

func (s *adminServiceImpl) UpdateCodexEnvironmentProfileSettings(ctx context.Context, id int64, updates map[string]any) (*Account, error) {
account, err := s.accountRepo.GetByID(ctx, id)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/service/api_key_auth_cache_impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import (
"math/rand/v2"
"time"

"github.com/dofastted/claude2api/internal/config"
"github.com/dgraph-io/ristretto"
"github.com/dofastted/claude2api/internal/config"
)

const apiKeyAuthSnapshotVersion = 12 // v12: include exclusive group authorization fields
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/service/api_key_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ import (
"sync"
"time"

"github.com/dgraph-io/ristretto"
"github.com/dofastted/claude2api/internal/config"
infraerrors "github.com/dofastted/claude2api/internal/pkg/errors"
"github.com/dofastted/claude2api/internal/pkg/ip"
"github.com/dofastted/claude2api/internal/pkg/pagination"
"github.com/dofastted/claude2api/internal/pkg/timezone"
"github.com/dgraph-io/ristretto"
"golang.org/x/sync/singleflight"
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -769,9 +769,9 @@ func TestHasUserPlatformQuotaLimit(t *testing.T) {
daily := 5.0

tests := []struct {
name string
setup func() *BillingCacheService
want bool
name string
setup func() *BillingCacheService
want bool
}{
{
name: "has_limit",
Expand Down
1 change: 0 additions & 1 deletion backend/internal/service/channel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,6 @@ func TestSupportedModels_WildcardExpandedFromPricing(t *testing.T) {
}
}


func TestSupportedModels_MissingPricingKeepsNilPricing(t *testing.T) {
ch := &Channel{
ModelMapping: map[string]map[string]string{
Expand Down
51 changes: 51 additions & 0 deletions backend/internal/service/claude_environment_profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/dofastted/claude2api/internal/pkg/claude"
"github.com/dofastted/claude2api/internal/pkg/clientidentity"
"github.com/dofastted/claude2api/internal/pkg/tlsfingerprint"
"github.com/google/uuid"
)

Expand All @@ -25,6 +26,8 @@ const (
claudeEnvironmentProfileSourceLearnedDesktop = "learned_verified_desktop"
claudeEnvironmentProfileSourceAdmin = "admin"
claudeEnvironmentProfileSourceSimulated = "simulated"
claudeEnvironmentCachePolicyPreserveClient = "preserve_client"
claudeEnvironmentCachePolicyProfileManaged = "profile_managed"
)

type ClaudeClientFamily string
Expand All @@ -51,6 +54,8 @@ type ClaudeEnvironmentProfile struct {
ClientType string `json:"client_type"`
Headers map[string]string `json:"headers"`
BetaSet []string `json:"beta_set,omitempty"`
TLSProfile string `json:"tls_profile,omitempty"`
CachePolicy string `json:"cache_policy,omitempty"`
FrozenAt time.Time `json:"frozen_at,omitempty"`
TelemetryPolicy string `json:"telemetry_policy"`
CreatedAt time.Time `json:"created_at"`
Expand Down Expand Up @@ -102,6 +107,12 @@ func ValidateClaudeEnvironmentProfile(profile *ClaudeEnvironmentProfile) error {
if strings.TrimSpace(profile.SessionSeed) == "" {
return fmt.Errorf("claude environment profile session_seed is required")
}
if strings.TrimSpace(profile.TLSProfile) == "" {
profile.TLSProfile = defaultClaudeEnvironmentTLSProfileForFamily(profile.Family)
}
if strings.TrimSpace(profile.CachePolicy) == "" {
profile.CachePolicy = claudeEnvironmentCachePolicyPreserveClient
}
return nil
}

Expand All @@ -125,12 +136,52 @@ func defaultClaudeCodeEnvironmentProfile(identityRegistry *clientidentity.Regist
RuntimeVersion: strings.TrimPrefix(headers["X-Stainless-Runtime-Version"], "v"),
ClientType: "cli",
Headers: map[string]string{},
TLSProfile: tlsfingerprint.ProfileNameClaudeCLIDefault,
CachePolicy: claudeEnvironmentCachePolicyPreserveClient,
TelemetryPolicy: claudeEnvironmentTelemetryPolicyLocalAck,
CreatedAt: now,
UpdatedAt: now,
}
}

func defaultClaudeEnvironmentTLSProfileForFamily(family ClaudeClientFamily) string {
switch family {
case ClaudeClientFamilyDesktop:
return tlsfingerprint.ProfileNameClaudeDesktopDefault
default:
return tlsfingerprint.ProfileNameClaudeCLIDefault
}
}

func resolveClaudeEnvironmentTLSProfile(profile *ClaudeEnvironmentProfile) *tlsfingerprint.Profile {
if profile == nil {
return nil
}
return tlsfingerprint.BuiltInProfileByName(strings.TrimSpace(profile.TLSProfile))
}

type claudeEnvironmentTLSProfileContextKey struct{}

func attachClaudeEnvironmentTLSProfileToRequest(req *http.Request, profile *tlsfingerprint.Profile) *http.Request {
if req == nil || profile == nil {
return req
}
return req.WithContext(context.WithValue(req.Context(), claudeEnvironmentTLSProfileContextKey{}, profile))
}

func tlsProfileForRequest(req *http.Request, fallback *tlsfingerprint.Profile) *tlsfingerprint.Profile {
if req != nil {
if profile, ok := req.Context().Value(claudeEnvironmentTLSProfileContextKey{}).(*tlsfingerprint.Profile); ok && profile != nil {
return profile
}
}
return fallback
}

func claudeEnvironmentProfileManagesCache(profile *ClaudeEnvironmentProfile) bool {
return isV2ClaudeEnvironmentProfile(profile) && strings.TrimSpace(profile.CachePolicy) == claudeEnvironmentCachePolicyProfileManaged
}

func classifyClaudeClientFamily(headers http.Header, _ []byte) ClaudeClientFamily {
uaRaw := strings.TrimSpace(headers.Get("User-Agent"))
if IsGenericProbeUserAgent(uaRaw) {
Expand Down
Loading
Loading